HarfBuzz Study: A C library written in C++
behdad
August 2, 2022
Introduction
For the most part HarfBuzz is a library designed around one function: hb_shape(). Various design constraints and criteria however, have resulted in a library with over 400 functions so far, and still growing. To fully grasp why things are done the way they are done, in this document we look at some of the main design principles that drove the API as a C library. Moreover, we look at what writing such a library with C++, specially in the mid 2000s, involved.
C API
HarfBuzz is a C library and as such constrained by design principles common to C libraries. To make it possible to interact with other libraries and with application code for example, types in HarfBuzz are implemented as either value types or object types, with object lifecycle management API to facilitate sharing objects. Types and functions are also multi-thread safe when possible. Finally, error-handling within the library is designed to facilitate robust coding without cluttering the user code with error-handling conditionals all over the code.
Objects, Types, and Object Lifecycle Management
Data types in HarfBuzz are of two kinds: non-opaque pass-by-value types, and opaque heap-allocated types. This kind of separation is common in C libraries that have to provide API/ABI compatibility (almost) indefinitely.
Value Types
The non-opaque pass-by-value types include integer types, enums, and small structs. Exposing structs in the API makes it impossible to expand them in the future, and as such should be reserved for cases where it’s extremely inefficient to do otherwise. Structs like hb_glyph_info_t and hb_glyph_position_t fall into that category. For such non-opaque structs where future extensibility may deem necessary, reserved members will be included to hold space for possible future members. As such, it’s important to provide equal(), and hash() methods for such structs such that users of the API can effectively deal with the type, for example inserting them in a hash-table, without having to adapt their code to future changes.
Object Types
Opaque struct types are used for what we will loosely call objects. This doesn’t have much to do with OOP, but some of the concepts are similar. In HarfBuzz, all object types provide certain lifecycle management APIs. Objects are reference-counted, and constructed with various create() methods (C functions really), referenced via reference() and dereferenced using destroy() methods. So, for example, the hb_buffer_t object has hb_buffer_create() as constructor, hb_buffer_reference(), and hb_buffer_destroy().
Object types also have set_user_data() / get_user_data() methods that can be used to attach arbitrary data to the object. This is useful in tying the lifecycle of pieces of data external to the library, to HarfBuzz objects, and to receive destroy notification when a HarfBuzz object is destroyed, as well as for language bindings.
All object lifecycle management APIs are threadsafe, even when the object itself is not. It is also permissible to reference() / destroy() the NULL value. Some objects are threadsafe after construction and setting up. The pattern there is to create() the object, make a few set_*() calls to setup the object, and then use it without further modification. To enforce that, such objects can be explicitly marked immutable().
Finally, constructors (and as much of the API as possible) will never return NULL. Instead, upon allocation errors, they will return certain “empty” object singletons which are inert and safe (even if useless) to pass around. This avoids having to check for NULL pointers all over the code. This “empty” object can typically be accessed using the get_empty() method of the object type. We will discuss these “empty” objects in detail later.
C++ implementation
When the current HarfBuzz rewrite was started in C++, there was a strong desire to not link with libstdc++. Whether that decision is still valid or not, we still follow that. As such, we also do not use exceptions. Neither do we use rtti.
Initially we were not using any C++ standard library features or headers. Recently we have started using some header-only features instead of duplicating them in our code-base.
The features of C++ that initially attracted us to the language were:
operator overloading to automatically perform endianness conversion on custom integer types when parsing OpenType structures,
.method() syntax instead of long_c_function_names(),
better const-correctness enforcement via function overloading,
Over time, using templates simplified our codebase significantly, and we found that the C++ coding style does produce faster code than equivalent C-style code.
Not linking to the standard library comes with severe consequences though. It means that we cannot use any facilities of the standard library, even the header-only portions, that throw exceptions. So using std::vector for example, or std::function even, are not possible.
Sanity checks
We have certain tests that sanity-check the built library to enforce some quality properties, to make sure we don’t inadvertently use features of C++ that we do not intend to:
libstdc++
The first check is to make sure we do not link to libstdc++.
Static initializers
For performance reasons, we also check that our C++ code does not incur any static initialization, since those need to be run at library link time and slow down any program startup time.
Symbols
We check that the symbols the library exports are the same set as those the public header files export. This check is not C++-specific, but in C++ code it is easier to accidentally export unintended symbols. Note that we compile the library with -fvisibility-inlines-hidden.
Header files
There are certain properties header files of every C library should hold. We found that it was easy to violate some of these properties by mistake, so we have written tests to enforce them.
HB_BEGIN_DECLS / HB_END_DECLS
For header files of a C library to be usable by C++ client code, they must be wrapped in some version of the following BEGIN_DECLS / END_DECLS macros:
#ifndef HB_BEGIN_DECLS
# ifdef __cplusplus
# define HB_BEGIN_DECLS extern "C" {
# define HB_END_DECLS }
# else /* !__cplusplus */
# define HB_BEGIN_DECLS
# define HB_END_DECLS
# endif /* !__cplusplus */
#endif
So those will be defined in one of your top headers, then every header will have:
HB_BEGIN DECLS
…C function declarations…
HB_END_DECLS
We check that all our headers follow this pattern.
HB_EXTERN
For C libraries to be most useful on Windows platforms, all function declarations should be prefixed by some overridable EXTERN macro:
#ifndef HB_EXTERN
#define HB_EXTERN extern
#endif
Then for example:
HB_EXTERN hb_direction_t
hb_direction_from_string (const char *str, int len);
We check that our headers follow this rule on all symbols.
Header guards
All C header files need a unique header guard of the form:
#ifndef HB_FILE_NAME_H
#define HB_FILE_NAME_H
…
#endif /* HB_FILE_NAME_H */
We check that all our headers have the correct header guard.
Include order
Finally, we enforce that all public headers include hb.h or hb-common.h before any other header. This reduces surprises. We check for that as well.