Having one root object limits what you can do and what the compiler can do, without much payoff.
A common root class makes it possible to create containers-of-anything and extract what they are with a dynamic_cast
, but if you need containers-of-anything then something akin to boost::any
can do it without a common root class. And boost::any
also supports primitives -- it can even support the small buffer optimization and leave them almost "unboxed" in Java parlance.
C++ supports and thrives on value types. Both literals, and programmer written value types. C++ containers efficiently store, sort, hash, consume and produce value types.
Inheritance, especially the kind of monolithic inheritance Java style base classes imply, requires free-store based "pointer" or "reference" types. Your handle/pointer/reference to data holds a pointer to the interface of the class, and polymorphically could represent something else.
While this is useful in some situations, once you have married yourself to the pattern with a "common base class", you have locked your entire code base into the cost and baggage of this pattern, even when it isn't useful.
Almost always you know more about a type than "it is an object" at either the calling site, or in the code that uses it.
If the function is simple, writing the function as a template gives you duck-type compile time based polymorphism where information at the calling site is not thrown away. If the function is more complex, type erasure can be done whereby the uniform operations on the type you want to perform (say, serialization and deserialization) can be built and stored (at compile time) to be consumed (at run time) by the code in a different translation unit.
Suppose you have some library where you want everything to be serializable. One approach is to have a base class:
struct serialization_friendly {
virtual void write_to( my_buffer* ) const = 0;
virtual void read_from( my_buffer const* ) = 0;
virtual ~serialization_friendly() {}
};
Now every bit of code you write can be serialization_friendly
.
void serialize( my_buffer* b, serialization_friendly const* x ) {
if (x) x->write_to(b);
}
Except not a std::vector
, so now you need to write every container. And not those integers you got from that bignum library. And not that type you wrote that you didn't think needed serialization. And not a tuple
, or an int
or a double
, or a std::ptrdiff_t
.
We take another approach:
void write_to( my_buffer* b, int x ) {
b->write_integer(x);
}
template<class T,
class=std::enable_if_t< void_t<
std::declval<T const*>()->write_to( std::declval<my_buffer*>()
> >
>
void write_to( my_buffer* b, T const* x ) {
if (x) x->write_to(b);
}
template<class T>
void serialize( my_buffer* b, T const& t ) {
write_to( b, t );
}
which consists of, well, doing nothing, seemingly. Except now we can extend write_to
by overriding write_to
as a free function in the namespace of a type or a method in the type.
We can even write a bit of type erasure code:
namespace details {
struct can_serialize_pimpl {
virtual void write_to( my_buffer* ) const = 0;
virtual void read_from( my_buffer const* ) = 0;
virtual ~can_serialize_pimpl() {}
};
}
struct can_serialize {
void write_to( my_buffer* b ) const { pImpl->write_to(b); }
void read_from( my_buffer const* b ) { pImpl->read_from(b); }
std::unique_ptr<details::can_serialize_pimpl> pImpl;
template<class T> can_serialize(T&&);
};
namespace details {
template<class T>
struct can_serialize : can_serialize_pimpl {
std::decay_t<T>* t;
void write_to( my_buffer*b ) const final override {
serialize( b, std::forward<T>(*t) );
}
void read_from( my_buffer const* ) final override {
deserialize( b, std::forward<T>(*t) );
}
can_serialize(T&& in):t(&in) {}
};
}
template<class T> can_serialize::can_serialize<T>(T&&t):pImpl(
std::make_unique<details::can_serialize<T>>( std::forward<T>(t) );
) {}
and now we can take an arbitrary type and auto-box it into a can_serialize
interface that lets you invoke serialize
at a later point through a virtual interface.
So:
void writer_thingy( can_serialize s );
is a function that takes anything that can serialize, instead of
void writer_thingy( serialization_friendly const* s );
and the first, unlike the second, it can handle int
, std::vector<std::vector<Bob>>
automatically.
It didn't take much to write it, especially because this kind of thing is something you only rarely want to do, but we gained the ability to treat anything as serializable without requiring a base type.
What more, we can now make std::vector<T>
serializable as a first-class citizen by simply overriding write_to( my_buffer*, std::vector<T> const& )
-- with that overload, it can be passed to a can_serialize
and the serializabilty of the std::vector
gets stored in a vtable and accessed by .write_to
.
In short, C++ is powerful enough that you can implement the advantages of a single base class on-the-fly when required, without having to pay the price of a forced inheritance hierarchy when not required. And the times when the single base (faked or not) is required is reasonably rare.
When types are actually their identity, and you know what they are, optimization opportunities abound. Data is stored locally and contiguously (which is highly important for cache friendliness on modern processors), compilers can easily understand what a given operation does (instead of having an opaque virtual method pointer it has to jump over, on leading to unknown code on the other side) which lets instructions be optimally reordered, and fewer round pegs are hammered into round holes.