C++ Type Erasure on the Stack - Part III

C++ Type Erasure on the Stack - Part III

In Part I of this blog series, we covered how to convert our type name to a string, how to safely store type-erased objects, and how to handle trivial types (AnyTrivial). In Part II we covered how to manage type-erased storage of general types (AnyObject). Now we'll cover how to add interfaces for interacting with these type-erased objects, including callables (AnyCallable), and your own custom types (e.g. AnyAllocator).

AnyCallable: CallablePolicy

For AnyCallable we need to distinguish whether our stored callable is const-invokable or not. This is because if a given AnyCallable object is const, we can only call operator() const if that method is defined for the stored type. This is unlike std::function which ignores const-safety: it only has the const version of the function call method, and allows it to call non-const operator() on its stored type.

So we introduce CallablePolicy, which is similar to AnyObject's ObjectPolicy but also includes const-invokable policies. It also extends ObjectPolicy using the extending-enum technique:

//ConstInvoke: Requires callable has: operator() const
enum class CallablePolicy : int
{
    ConstInvoke = 4, MoveOnlyConstInvoke = 5, CopyableConstInvoke = 6, 
    FirstEnum = ConstInvoke, LastEnum = CopyableConstInvoke
};

template <>
struct EnumBranch<CallablePolicy> { using BaseType = ObjectPolicy; };

constexpr ObjectPolicy Get_ObjectPolicy(MetaObjectPolicy aPolicy)
{
    //Downconverts (e.g.) CallablePolicy to ObjectPolicy
    //Get_LowBits() is left as an exercise for the reader :)
    auto cObjectPolicyValue = Get_LowBits<2>(aPolicy.Get());
    return static_cast<ObjectPolicy>(cObjectPolicyValue);
}

By defining the enum values as the above, we can convert an CallablePolicy to an ObjectPolicy by simply extracting its lowest two bits and casting it to an ObjectPolicy.

AnyCallable: Class Introduction

AnyCallable will be able to store callables such as function pointers, function objects, and lambdas. Since these are all objects, AnyCallable will inherit from AnyObject, and we'll defer to it for the implementation of the copy, move, and destruction operations.

Similar to std::function, AnyCallable is templated on the argument types and the return type. The below class declaration and definition allow the user to provide the function signature as a single template argument (CallSignature), which we can then separate into ReturnType and ArgumentTypes... components:

//Class declaration
template <typename CallSignature, CallablePolicy Policy = 
    CallablePolicy::Copyable, int Size = 48, int Alignment = 8>
class AnyCallable;

//Class definition
template <CallablePolicy Policy, int Size, int Alignment, 
    typename ReturnType, typename... ArgumentTypes>
class AnyCallable<ReturnType(ArgumentTypes...), Policy, Size, Alignment> : 
    public AnyObject<Size, Get_ObjectPolicy(Policy), Alignment>
{
private:
    using AnyObjectType = 
        AnyObject<Size, Get_ObjectPolicy(Policy), Alignment>;
//...
};

Thus to create an AnyCallable object, the user can just do:

//void ReturnType, int & float are ArgumentTypes...
AnyCallable<void(int, float)> cCallback;

Here are a few more components of the AnyCallable class:

//Class template parameters removed for brevity
class AnyCallable : public AnyObject
{
private:
    using BaseClass = typename AnyObjectType::BaseClass; 
    using AnyObjectType::mDispatcher;
    using AnyObjectType::BaseClass::mStorage;

    // 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<AnyCallable, std::remove_reference_t<DataType>>;
//...
public:
    template <typename ObjectType, typename... InputArgTypes>
    static constexpr bool Is_Storable();
    static constexpr bool Is_ConstInvoke();
    using AnyObjectType::Is_Copyable;
    using AnyObjectType::Is_Movable;
//...
};

Note that sThisIsBaseOf needs to also be defined for AnyCallable, to again distinguish which methods should be called for the constructor, copy-assignment, and move-assignment.

The requirements for AnyCallable::Is_Storable() are a little more strict than those for AnyObject. In addition to requiring that the input object fit in storage and obey the appropriate ObjectPolicy, it has to be invokable with the specified argument types, and yield an object of the specified return type. Also, if its CallablePolicy required the presence of operator() const, it checks to make sure it exists:

//Class template parameters removed from function definition for brevity
template <CallablePolicy Policy, int Size, int Alignment, 
    typename ReturnType, typename... ArgTypes>
template <typename ObjectType, typename... InArgTypes>
constexpr bool AnyCallable::Is_Storable()
{
    //If the policy allows X, the object better allow X
    //If the policy prevents X, we need to disable the X member functions    
    //If policy is mutable-invoke the stored-type can have ANY invoke
    using StoredType = std::decay_t<ObjectType>;
    bool cStorable = AnyObjectType::template Is_Storable<StoredType>();

    if constexpr (sizeof...(InArgTypes) == 0)
        return std::is_invocable_r_v<ReturnType, StoredType, ArgTypes...> && 
            cStorable && (!Is_ConstInvoke(Policy) || 
            Has_ConstInvoke<StoredType, ReturnType, ArgTypes...>());
    else
        return std::is_invocable_r_v<ReturnType, StoredType, InArgTypes...>    
            && cStorable && (!Is_ConstInvoke(Policy) || 
            Has_ConstInvoke<StoredType, ReturnType, InArgTypes...>());
}

constexpr bool AnyCallable::Is_ConstInvoke()
{
    return (Policy == To_Underlying(CallablePolicy::ConstInvoke)) || 
        (Policy == To_Underlying(CallablePolicy::MoveOnlyConstInvoke)) || 
        (Policy == To_Underlying(CallablePolicy::CopyableConstInvoke));
}

template <typename CallableType, typename ReturnType, typename... ArgTypes>
constexpr bool Has_ConstInvoke()
{
    return requires (const CallableType& aCallable, ArgTypes&&... aArgs)
    {
        { aCallable(std::forward<ArgTypes>(aArgs)...) } -> 
            std::convertible_to<ReturnType>;
    };
}

The purpose of the optional, alternate argument types called InArgTypes... will be discussed in a later section of this blog post.

AnyCallable: Type-Erased Function Calls

For type-erased function calls, we'll use the same trick of storing a pointer to a function named Call_Method(), which has the StoredType embedded as a template argument. The main difference is that we need to extend AnyCall to include invoking operator() and operator() const. We'll do so by defining the FunctionCall enum and having it extend AnyCall, as introduced here.

enum class FunctionCall : int
{
    MutableInvoke = 10, ConstInvoke, FirstEnum = MutableInvoke, 
    LastEnum = ConstInvoke
};

template <>
struct EnumBranch<FunctionCall> { using BaseType = AnyCall; };

Thus we'll define the new Call_Method() as:

//Class template parameters removed for brevity
template <typename StoredType, typename... CastArgTypes>
void AnyCallable::Call_Method(std::byte* aObjectStorage, 
    ExtendedAnyCall aCall, const void* aArguments, std::byte* aResult)
{
    switch (aCall.Get())
    {
    case To_Underlying(FunctionCall::MutableInvoke):
        return Delegate_Invoke<StoredType, CastArgTypes...>(
            aObjectStorage, aArguments, aResult);
    case To_Underlying(FunctionCall::ConstInvoke):
    {
        if constexpr (Is_ConstInvoke())
            return Delegate_Invoke<const StoredType, CastArgTypes...>(
                aObjectStorage, aArguments, aResult);
        else
            Assert(false, "Cannot const-invoke!\n");
        return;
    }
    default:
        return AnyObjectType::template Call_Method<StoredType>(
            aObjectStorage, aCall, aArguments, aResult);
    }
}

If the input ExtendedAnyCall is storing the value of one of our new invoke methods, we'll forward the work to Delegate_Invoke() with the appropriate const-ness. If not, then it must be a copy, move, or destroy operation, and we'll forward the work on to AnyObject::Call_Method(). The optional CastArgTypes... are will be discussed in the following section.

AnyCallable: Invoking

Now for the meat of the class, defining how the function-call operators work. We need both const and non-const methods, and the const method requires that the CallablePolicy supports const-invoke. The const method casts the storage to non-const, and both forward their arguments to a common CallOperator_Impl() function:

//Class template parameters removed for brevity
ReturnType AnyCallable::operator()(ArgTypes... aArgs)
{
    return CallOperator_Impl(mStorage, FunctionCall::MutableInvoke, 
        std::forward<ArgTypes>(aArgs)...);
}

ReturnType AnyCallable::operator()(ArgTypes... aArgs) const
    requires(Is_ConstInvoke())
 {
    //const_cast is ok here: will respect constness on the other side
    auto cStorage = const_cast<std::byte*>(mStorage);
    return CallOperator_Impl(cStorage, FunctionCall::ConstInvoke, 
        std::forward<ArgTypes>(aArgs)...);
}

And now for the implementation of CallOperator_Impl():

ReturnType AnyCallable::CallOperator_Impl(std::byte* aStorage, 
    ExtendedAnyCall aCall, ArgTypes... aArgs) const
{
    Assert(mDispatcher != nullptr, "Nothing to invoke!\n");

    auto cArgTuple = std::forward_as_tuple(std::forward<ArgTypes>(aArgs)...);
    auto cArgPointer = reinterpret_cast<const void*>(&cArgTuple);

    if constexpr (std::is_same_v<ReturnType, void>)
        mDispatcher(aStorage, aCall, cArgPointer, nullptr);
    else if constexpr (!std::is_reference_v<ReturnType>)
    {
        //Return type is a value, create a byte buffer for it
        alignas(alignof(ReturnType)) std::byte cResult[sizeof(ReturnType)];

        //Call our function, return the result
        mDispatcher(aStorage, aCall, cArgPointer, cResult);
        return *std::launder(reinterpret_cast<ReturnType*>(cResult));
    }
    else //Return type is a reference: must return a pointer to it instead
    {
        //Create a byte buffer for the pointer type
        std::remove_reference_t<ReturnType>* cResultPointer;
        std::byte* cResult = reinterpret_cast<std::byte*>(&cResultPointer);

        //call our function
        mDispatcher(aStorage, aCall, cArgumentsPointer, cResult);

          //dereference our result pointer and return it
        if constexpr (std::is_lvalue_reference_v<ReturnType>)
            return *cResultPointer;
        else //rvalue reference: move it
            return std::move(*cResultPointer);
    }
}

To pass the (arbitrary number of) function arguments on as a void*, we collect them into a std::tuple of lvalue and rvalue references using std::forward_as_tuple. We then cast this tuple to const void* for forwarding to mDispatcher.

If there is no return value, we simply call mDispatcher and then we're done. If an object is returned by value, we set aside an aligned buffer of bytes for storing it, and pass on a pointer to this buffer. We could have created a ReturnType object on the stack and passed a pointer to it instead, but invoking the ReturnType constructor could be slow, or there may be unintended side effects. After calling the mDispatcher, we reinterpret_cast, std::launder, and return the object in this byte buffer.

If we're returning an object by reference though, we have to first create a pointer to that object instead. mDispatcher will then set that to a pointer to our object, and then we can return the appropriate reference type for the object. There is no danger in taking the pointer of a temporary, as the lifetime of any temporary wouldn't be extended past the return statement of the stored callable anyway.

If you thought that was complicated, let's now look at what happens on the other side of the mDispatcher. Above we showed that the stored Call_Method() will forward the work on to Delegate_Invoke(). Here we first reinterpret the object storage as the (possibly-const) StoredType, then split up the work based on whether there is a returned object or not:

//Class template parameters removed for brevity
template <typename StoredType, typename... CastArgTypes>
void AnyCallable::Delegate_Invoke(std::byte* aStorage, const void* aArgs, 
    std::byte* aResult)
{
    auto& cObject = *std::launder(reinterpret_cast<StoredType*>(aStorage));
    if constexpr (std::is_same_v<ReturnType, void>)
        Call_Invoke<StoredType, CastArgTypes...>(cObject, aArgs);
    else
        Call_Invoke<StoredType, CastArgTypes...>(cObject, aArgs, aResult);
}

And now one of the most complicated functions in the engine, Call_Invoke(). We'll first look at the method for which there is no return value:

//Class template parameters removed for brevity
template <typename StoredType, typename... CastArgTypes>
void AnyCallable::Call_Invoke(StoredType& aInvokable, const void* aArgs)
{
    using ArgTupleType = std::tuple<ArgTypes&&...>;
    auto& cArgTuple = *std::launder(reinterpret_cast<ArgTupleType*>(
        const_cast<void*>(aArgs)));

    //If no argument cast needed, call aInvokable via std::apply
    if constexpr (std::is_same_v<Meta::TypeList<ArgTypes...>, 
        Meta::TypeList<CastArgTypes...>> || (sizeof...(CastArgTypes) == 0))
        std::apply(aInvokable, std::move(cArgTuple));
    else //use Forward_As() to cast arguments to CastArgTypes...
    {
        auto cInvoker = [&] <std::size_t... Indices>(
            std::index_sequence<Indices...>)
        {
            std::invoke(aInvokable, Forward_As<ArgTypes, CastArgTypes>(
                std::get<Indices>(cArgTuple))...);
        };

        static constexpr auto sNumArgs = std::tuple_size_v<ArgTupleType>;
        cInvoker(std::make_index_sequence<sNumArgs>{});
    }
}

OK. First we cast the void* input arguments back to the std::tuple of references that were originally created with std::forward_as_tuple. Then, if the optional CastArgTypes... is empty, or is the same as the main AnyCallable template parameter pack ArgTypes..., we can simply call std::apply. This will unpack the std::tuple of arguments and forward them on to our stored callable.

However, suppose we want to use our type-erased AnyCallable with type-erased AnyObject's as arguments? For example, at the beginning of Part I of this blog series we introduced the example of an EventManager. It contained a queue of type-erased events (AnyEvent's), on which we want to call type-erased callbacks.

In this case, the function signature for our AnyCallable's are void(const EventAnyType&), but we'll want the signature of the actual callbacks to be event-type-specific (e.g. void(const PlayerDamagedEvent&)). For this to work, we need to do a type conversion from EventAnyType to PlayerDamagedEvent here, within this function call! And because the cast operators in AnyObjectBase are explicit (for safety!), we have to do the appropriate static_cast ourselves. The optional, user-supplied CastArgTypes... template parameter, also embedded into Call_Method(), informs us what types we need to cast the arguments to.

Since we need to static_cast each of the arguments individually, we use std::index_sequence with a lambda to get a set of Indices... (e.g. 0, 1, 2...) to our arguments. We then use std::get<Indices>() to individually extract our arguments from the input ArgTupleType, and use Forward_As() to static_cast the arguments to the types needed. This use of the sequence Indices... to individually extract and forward our parameter pack of arguments to std::invoke is a fold expression. Fold expressions unpack an operation on a parameter pack into a series of individual operations. We've also used these in our earlier std::forward calls, but I wanted to call special attention to it here for its use with std::get.

The Call_Invoke() method with a returned object is similar; only the logic for the return value is different. If the result is returned by value, we'll placement-new the result of our std::apply and std::invoke calls into the aResult byte buffer. And if the result is returned by reference, we'll store a pointer to that reference in the pointer pointed-to by aResult:

//Class template parameters removed for brevity
template <typename StoredType, typename... CastArgTypes>
void AnyCallable::Call_Invoke(StoredType& aInvokable, const void* aArgs, 
    std::byte* aResult)
{
    //If no argument cast needed, call aInvokable via std::apply
    {
        //...
        if constexpr (!std::is_reference_v<ReturnType>)
            new (aResult) ReturnType(std::apply(/* ... */));
        else
        {
            ReturnType cReference = std::apply(/* ... */);
            *reinterpret_cast<PointerResultType*>(aResult) = &cReference;
        }
    }

    //...
    //use Forward_As() to cast arguments to CastArgTypes...
    {
        //...
        if constexpr (!std::is_reference_v<ReturnType>)
            new (aResult) ReturnType(std::invoke(/* ... */));
        else
        {
            ReturnType cReference = std::invoke(/* ... */);
            *reinterpret_cast<PointerResultType*>(aResult) = &cReference;
        }
    }
    //...
}

Forward_As()

std::forward is designed to help implement wrapper functions that forward their arguments to another function. Here, we're using AnyCallable::operator() to wrap a call to StoredType::operator() (via Call_Invoke()). If we had used std::forward here, it would make sure that the arguments that we pass to StoredType::operator() have the same value category they had when they were passed to AnyCallable::operator(). If no form of forwarding is used, then (e.g.) all of the rvalue inputs to AnyCallable::operator() would be forwarded to StoredType::operator() as lvalue references.

Thus std::forward<T> forwards rvalue inputs as rvalues, and forwards lvalue inputs as lvalues if T is an lvalue reference, else it forwards them as rvalues. It is implemented by casting the input as static_cast(T&&). Forward_As<InputType, OutputType>() is implemented similarly, but there are several important differences.

The main difference with Forward_As() is that we are (potentially) static_cast'ing to a different type altogether than was inputted. The danger is that if the cast creates a new object, we need to make sure that we don't return a dangling reference to this temporary object. Unfortunately, there is no direct facility for determining when this will occur; we can't directly query whether a static_cast will invoke a constructor call or not.

