C++ Type Erasure on the Stack - Part I

C++ Type Erasure on the Stack - Part I

Type erasure is where the type of an object is hidden so that the object can be utilized in a type-independent manner. Type erasure is extremely useful in several scenarios, including heterogeneous containers (containers that store objects of different types), and using objects generically through common interfaces.

The C++ standard library provides several classes for doing type erasure, but none of them are ideal. Most notably, they tend to store their data on the heap instead of the stack, and dynamic memory allocations are slow.

An alternative method, runtime-polymorphism, requires multiple levels of indirection via virtual tables. This means that objects can only be accessed/used by pointer-hopping all over memory, which is much slower than it needs to be for (e.g.) accessing array data that would otherwise be contiguous in memory. Static-polymorphism (namely CRTP) is efficient but doesn't hide object types, so it can't be used for our purposes here.

Instead we'll develop our own classes for type erasure in this series of blog posts.

Motivating Example: EventManager

Here's an example of where type erasure makes programming very simple and easy. We want to define an EventManager class that users can submit events to, which will then invoke any callbacks that have been registered for those events. However, we don't want to call those callbacks immediately; we instead want to queue events up for later (in the frame) when we can do all of this work at once. Besides any efficiency gains this may yield, this allows us to guarantee that these (arbitrary) callbacks won't interfere with any other asynchronous that may be ongoing.

The fastest and simplest way of storing these events (AnyEvent) and callbacks (AnyEventHandler) is to do so in a type-erased manner, without dynamic memory allocations or runtime-polymorphism, as illustrated below:

//Type-erased events/callbacks for storage in EventManager
using AnyEvent = AnyTrivial<32, 8>; //like std::any
using EventHandler = AnyCallable<void(const AnyEvent&)>; //like std::function

class EventManager
{
public:
    template <typename EventType>
    void Submit_Event(EventType&& aEvent) {mEventQueue.emplace(aEvent);}
    //...

private:
    //Map key (TypeIndex) is unique to the type of the event
    HashMap<TypeIndex, Vector<EventHandler>> mEventHandlers;
    Queue<AnyEvent> mEventQueue;
};

Now, we can just emplace events (such as PlayerDamagedEvent) into AnyEvent, and submit them to the queue in our EventManager. Later in the frame we can then pop the event off of the queue, and call the appropriate type-erased event handlers that we've stored there.

struct PlayerDamagedEvent
{
    int mPlayerHandle;
    int mHitPointDamage;
};

//Create an event, emplace into mEventQueue
EventManager::Submit_Event(PlayerDamagedEvent{0, 10});

//...

//Then later in the frame, in the EventManager, pop the event
AnyEvent cEvent = mEventQueue.pop();

//Lookup callbacks by event type and call them
for (auto& cCallback : mEventHandlers[cEvent.Get_TypeIndex()])
    cCallback(cEvent);

Type erasure enabled everything to be stored simply and heterogeneously in containers. And we're able to access and use these quickly without a lot of the slow, unnecessary pointer-hopping that would be needed with runtime polymorphism, and without the dynamic allocations needed for the standard std::any and std::function classes.

Introduction to Type Erasure

There are several different ways of achieving type erasure. One way is to reinterpret_cast an object pointer to a void*, pass the pointer into a generalized function, and reinterpret_cast it back to the original type before using it. Here's an overly simple, contrived example:

enum class DataType { IntType, FloatType };

void Use_Data(void* aData, DataType aType)
{
    switch (aType)
    {
    case DataType::IntType:
        Use_Int(*reinterpret_cast<int*>(aData));
        break;
    case DataType::FloatType:
        Use_Float(*reinterpret_cast<float*>(aData));
        break;
    default:
        break;
    }
}

You can see the danger in doing this: if the caller makes a mistake about which type was passed in via void*, you could reinterpret the data as the wrong type and cause undefined behavior. We've thrown type-safety out the window, so we need to make sure we only do this type of thing in a tightly controlled environment: encapsulated within private class code.

The C++ standard library provides several classes utilizing type erasure, most notably std::any and std::function. In addition to storing their data on the heap and throwing exceptions (both slow!), std::function does not support non-copyable functions, AND it throws const-safety out the window (it will invoke your non-const operator() even on const objects!). C++23 introduced std::move_only_function, but it still doesn't handle non-movable types, or fix the other problems.

