Perhaps I wasn’t paying enough attention, but private inheritance never really made much sense to me. I always brushed it off as some obscure C++ feature that’s only used in a handful of projects across the world. However, turns out it can be pretty useful.
Technically, private inheritance can be described as anonymous composition. In simpler terms, private inheritance is a way to have a member variable that doesn’t have a name.
struct S : private std::string {
const std::string &string() const {
return static_cast<const std::string &>(*this);
}
size_t length() const {
return std::string::size();
}
};
In the code above, we define a structure S that has got a std::string value somewhere inside. This sample also shows how we’d access this value.
Of course, we could have used a member variable. This would probably be a better choice here since it would let us get rid of this cumbersome syntax. The only possible disadvantage of this approach is that you have to come up with a name for this variable, and we all know how difficult that is.
Alright, that wasn’t that useful or interesting. Let’s take a look at an actually meaningful example:
struct no_copy {
no_copy() = default;
~no_copy() = default;
// remove copying operations
no_copy(const no_copy &) = delete;
no_copy &operator=(const no_copy &) = delete;
// allow move operations
no_copy(no_copy &&) = default;
no_copy &operator=(no_copy &&) = default;
};
This structure doesn’t contain any data, just prohibits copying by deleting the copy constructor and copy assignment operations. This class can be used in two ways:
struct A {
no_copy move_only;
int some_data;
int some_more_data;
};
struct B : private no_copy {
int some_data;
int some_more_data;
};
These two classes are identical in a sense that their objects can’t be copied and contain two int member variables.
Since no_copy doesn’t contain any data you’d think it wouldn’t take up any space. This would mean that the objects of types A and B would be unaffected by this no_copy thing and their memory layouts would be the same, right?
Nope, C++ has to guarantee that all member variables have distinct memory addresses. Because of that this move_only field is going to take up some space, making the objects of class A slightly larger than they need to be.
But what about B? There’s something in C++ called empty base optimization. Since you’re reading this, you can probably read so go read the page I linked. Here’s a condensed version: empty base objects don’t take up any space. Oh, and read this page too.
This optimization can be used not just by our little no_copy class (by the way, boost provides something called boost/core/noncopyable which might be better depending on your use case). This is what std::tuple is based on, for example. Keep in mind, though, that this optimization can be pretty brittle.
Another interesting example of a class that can benefit from this optimization is std::unique_ptr. In a lot of cases, the Deleter class doesn’t contain any data. In these cases, by storing this deleter as a private base, the std::unique_ptr object can, for all intents and purposes, be treated as if it was just a simple pointer. I’m talking about memory layout, of course.
This is particularly useful with C APIs, where you have dedicated functions that destroy the passed object and free the memory. Let’s take a look at how we could use a smart pointer to manage such “C” objects.
// functions provided by some C API
A* A_init();
void A_free(A *);
int case1(){
std::unique_ptr<A, std::function<void(A*)>> p(A_init(), A_free);
// ...
// do something with p
// ...
return sizeof(p); // a nice way of checking object sizes on godbolt.org btw
}
struct ADeleter {
void operator()(A *a){ A_free(a); }
};
// using a dedicated Deleter class
int case2(){
std::unique_ptr<A, ADeleter> p(A_init());
// ...
// again, do something with p
// ...
return sizeof(p);
}
// C++20, using a lambda as a deleter
int case3(){
std::unique_ptr<A, decltype([](A *a){ A_free(a); })> p(A_init());
// ...
// again, do something with p
// ...
return sizeof(p);
}
As you can guess, case2 and case3 both return 8 (when using a 64-bit build, of course). But how big will the pointer be in case1? 40 bytes. That’s a lot of bytes for a single pointer, even a smart one. Perhaps using std::function is unnecessarily grotesque, but even if you use a simple function pointer, the resulting size will be 16 bytes, which is definitely less but still not perfect.
Hopefully, this post gave you some idea why private inheritance is a thing. Let’s sum up a little: