Type erasure with unified call syntax
Type erasure bridges the gap between compile-time and runtime polymorphism. It allows us to generate code for a range of types without inheritance relationships at compile-time, then and choose between these behaviours at runtime. As a simple example:
Note that the only thing which connects
B is that they expose the same interface. They don’t inherit from each other, yet we can store either of them in this
Fooable type and call the relevant version of
foo. We can happily return
Fooable objects from functions, pass them around, dynamically allocate them, whatever; the erasure container acts pretty much as you would expect.
One example of this in the standard library is
std::function. So long as you give it some object which can be called with the arguments specified and which returns the right type, you can store closures, function pointers, functors, anything.
Of course, type erasure comes at a cost. Usually, you’ll incur a dynamic allocation on construction and assignment, and virtual function call on using the type-erased object. As such, you should consider the trade-off between flexibility and performance for your application when type erasure is proposed.
Thus ends our crash-course in type erasure. Later in this post we’ll be implementing a
Fooable class, but with support for unified call syntax. Before we put these two concepts together, I’ll briefly explain what I mean by this term.
Unified call syntax
If we have a class
Toddler and want to write a function called
take_a_nap which operates on it, we have two main options for our implementation: a member function or a non-member function.
If we declare it as a member function, we need to call it like
my_toddler.take_a_nap(), and if we declare it as a non-member, we need to call it like
take_a_nap(my_toddler). You cannot use these two forms interchangeably, i.e. you can’t call a member function like
take_a_nap(my_toddler), or a non-member function like
my_toddler.take_a_nap(). Unified call syntax relaxes this constraint in some direction. D allows you to write
my_toddler.take_a_nap() even if
take_a_nap is declared as a non-member, but not the other way around. Rust allows non-member call syntax for members. A few proposals have been written for C++123456 but they have been rejected for now.
Smushing them together
Putting the two concepts together, by “type erasure with uniform call syntax”, I mean allowing both non-member and member call syntax for types which supply either. An example with our
Fooable class from before:
Note again that
NonMember are not related by inheritance, but this time they even declare
foo in different ways.
Fooable does some magic which allows both of them to be called in either way.
Now for the magic.
Implementing the type erasure
The usual way to do this kind of type erasure is to have some
Storage class with a pure virtual function, and a template class which inherits from
Storage and forwards the virtual call to the type-erased object.
Note that the above Storage calls
foo using the member syntax, so it won’t work with the
NonMember class. I call the function
docall rather than
foo, because using the same name as the function we’re trying to call will get us into trouble with argument dependent lookup when we try to support non-member syntax.
Fooable, we store a
Storage, and forward our call to
foo on to that pointee:
The clever bit is the constructor to
Fooable. We make this a template, and dynamically allocate a
StorageImpl<T> based on what got passed in:
When we construct a
Fooable with some
m_storage will hold a
StorageImpl<T>. Calls to
foo will be forwarded through
Storage, down to
StorageImpl through the virtual function, ending up at
T through the implementation of
Supporting both ways of calling
Fooable is pretty simple: we just add a non-member function which calls
foo(my_fooable) are supported!
The tricky bit comes in supporting erasure of types with both declaration types. We’ll need some template metaprogramming trickery for this.
Erasure both ways
This is a rather gorgeous/horrific trick which uses expression SFINAE to ignore the partial specialization when
my_t.foo() is not a valid expression. If you don’t understand it, have a look at this StackOverflow answer, or just believe me that it works.
We’ll then write two versions of
StorageImpl: one for when the type has a
foo member, and one for when it does not.
The only difference between these two is that one uses member call syntax and the other uses non-member syntax. Keen readers may have noticed that if a type has both a member function and non-member function called
foo, then this will prefer the member one. I think this is pretty reasonable; if you have two different functions operating on your type with the same set of arguments and they’re not functionally equivalent, you have bigger problems.
Now we’re done! The code works on both GCC and Clang.
Let me know what you think of this article on twitter @TartanLlama or leave a comment below!