Object Storage and Access

To store our objects on the stack, we need to set aside a buffer of memory for them in our classes. We'll start by introducing part of our base class, AnyObjectBase:

template <int Size, int Alignment = 8>
class AnyObjectBase
{
public: 
    //OBSERVERS
    bool Has_Object() const {return (mTypeIndex != Make_TypeIndex<void>());}
    TypeIndex Get_TypeIndex() const {return mTypeIndex;}

    //...
protected:
    //STRUCTORS
    AnyObjectBase() = default; //Protected: prevents base class instantiation
    ~AnyObjectBase() = default; //Protected: prevents object slicing
    AnyObjectBase(TypeIndex aTypeIndex) : mTypeIndex(aTypeIndex) {}

    //COPIERS: Empty, inheriting classes must implement
    AnyObjectBase(const AnyObjectBase&){}
    AnyObjectBase& operator=(const AnyObjectBase&){}

    //MOVERS: Empty, inheriting classes must implement
    AnyObjectBase(AnyObjectBase&&){}
    AnyObjectBase& operator=(AnyObjectBase&&){}

    //...

    //MEMBERS
    alignas(Alignment) std::byte mStorage[Size];

    //For type safety, and determining whether an object is present
    TypeIndex mTypeIndex = Make_TypeIndex<void>();
};

We'll store the object within the mStorage array of bytes, appropriately sized and aligned using the template arguments. We don't want users to create an AnyObjectBase directly, so the constructors, assignment operators, and destructor are all protected. The copiers and movers are no-ops, as deriving classes will need to handle these operations differently for different classes of stored objects. The stored TypeIndex (introduced here) identifies what type (if any we have stored in this object.

Here is the rest of the AnyObjectBase class definition, which consists of methods for constructing and accessing the stored data:

template <int Size, int Alignment>
class AnyObjectBase
{
public:
    //GETTERS
    template <typename DataType>
    DataType& Get();
    template <typename DataType>
    const DataType& Get() const;

    //CASTERS
    template <typename DataType>
    explicit operator const DataType& () const& {return Get<DataType>();}
    template <typename DataType>
    explicit operator DataType& () & {return Get<DataType>();}
    template <typename DataType>
    explicit operator DataType&& () && {return std::move(Get<DataType>());}

    //COMPATIBILITY
    template <typename DataType>
    static constexpr bool Is_Storable();
    template <int InputSize, int InputAlignment>
    static constexpr bool Is_Storable();

protected:

    //CONSTRUCT
    template <typename DataType, typename... ArgumentTypes>
    std::decay_t<DataType>& Construct(ArgumentTypes&&... aArguments) 
        requires(Is_Storable<DataType>());

    //...
};

We use Is_Storable() to check whether an object of a given type can be stored in our std::byte buffer. This is enforced via a concept requirement on our Construct() method. Objects are only storable if they have room to fit, and have a small-enough alignment:

//Class template parameters removed for brevity
template <typename DataType>
constexpr bool AnyObjectBase<Size, Alignment>::Is_Storable()
{
    using StoredType = std::decay_t<DataType>;
    return Is_Storable<sizeof(StoredType), alignof(StoredType)>();
}

template <int InputSize, int InputAlignment>
constexpr bool AnyObjectBase<Size, Alignment>::Is_Storable()
{
    return (InputSize <= Size) && (InputAlignment <= Alignment);
}

We then apply this Is_Storable() constraint on the Construct() method. Here we first use std::decay_t to get the type of the object that will actually be stored (removes cv/ref-qualifiers, etc.). Any supplied arguments are then forwarded to the constructor for this object, which is emplaced into the byte buffer with placement-new. Finally, we return a reference to the stored object (memory laundering is explained further below):

//Class template parameters removed for brevity
template <typename DataType, typename... ArgTypes>
std::decay_t<DataType>& AnyObjectBase::Construct(ArgTypes&&... aArgs) 
    requires(Is_Storable<DataType>())
{
    using StoredType = std::decay_t<DataType>;
    new (mStorage) StoredType(std::forward<ArgTypes>(aArgs)...);
    return *std::launder(reinterpret_cast<StoredType*>(mStorage));
}

To access the stored data, the user must specify the type object to be returned, as the compiler doesn't know what it is (we've erased it!). This is supplied to the Get() methods via a template parameter:

//Class template parameters removed for brevity
template <typename DataType>
DataType& AnyObjectBase::Get()
{
    auto cConstThis = const_cast<const AnyObjectBase*>(this);
    return const_cast<DataType&>(cConstThis->Get<DataType>());
}

template <typename DataType>
const DataType& AnyObjectBase::Get() const
{
    using StoredType = std::decay_t<DataType>;
    Assert(Types::Make_TypeIndex<StoredType>() == mTypeIndex);
    return *std::launder(reinterpret_cast<const StoredType*>(mStorage));
}

Here we Assert() that the type we're requesting is the same as was stored in our type-erased byte buffer, using our TypeIndex class to enforce type safety. To retrieve the object, we first reinterpret_cast our buffer to the stored type, and std::launder the pointer.

This laundering doesn't execute any code; it instead informs the compiler that it can't make any assumptions about where the object came from when doing optimizations. This is because we may create objects in this byte buffer multiple times, perhaps even of different types. [Reference]

Note also that there are three different explicit cast methods, each with different const/ref qualifiers. For example, if the object is an r-value, explicitly static_cast'ing to DataType will return a DataType&&. Having these separate methods allows us to return the stored data with the appropriate const/ref qualifiers for the given situation. These all defer to the earlier-defined Get() methods.

AnyTrivial

With AnyObjectBase defined, we'll inherit it from it to define our AnyTrivial class for storing the simplest type of objects: those that are trivial. These types have trivial copies, moves, destructors, and a defaulted trivial constructor. Trivial destructors perform no actions and thus do not need to be invoked, and trivial copies and moves can be safely implemented with a simple std::memcpy(). std::memcpy() implicitly triggers object creation since trivial types are also implicit lifetime, thanks to having a defaulted trivial constructor.

Typical trivial types include arithmetic types, pointers, enums, and simple class objects. Note that more complex types can be stored in AnyObject instead, which we'll discuss in Part II of this blog series.

What might we want to use AnyTrivial for? As in our example above, perhaps our event system needs to store all of the events it receives in a single queue for processing. We could have many different classes for each type of event, but if they are all trivial then we could just pick a maximum class size (e.g. 32 bytes) and store them all in a single container. We can then submit them to type-erased event callbacks without the EventManager ever knowing what types of events they were!

For trivial types we don't need to know the type of data that is stored in our buffer to do any of these operations. This reduces our implementation of AnyTrivial to mostly just being a series of std::memcpy()'s. Here's the first portion of the class definition, with the standard copy and move operators, in addition to Emplace():

template <int Size, int Alignment>
class AnyTrivial : public AnyObjectBase<Size, Alignment>
{
private:
    using BaseClass = AnyObjectBase<Size, Alignment>;
    using BaseClass::mTypeIndex;
    using BaseClass::mStorage;

public:
    //DEFAULT STRUCTORS
    AnyTrivial() = default;
    ~AnyTrivial() = default;

    //COMPATIBILITY
    template <typename DataType>
    static constexpr bool Is_Storable();

    //COPIERS
    AnyTrivial(const AnyTrivial& aRHS);
    AnyTrivial& operator=(const AnyTrivial& aRHS);

    //MOVERS: Defer to copiers
    AnyTrivial(AnyTrivial&& aRHS) : AnyTrivial(aRHS) {}
    AnyTrivial& operator=(AnyTrivial&& aRHS) {return (*this = aRHS);}

    //EMPLACE
    template <typename DataType, typename... ArgTypes>
    auto& Emplace(ArgTypes&&... aArgs) requires(Is_Storable<DataType>());

    //...
private:
    void Copy_Data(const std::byte* aOtherStorage);
};

The movers just defer to the copiers, since all we're doing is byte copies. First the implementation of the copiers (mostly just std::memcpy):

//COPIERS
//Class template parameters removed for brevity
AnyTrivial::AnyTrivial(const AnyTrivial& aRHS) : BaseClass(aRHS.mTypeIndex)
{
    Copy_Data(aRHS.mStorage);
}

auto AnyTrivial::operator=(const AnyTrivial& aRHS) -> AnyTrivial&
{
    //Guard against self-copy-assignment
    if (this == &aRHS)
        return *this;

    Copy_Data(aRHS.mStorage);
    mTypeIndex = aRHS.mTypeIndex;
    return *this;
}