However, we know that a new object will not be created if OutputType is the same-as or a base-of InputType. We also know that a new object will not be created if OutputType is a reference: it must refer to a pre-existing object, or else the behavior is undefined anyway. I believe that these are the only cases where a static_cast will create new objects ... unless of course you do something really bizarre in your own cast-operator methods. I'm sure you can find a way to get undefined behavior here if you really want to.

Anyway, we create the following helper function for the concepts requirements that we'll use:

template <typename InputType, typename OutputType>
constexpr bool Does_CastCreateObject()
{
    return !std::is_base_of_v<std::decay_t<OutputType>, 
        std::decay_t<InputType>> && !std::is_reference_v<OutputType>;
}

If we detect that a cast will create a new object, we will instead return the temporary by value instead of by reference. Any unnecessary copy that this may cause will likely be optimized away by the compiler. This is implemented as:

//Return inputs as non-ref IF the cast creates a temporary
template <typename InputType, typename OutputType>
constexpr OutputType Forward_As(const std::remove_reference_t<InputType>& 
    aInput) requires(Does_CastCreateObject<InputType, OutputType>())
{
    //This handles T, T&, or T&& -> U by returning U (instead of U&&)
    return static_cast<OutputType>(aInput);
}

Now, if the cast was to a const lvalue reference, we could return a temporary by-value, and it will bind to const OutputType& on function input. But it is not possible to pre-detect whether a temporary will be created in this case. We could always return by value, but this will incur the penalty of additional copies when temporaries are not created. It is better to simply not allow this case, as a cast to const OutputType& that creates a temporary can be avoided with a better choice of function signatures. After all, no cast from AnyObjectBase will create a temporary, so something fishy is going on with the argument types anyway.

When not creating temporaries, Forward_As() has different function definitions for binding to lvalue-reference inputs and rvalue-reference inputs, similar to std::forward(). This allows us to detect and assert that we aren't forwarding an rvalue as an lvalue reference, as this may result in a dangling reference. For lvalue inputs:

//NO TEMPORARY, LVALUE INPUTS:
//Forward inputted lvalues as:
//lvalues (U&)  IF OutputType is an lvalue-ref (U&)
//rvalues (U&&) IF OutputType is a non-ref (U) or rvalue-ref (U&&)
template <typename InputType, typename OutputType>
constexpr OutputType&& Forward_As(std::remove_reference_t<InputType>& aInput)
    requires(!Does_CastCreateObject<InputType, OutputType>())
{
    //The standard way of defining conversion operators is to:
    //Cast to T&, T&&, or const T& based on if the object is an L/R/L-value
    //Therefore, cast input to correct refness for return cast to work
    if constexpr (std::is_lvalue_reference_v<OutputType&&>)
        return static_cast<OutputType&&>(aInput); //lvalue input, lvalue cast
    else //Need an rvalue obj for rvalue cast operator
    {
        using InputCastType = std::remove_reference_t<InputType>&&;
        return static_cast<OutputType&&>(static_cast<InputCastType>(aInput));
    }
}

The standard way of implementing user-defined cast operators is to cast to an lvalue/rvalue reference for lvalue/rvalue objects, as done for AnyObjectBase in Part I of this blog series. If we need to cast to an rvalue-reference OutputType, we first need to cast aInput from an lvalue to an rvalue for the cast to OutputType&& to be available. We then cast to OutputType&&, which is an lvalue reference if OutputType is an lvalue, else it is an rvalue reference, similar to std::forward.

The rvalue-input method is identical, except for the static_assert preventing forwarding rvalues as lvalues (potentially creating a dangling reference):

//NO TEMPORARY, RVALUE INPUTS:
//Forward inputted rvalues as rvalues U(&&) of OutputType
template <typename InputType, typename OutputType>
constexpr OutputType&& Forward_As(std::remove_reference_t<InputType>&& 
    aInput) requires(!Does_CastCreateObject<InputType, OutputType>())
{
    //Could create dangling reference: disallow (as in std::forward)
    static_assert(!std::is_lvalue_reference_v<OutputType>, 
        "Can't forward rvalues as lvalues.");

    //Rest of body identical to lvalue inputs version
    //...
}

AnyCallable: Specifying Argument Casting

