True encapsulation in C++
2019-04-25 Permalink
Access specifiers are taught as the encapsulation mechanism in C++. However, I’ve mentioned before how poorly they perform their function.
For example:
class A { public: // ... public interface ... private: void f(C); B b; };
In order for this code to compile, B
must be previously defined and C
must be previously declared. Both of these types, as well as the function f
and the layout of the private members of A
are an implementation detail. Yet they leak out to the users of A
.[1] This in turn has a negative impact on compile times (of incremental builds in particular), makes headers harder to comprehend,[2] and makes it harder to change the implementation of A
without breaking the ABI.
In the following text I refer to this approach as the ‘traditional’ method. I dwell deeper into alternative approaches to encapsulation in C++ and present their pros and cons. For the purpose of comparing various encapsulation mechanisms, I evaluate them based on the following criteria:
- Compile time dependencies on private data or functions.
- Dynamic memory allocations and fragmentation.
- Runtime cost of function calls.
- ABI stability.
I’m trying to address here only well-contained and well-encapsulated software components, potentially dynamically linked ones. Therefore it’s assumed that A
has no template or inline functions and no compiler generated constructors or operators. These features require the innards of the class to be accessible at compile time by design,[3] thus contradicting our encapsulation efforts.
Friendly statics
Moving the private functions out of the class definition is easy. One just needs to put them in a friendly class of A
:
class A { public: // ... public interface ... private: friend class A_impl; B b; };
And in the translation unit implementing A
:
struct A_impl { static void f(A &that, C c) { // ... work on that and c ... } }; A::A() { A_impl::f(*this, ...); }
This hides the private member function declarations as well as all the types they depend on, at a cost of some verbosity.
Pimpl idiom
The next improvement is to hide the private data members. An often used way of doing that is the so-called ‘pimpl idiom’. The private data members are extracted to a separate struct visible only within the translation units implementing the class. The public interface works with the data in that struct. Applying it to our example:
class A { public: // ... public interface ... private: friend struct A_impl; std::unique_ptr<A_impl> _; };
And in the translation unit:
struct A_impl { B b; static void f(A &that, C c) { ... } }; A::A() : _(new A_impl) { A_impl::f(*this, ...); }
This entirely hides the implementation details of A
, and makes the ABI immune to changes in A
’s implementation.
Unfortunately the downside of this approach is the incurred memory fragmentation: if A
’s functionality is extended and each class in the hierarchy uses the same methodology to hide its own private members:
class AA : public A { public: // ... public interface ... private: friend struct AA_impl; std::unique_ptr<AA_impl> _; };
And an array of AA
s is allocated:
AA a[10];
Then each instance of AA
ends up requiring two dynamic memory allocations, 20 in total, whereas the original code required none.[4]
Pimpl is in essence similar to C libraries returning opaque pointers to their internally allocated structures. For example, SQLite has a function to allocate and initialize an sqlite3
object whose internals are hidden:
int sqlite3_open(const char *filename, sqlite3 **ppDb);
and the rest of the functions operate on that opaque sqlite3
pointer:
int sqlite3_db_config(sqlite3*, int op, ...);
The only difference is that such C code can have multiple copies of the sqlite3
pointer laying around, each operating on the connection directly, whereas the C++ pimpl code would require an extra indirection for each such operation.
Polymorphism
Another frequently used method is to hide the implementation behind an abstract interface. A factory function is provided to create an instance of the concrete implementation:
class A { public: // ... public interface ... (everything is pure virtual) }; A *make_A();
And in the translation unit:
class A_impl : public A { // ... implementation ... void f(C c) { ... } B b; }; A *make_A() { return new A_impl; }
This has slightly different trade-offs compared to pimpl:
On the plus side different implementations of
A
can be provided, with all the implied benefits for testing and future proofing. When extendingA
’s functionality like in the example withAA
above, only one dynamic memory allocation per instance is required.However, code that extends
A
like that would also need to have a compile time dependency onA_impl
and its implementation details. Additionally, all public interface function calls go through a level of indirection through the vtable.
An array of A
s would now be defined like
A *a[10];
And populating it would require one memory allocation per instance, even though it’s technically homogeneous.
Opaque char[]
This is the least used method.[5]
Since it was assumed that A
has no inline or template functions, the only thing that the compiler cares about when creating instances of A
is the size of the memory required and its alignment. Realizing this one can tell the compiler exactly those two pieces of information:
class A { public: // ... public interface ... alignas(void*) char _[sizeof(void*)*8]; // reserved for private use };
And in the translation unit:
struct A_impl { B b; }; static_assert(sizeof(A_impl) <= sizeof(A::_), "reserved memory is too small"); static void f(A &that, C c) { A_impl *p = (A_impl*)&that._[0]; // ... work on that, p and c ... } A::A() { new(_) A_impl; // initialize private data f(*this, ...); }
Comparing to C again, this is analogous to how C structures have reserved
or internal
fields in their publicly exposed headers (e.g. see OVERLAPPED
structure of WinAPI).
Sure, this method is the most verbose and the least ‘C++ style’. There’s also a risk that, if used throughout, too much memory would be reserved but unused. Nonetheless, it checks off each of the criteria set above:
- No compile time dependencies on private data.
- No dynamic memory allocations.
- Public function calls are resolved at link-time.
- It’s immune to ABI changes as long as
A_impl
fits in the reserved memory block.
At this point, a natural question to ask is—“why use classes at all then”?
Because a C++ programmer would still benefit from a lot of built-in C++ machinery, including constructors, destructors, virtual tables, RTTI and casts. They all are tricky to do otherwise (even though C programmers would disagree).
Conclusion
I presented a number of alternatives to the ‘traditional’ encapsulation method. They all improve upon the encapsulation provided by access-specifiers at a cost of verbosity. This raises the question of whether access-specifiers are at all needed in the language?
A Fantasy Language™ would have a way to express the equivalent of the above mechanisms in an easier manner. For example it would be useful if the size of the required memory could be pulled during link-time. Another direction of research is to decouple object layouts from compile time features like inheritance or destructors. Describing arbitrary object layouts would facilitate data-oriented code.
To conclude: being a close-to-the-metal language, C++ gives us the tools to do anything, albeit the better solutions aren’t necessarily the prettiest ones.
Footnotes
- C++ headers are, in a sense, interfaces.
- C++ headers frequently serve a substitute for documentation.
- If link-time optimizations are used then there’s one less thing to say in favor of the ‘traditional’ method.
- The memory of the array is assumed to be managed by the user of the class. E.g. it can be statically allocated or pooled.
- As far as C++ libraries go, I’ve seen it used only in pugixml.