What is type erasure?
Type erasure is a term that has a different meaning in C++ than it does in other languages such as Java; in C++, type erasure is a strategy that enables the abstraction of an object to an interface without strictly requiring inheritance between the interface and the object itself. In simpler terms, it’s a fancy form of runtime polymorphism.
So how do you do it? It’s the use of a Concept/Model relationship within a concrete Interface type. First for the Concept/Model relationship:
// type concept -- defines the interface
// of the type to be erased
struct TypeConcept
{
// pure virtual passthrough function
virtual void Action() = 0;
};
// type model -- templated to hold whatever
// type is to be erased, implements the
// interface of Type
template<typename Type>
struct TypeModel : TypeConcept
{
TypeModel(Type type) : m_type(type) {}
// passthrough action to the erased type
virtual void Action() override
{
m_type->Action();
}
T m_type;
};
From the code above, notice that this is a simple inheritance relationship: we define an interface (TypeConcept) and a concrete element of that class (TypeModel). TypeModel is templated, which allows it to hold an element of any type. The important part is that we can store an element of TypeModel as a pointer to TypeConcept no matter what template type it is and still make use of polymorphism.
Now for the Concrete Interface class, in this case ErasedType:
// required for std::shared_ptr
#include <memory>
// the erased type
class ErasedType
{
public:
// templated constructor for generating our model
template<typename ErasedType>
ErasedType(ErasedType type)
: m_erasedType(new TypeModel(type))
{ }
// passthrough function for our erased type
// simply invoke the underlying interface
// from the TypeConcept struct
void Action() { m_erasedType->Action(); }
private:
// smart pointer for automatic memory management
std::shared_ptr<TypeConcept> m_erasedType;
}
From the above code, we see that we hold a pointer to a TypeConcept object, which we promptly construct from the type passed in to the ErasedType constructor.
The ErasedType class is responsible for exposing the interface of the erased type to the consumer. Additionally, in constructing the erased type, you assume a list of affordances that are required of all of the types to be erased — in our case it is required that all erased types implement the Action function, and the copy constructor (since we did not pass by reference). As long as a type conforms to that interface, we can generate an object of ErasedType that uses it.
That’s all fine and well, but how exactly is this useful? I have a good example involving a plugin system I wrote the other night.
Motivation for type erasure
My plugin system allows the execution of arbitrary code provided by a third party to be executed based on an interface supplied by the application developer. Using a plugin system allows for an extensible feature set, and enables users to modify the behavior of the program in order to suit their needs, as well as enable the developer to enable or disable features simply based on the existence of particular plugins.
As part of my system, I have a PluginManager class that implements the singleton pattern. My plugin manager was based on a simple principle: the application programmer should decide where the arbitrary code is allowed to hook into their application without needing to expose the underlying implementation (i.e. without providing the source code).
As such, my implementation relies on a modification of the observer pattern. The plugin developer registers with the PluginManager any of the functions it wants the application to call when a particular handle comes up in the application. The application developer asks the PluginManager for a list of all functions that have registered for a particular handle (in this case, a unique string specified by the application developer), and executes them to extract their behavior, based on an interface that the application developer defines.
// application developer
void SomeFunction(int someVal)
{
// query the plugin manager for plugins
auto plugins = PMgr::GetPlugins("myHandle");
// run the plugin functions
for(auto f : plugins)
someVale = f(someVal);
// do the final behavior of SomeFunction
DoCalculation(someVal);
}
// plugin developer
// function that doubles an integer value
int Double(int in) { return int * 2; }
// the function the program manager calls
// when initializing this plugin
void Register(PluginManager mgr)
{
// let the plugin manager know we want to
// call Double when "myHandle" is asked for
mgr.Register("myHandle", Double);
}
Because the plugin developer is required to register with the PluginManager, it is required to have knowledge of that class. Unfortunately, there are parts of the PluginManager that I do not want to expose to the plugin developers. In fact, I want to expose only a strict subset of my PluginManager interface to the plugin developer.
What options do I have in that case? I could split the PluginManager class into different classes who only exist to be components of some larger PluginManager concept, but that seems hard to maintain (when I add a new feature, which component gets it?). I could also create some abstract interface that only has the functions I want to expose, then pass the PluginManager as a pointer or reference to IPluginManager, and thus have only the functionality exposed that I want.
This second option is a reflection of poor design: we are forcing an inheritance relationship arbitrarily, and letting this “need” for the arbitrary relationship affect our overall design. Additionally, we now incur the cost of virtual method access for all of the functions we expose via the IPluginManager interface, which isn’t always acceptable, and is certainly a waste of performance. Are there any better options?
I chose to use type erasure to solve this problem: I made an erased type whose affordances included only the functionality I wanted to expose to the plugin developers.
// required for shared_ptr
#include <memory>
#include <string>
using std::string;
using std::shared_ptr;
// the manager concept we are modeling
struct ManagerConcept
{
virtual void Register(string, void*) = 0;
}
// the concrete manager model
template<typename Manager>
struct ManagerModel : ManagerConcept
{
// void* here is also a kind of type erasure
// but it is NOT type safe and can lead to
// bad behavior if interfaces are not honored
void Register(string handle, void* func) override
{
m_erasedMgr.Register(handle, func);
}
Manager m_erasedMgr;
}
// the interface we are exposing to the consumer
class IManager
{
// templated ctor to abstract the type we are
// erasing away from the interface
template<typename Manager>
IManager(Manager manager)
: m_mgr(new ManagerConcept(manager)) {}
// passthrough Register function
void Register(string handle, void* func)
{
m_mgr->Register(handle, func);
}
shared_ptr<ManagerConcept> m_mgr;
}
Because of this, the plugin developer only needs to know about the functions available to them — and even better, is only afforded those functions, and nothing more. In effect, I whittled down the interface to the PluginManager to better fit the different interface required by the plugin developer.
Another good outcome is that PluginManager is not as tightly coupled to the plugins since its type information is not contained anywhere in them. So long as the PluginManager used conforms to that interface at a minimum, then it will be compatible. I could add as much functionality to my PluginManager and so long as I leave the behavior of the existing functions alone, it will remain completely compatible.
Reflection
Especially while writing this, I had to think about alternatives and challenges to using type erasure for my plugin system. The largest question I needed to overcome was: “why don’t you just make IManager templated?” In truth, making a single templated IManager class would solve a lot of the same issues, but it lacks some important nuances.
Primarily, consider this: the plugin developer maliciously changes the definition of the IManager class and makes m_manager public. This allows them to access the underlying manager without restrictions, effectively exposing the rest of its interface. This is against one of the initial goals of restricting the interface that the plugin developer has access to.
To fix this same problem in the type erasure example, you simply don’t give the plugin developer the definition for ManagerModel. They can change as much as they want, but the interface that actually limits their access is not accessible. This obviously requires a slight modification of the erasure structure (IManager would need to take in a ManagerConcept reference or pointer), but it is extremely simple to enforce this limit on the plugin developer, which is a very powerful tool.
Another question that I posed was: “why don’t we just pass around smart pointers to the ManagerConcept type to reduce code duplication?” This was another very good counterpoint to the system I laid out above — it would cut the code duplication down by a third. That being said, the purpose of the IManager object I described above was to encapsulate an object and let us treat it like any object that conforms to that interface.
Under the covers, one of the affordances that I gave was the ability for the plugin developers to refer to the IManager interface as an object, which required wrapping the ManagerConcept in an object. The ManagerConcept is truly the object that is tasked with defining the interface. If using the interface as an object rather than a pointer is not on your list of affordances, then passing around smart pointers to a ManagerConcept is definitely a good strategy.
That’s it; that’s type erasure. From what I have been able to see, it is an incredibly niche tool, but like any tool there are places where it just fits. I think that this was one case where it does a very nice job doing the task I needed, and it did so without influencing the design of the PluginManager.
881