How do we indicate that we want to static_cast our arguments to different types when forwarding them? We do that when we set the callable itself. Rather than showing all of the methods where we can do this, we'll only look at the most involved one: the AnyCallable constructor allowing us to emplace an InputType callable that takes InputArgs... as arguments:

//Class template parameters removed for brevity
template <typename InputType, typename... InputArgs, typename... EmplaceArgs>
AnyCallable::AnyCallable(std::in_place_type_t<InputType>, 
    TypeList<InputArgs...>, EmplaceArgs&&... aArguments)
    requires(Is_Storable<InputType, InputArgs...>()) :
    AnyObjectType(std::in_place_type<InputType>, 
        std::forward<EmplaceArgs>(aArguments)...)
{
    mDispatcher = &Call_Method<std::decay_t<InputType>, InputArgs...>;
}

std::in_place_type<InputType> is used to indicate the type of callable to emplace and a TypeList is used to encapsulate the types of arguments it needs. We need to use a TypeList because it's the simplest way of specifying two different parameter packs (EmplaceArgs... is the other) for the same function. Here TypeList can be simply:

template <typename... Types>
struct TypeList {};

The work of emplacing the object is forwarded on to the AnyObject base class, and mDispatcher is set to the function pointer for our Call_Method() with our arguments InputArgs.... If we didn't need to cast our arguments, then we would just call a different constructor, one without the TypeList argument, and use the AnyCallable template parameter pack ArgTypes... for the Call_Method() function pointer.

Custom Type-Erased Interfaces

Custom interfaces on type-erased objects can be created similarly to how AnyCallable was implemented. You inherit your type from AnyObject, and extend the enum-like class AnyCall as we did for FunctionCall. You then set mDispatcher to the Call_Method() unique to your class, and defer unrecognized operations (like copy and move) to AnyObject.

An example of this AnyAllocator, which can be used to store memory allocators of different types into a single container. Unlike with runtime polymorphism, these allocators can be stored on the stack, and don't require extra pointer hopping to use them. The implementation of the main Allocate() and Free() calls are:

//Class template parameters removed for brevity
std::byte* AnyAllocator::Allocate(std::intmax_t aNumBytes, int aAlignment)
{
    auto cArgsTuple = std::make_tuple(aNumBytes, aAlignment);
    auto cArgsPointer = reinterpret_cast<const void*>(&cArgsTuple);

    std::byte* cAllocation;
    auto cResult = reinterpret_cast<std::byte*>(&cAllocation);

    mDispatcher(mStorage, AllocCall::Allocate, cArgsPointer, cResult);
    return cAllocation;
}

//Called by Call_Method()
template <typename StoredType>
void AnyAllocator::Call_Allocate(StoredType&& aLHS, const void* aArgs, 
    std::byte* aResult)
{
    using ArgTupleType = std::tuple<std::intmax_t, int>;
    auto& cTuple = *std::launder(reinterpret_cast<ArgTupleType*>(
        const_cast<void*>(aArgs)));

    auto cReturn = std::launder(reinterpret_cast<std::byte**>(
        aResult));
    *cReturn = aLHS.Allocate(std::get<0>(cTuple), std::get<1>(cTuple));
}

void AnyAllocator::Free(std::byte* aAddress)
{
    auto cArgsPointer = reinterpret_cast<const void*>(aAddress);
    mDispatcher(mStorage, AllocCall::Free, cArgsPointer, nullptr);
}

//Called by Call_Method()
template <typename StoredType>
void AnyAllocator::Call_Free(StoredType&& aLHS, const void* aArgs)
{
    aLHS.Free(std::launder(reinterpret_cast<std::byte*>(const_cast<void*>(
        aArgs))));
}

Conclusion

Type erasure is an extremely useful tool that allows us to store objects of different types in the same container, and to use those objects through a common interface. The standard-provided std::any and std::function types require dynamic memory allocations, and have been replaced with AnyTrivial, AnyObject, and AnyCallable, which can be placed on the stack. AnyObject can also be inherited to support other custom interfaces, such as type-erased allocators with AnyAllocator.

These type-erased classes are type-safe, can be adjacent to each other in containers, support non-copyable and non-movable objects, and respect const-ness. They are much faster than classes using runtime polymorphism, which requires not only dynamic allocations but also multiple indirections via their virtual tables. Although these classes are advanced, using them is relatively straightforward and can yield significant performance improvements in large-scale applications.

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!