void AnyTrivial::Copy_Data(const std::byte* aOtherStorage)
{
    //std::memcpy triggers implicit object creation
    std::memcpy(reinterpret_cast<void*>(mStorage), 
        reinterpret_cast<const void*>(aOtherStorage), Size);
}

For the type to be storable, it not only has to have the proper size and alignment (checked via BaseClass::Is_Storable()), but it must also be trivial, as discussed earlier:

//Class template parameters removed for brevity
template <typename DataType>
constexpr bool AnyTrivial::Is_Storable()
{
    using StoredType = std::decay_t<DataType>;
    return std::is_trivial_v<StoredType> && 
        BaseClass::template Is_Storable<StoredType>();
}

For Emplace(), we check Is_Storable(), then just forward the constructor arguments to the AnyObjectBase::Construct() method to store our data:

//Class template parameters removed for brevity
template <typename DataType, typename... ArgTypes>
auto& AnyTrivial::Emplace(ArgTypes&&... aArgs)
    requires(Is_Storable<DataType>())
{
    mTypeIndex = Make_TypeIndex<std::decay_t<DataType>>();
    return BaseClass::template Construct<DataType>(
        std::forward<ArgTypes>(aArgs)...);
}

The remaining methods are a little tricky. We want to be able to construct, copy, and move data of any type (DataType) into our object. We need to make sure though that these new methods don't supersede the copy and move functions accepting AnyTrivial objects during overload resolution. The (adapted) solution is to use concepts to restrict these new functions from being used for AnyTrivial and any types that derive from it. Here's the remainder of the class definition:

template <int Size, int Alignment>
class AnyTrivial : public AnyObjectBase<Size, Alignment>
{
private:
    //Detect if DataType is AnyTrivial or derived from it
    //Adapted from: http://ericniebler.com/2013/08/07/universal-references-and-the-copy-constructo/
    template <typename DataType>
    static constexpr bool sThisIsBaseOf = std::is_base_of_v<AnyTrivial, 
        std::remove_reference_t<DataType>>;

public:
    //CONSTRUCTOR
    template <typename DataType>
    explicit AnyTrivial(DataType&& aData) 
        requires(!sThisIsBaseOf<DataType> && Is_Storable<DataType>());

    //COPIERS
    template <typename DataType>
    AnyTrivial& operator=(const DataType& aData) 
        requires(!sThisIsBaseOf<DataType> && Is_Storable<DataType>());

    //MOVERS
    template <typename DataType>
    AnyTrivial& operator=(DataType&& aData)
        requires(!sThisIsBaseOf<DataType> && Is_Storable<DataType>()) : 
        AnyTrivial(aData) {}

    //...
}

Note again that the mover defers to the copier. The remaining function definitions simply defer to the Construct() and Emplace() methods:

//Concepts and class template parameters removed for brevity
template <typename DataType>
AnyTrivial::AnyTrivial(DataType&& aData) : 
    BaseClass(Types::Make_TypeIndex<std::decay_t<DataType>>())
{
    BaseClass::template Construct<DataType>(std::forward<DataType>(aData));
}

template <typename DataType>
auto AnyTrivial::operator=(const DataType& aData) -> AnyTrivial& 
{
    Emplace<DataType>(aData);
    return *this;
}

With AnyTrivial defined, here's some contrived example code on how to use it:

AnyTrivial<sizeof(int), alignof(int)> cAnyTrivial(9);
std::cout << "My int: " << cAnyTrivial.Get<int>() << "\n"; //9
cAnyTrivial = 15.0f; //Can store floats too!
std::cout << "My float: " << cAnyTrivial.Get<float>() << "\n"; //15.0f
//...

For a more useful example, see its usage as AnyEvent near the beginning of this blog post.

Conclusion

We have a base class (AnyObjectBase) with a byte buffer where we can store objects in a type-erased manner, and a TypeIndex to encode the stored type and enforce type safety. We can use AnyTrivial for encapsulating trivial types, and we'll cover more complicated types in Part II of this blog series.

License & Disclaimers

Support This Blog!

Did you find this article valuable?

Support C++ Professional Game Engine Programming by becoming a sponsor. Any amount is appreciated!