C++ Type Erasure  on the Stack - Part II

C++ Type Erasure on the Stack - Part II

In Part I of this blog series, we covered how to get a string with our type name, how to safely store type-erased objects, and how to handle trivial types. Now we'll cover how to handle type-erased storage of general types (AnyObject): ones whose copy, move, and destruction operations must be invoked explicitly. Type-erased callables and creating custom type-erased interfaces will be discussed in Part III.

AnyObject: Object Policy

Unlike trivial types, general objects may not always be copyable (e.g. types owning a unique resource), and some may not even be movable (e.g. types holding references). To store these types of objects, we need to disable the copy and move functions of AnyObject, or else our program won't compile. Therefore, this knowledge needs to be encoded into the type of AnyObject itself. To do so we'll create the ObjectPolicy enum, and make it extendable as MetaObjectPolicy for derived classes to add their own policies (as we'll see in Part III of this blog series):

//Copyable/Movable: Only needs to be copy/move-constructible, not assignable
enum class ObjectPolicy : int
{
    NonMovable = 0, MoveOnly, Copyable, //Copyable implies movable
    FirstEnum = NonMovable, LastEnum = Copyable
};

//Use MetaExtendedEnum so we can use it as a template parameter!
using MetaObjectPolicy = BranchedMetaEnum<ObjectPolicy>;

With AnyObject inheriting from AnyObjectBase (set as BaseClass), here's the check to see if an object of a given type can be stored in AnyObject:

//Class declaration
template <int Size, MetaObjectPolicy Policy = ObjectPolicy::Copyable, 
    int Alignment = 8>
class AnyObject;

template <int Size, MetaObjectPolicy Policy, int Alignment>
template <typename ObjectType>
constexpr bool AnyObject<Size, Policy, Alignment>::Is_Storable()
{
    using StoredType = std::decay_t<ObjectType>;

    //If the policy allows X, the object better allow X
    //If the policy prevents X, we need to disable the X member functions
    return BaseClass::template Is_Storable<StoredType>() && 
        std::is_destructible_v<StoredType> && 
        (std::copy_constructible<StoredType> || !Is_Copyable()) && 
        (std::move_constructible<StoredType> || !Is_Movable());
}

//Class template parameters removed for brevity
constexpr bool AnyObject::Is_Copyable()
{
    return (Policy == To_Underlying(ObjectPolicy::Copyable));
}

constexpr bool AnyObject::Is_Movable()
{
    return (Policy != To_Underlying(ObjectPolicy::NonMovable));
}

The default policy is ObjectPolicy::Copyable, which is the least surprising to users, and more restrictions are available by choosing a different policy. Note that a type need not be fully copyable or movable for the given policy, as long as it is copy/move constructible. For example, lambdas are not move-assignable, but in operator=(AnyObject&&) we can delete whatever we have stored in AnyObject and move-construct the lambda there instead.

AnyObject: Type-Erased Function Calls

For types that aren't trivial, we have to call their copy/move/destructor functions instead of simply copying the bytes or relying on the compiler to implicitly destroy the object. However, we can't call them if we don't know what the stored type is, because we don't know what functions to call! The solution is to again embed the type of the stored object in a function template parameter, as we did in TypeIndex in Part I of this blog series. We will then store a pointer to this function in our AnyObject class.

Instead of storing one function pointer for each type of type-erased function we want to call (wasting memory), we want to store only one. To do that though we need to generalize this function for every possible call on our stored object that we might want to make. For AnyObject we'll just want to copy/move/destroy, but for AnyCallable we will also want to call operator(), for which all sets of function arguments and return types are possible. To do this, the generalized function is:

//Class template parameters removed for brevity
template <typename StoredType>
void AnyObject::Call_Method(std::byte* aObject, ExtendedAnyCall aCall, 
    const void* aArgs, std::byte* aResult)
{
    //...
}

We'll pass a pointer to the object storage buffer as the first argument, the type of call we want to make (ExtendedAnyCall) as the second, ALL of the function arguments with the third, and a pointer to a byte buffer where we can store the result as the fourth argument. This signature is so generic it can cover any possible function call we might want to make.

ExtendedAnyCall makes the AnyCall enum extendable indicating what function call we want to make. AnyCallable and other user-defined types will want to inherit from AnyObject, so we want them to be able to extend this enum to add new methods as well.

enum class AnyCall : int
{
    Destruct = 0, CopyAssign, CopyConstruct, MoveAssign, MoveConstruct, 
    FirstEnum = Destruct, LastEnum = MoveConstruct
};

using ExtendedAnyCall = Meta::BranchedEnum<AnyCall>;

Call_Method() above must be a member function of AnyObject, as otherwise we wouldn't be able to cast its void* function argument to AnyObject<> for the copy and move function calls, as we'll see a little below. This is because we wouldn't know what the template parameters of the AnyObject would need to be. This also prevents us from being able to copy-assign (e.g.) AnyObjects of different sizes to one another.

However Call_Method(), can be a static member function: since we are passing it our object storage we don't need the this pointer, and thus can reference it via a simple function pointer (mDispatcher) stored within AnyObject:

template <int Size, MetaObjectPolicy Policy, int Alignment>
class AnyObject : public AnyObjectBase<Size, Alignment>
{
private:
    using BaseClass = AnyObjectBase<Size, Alignment>;
    using BaseClass::mTypeIndex;
    using BaseClass::mStorage;
//...

protected:
    using DispatcherType = void(std::byte*, ExtendedAnyCall, 
        const void*, std::byte*);
    DispatcherType* mDispatcher = nullptr;

    template <typename StoredType>
    static void Call_Method(std::byte* aObject, ExtendedAnyCall aCall, 
        void* aArgs, std::byte* aResult);
//...
}

Inside of Call_Method(), we'll switch on the value of ExtendedAnyCall, and forward the data to the appropriate (static) member function for the requested task. The functions for these tasks will be defined later, but for now, here is Call_Method()'s implementation:

//Class template parameters removed for brevity
template <typename StoredType>
void AnyObject::Call_Method(std::byte* aObject, ExtendedAnyCall aCall, 
    const void* aArgs, std::byte*)
{
    switch (aCall.Get())
    {
    case To_Underlying(AnyCall::Destruct):
        Call_Destructor<StoredType>(aObject);
        break;
    case To_Underlying(AnyCall::CopyConstruct):
    {
        if constexpr (Is_Copyable())
            Call_CopyConstruct<StoredType>(aObject, aArgs);
        else
            Assert(false, "Cannot copy with this policy!\n");
        break;
    }
    case To_Underlying(AnyCall::CopyAssign):
    {
        //Copy-assign if supported, else copy-construct
        if constexpr (!Is_Copyable())
            Assert(false, "Cannot copy with this policy!\n");
        else if constexpr (std::copyable<StoredType>)
            Call_CopyAssign<StoredType>(aObject, aArgs);
        else
        {
            Call_Destructor<StoredType>(aObject);
            Call_CopyConstruct<StoredType>(aObject, aArgs);
        }
        break;
    }
    case To_Underlying(AnyCall::MoveConstruct):
        //Analogous to CopyConstruct(), not shown for brevity
        break;
    case To_Underlying(AnyCall::MoveAssign):
        //Analogous to CopyAssign(), not shown for brevity
        break;
    default:
        Assert(false, "Call not implemented!\n");
        break;
    }
}

Also, since this one function pointer (to Call_Method()) is being used to redirect all possible operations on our stored data, we have passed the object storage as a non-const pointer. It will be up to the implementation of downstream methods to respect const-ness as needed. Although all of the operations in AnyCall require a non-const object, classes inheriting from AnyObject will have to be careful with their implementations.

AnyObject: Copy/Move/Destroy

Now we'll use the stored mDispatcher function pointer to perform type-erased copy, move, and destroy operations. First the destructor, since that is the simplest:

//Class template parameters removed for brevity
AnyObject::~AnyObject()
{
    Destroy_StoredObject();
}

void AnyObject::Destroy_StoredObject()
{
    //nullptrs: No additional arguments, no return value
    if (Has_Object())
        mDispatcher(mStorage, AnyCall::Destruct, nullptr, nullptr);
}

//This is called by ::Call_Method(), which is set to mDispatcher
template <typename StoredType>
void AnyObject::Call_Destructor(std::byte* aStorage)
{
    auto cReinterpreted = reinterpret_cast<StoredType*>(aStorage);
    auto cStoredObject = std::launder(cReinterpreted);
    std::destroy_at(cStoredObject);
}

In Destroy_StoredObject(), we only call mDispatcher if we have an object to destroy in the first place. There are no additional arguments or return values needed, just the storage for the object itself. Then when Call_Destructor() gets called by Call_Method(), we reinterpret the storage as the now-known StoredType, std::launder the memory, and call its destructor.

Although the destructor implementation is quite simple, copying objects is more complex. First the copy constructor, which isn't too complicated:

//Class template parameters removed for brevity
AnyObject::AnyObject(const AnyObject& aRHS) requires(Is_Copyable(Policy)) : 
    BaseClass(aRHS.mTypeIndex), mDispatcher(aRHS.mDispatcher)
{
    if (!BaseClass::Has_Object())
        return; //We already set mTypeIndex, and it is void: nothing to copy

    //nullptr: no return value
    auto cVoidRHS = reinterpret_cast<const void*>(&aRHS);
    mDispatcher(mStorage, AnyCall::CopyConstruct, cVoidRHS, nullptr);
}

//This is called by ::Call_Method(), which is set to mDispatcher
template <typename StoredType>
void AnyObject::Call_CopyConstruct(std::byte* aStorage, 
    const void* aArguments) requires(Is_Copyable())
{
    //Get the StoredType from input AnyObject, copy-construct it into storage
    auto cAnyObject = reinterpret_cast<const AnyObject*>(aArguments);
    const auto& cInputObject = cAnyObject->template Get<StoredType>();
    new (aStorage) StoredType(cInputObject);
}

We first set our mTypeIndex (AnyObjectBase constructor) and mDispatcher members, then exit early if the input object didn't have anything to copy. We then cast the object we're copying from to void*, then pass it through to Call_Method(). There, all we have to do is reinterpret the argument back as a const AnyObject*, get a reference to its stored object, then copy-construct a new StoredType into our object storage.

Copy assignment though is even more complicated. If our stored type is the same as the input we can simply copy-assign it, but if not we have to first destroy our object, then copy-construct the input:

//Class template parameters removed for brevity
auto AnyObject::operator=(const AnyObject& aRHS) -> AnyObject&
    requires(Is_Copyable())
{
    //Guard against self-copy-assignment
    if (this == &aRHS)
        return *this;

    //If the type we have stored is different, must destroy ours first
    bool cSameType = (mTypeIndex == aRHS.mTypeIndex);
    if(!cSameType)
        Destroy_StoredObject();

    //Switch to using their StoredType
    mTypeIndex = aRHS.mTypeIndex;
    mDispatcher = aRHS.mDispatcher;
    if (!BaseClass::Has_Object())
        return *this; //nothing to copy

    //Copy-assign if same type, else copy-construct
    auto cOp = cSameType ? AnyCall::CopyAssign : AnyCall::CopyConstruct;

    auto cVoidRHS = reinterpret_cast<const void*>(&aRHS);
    mDispatcher(mStorage, cOp, cVoidRHS, nullptr); //no return value
    return *this;
}

template <typename StoredType>
void AnyObject::Call_CopyAssign(std::byte* aStorage, const void* aArguments)
    requires(Is_Copyable())
{
    //Get the StoredType from input AnyObject, copy-assign it to our object
    auto cAnyObject = reinterpret_cast<const AnyObject*>(aArguments);
    const auto& cInputObject = cAnyObject->template Get<StoredType>();

    auto cThisObject = std::launder(reinterpret_cast<StoredType*>(aStorage));
    *cThisObject = cInputObject;
}

The assignment operator forwards the object to Call_Method(), which as discussed earlier will copy-assign if StoredType is copy-assignable, else it will copy-construct. In Call_CopyAssign(), we have to reinterpret the input argument as AnyObject to then extract the StoredType object. Finally, we reinterpret our object storage as StoredType to perform the copy assignment.

To copy-assign a new, user-supplied object of type InputType to our AnyObject, we first check to see if it's the same type as what we've already stored. If it is, then we can just copy the object directly instead of needing to rely on the type-erased method call:

//Class template parameters removed for brevity
template <typename InputType>
auto AnyObject::operator=(const InputType& aInput) -> AnyObject&
    requires(!sThisisBaseOf<InputType> && Is_Storable<InputType>())
{
    using ToStoreType = std::decay_t<InputType>;

    //Check if same type
    if(mTypeIndex != Make_TypeIndex<ToStoreType>())
        Emplace<ToStoreType>(std::forward<ToStoreType>(aInput)); //Nope
    else //We know the type! Just do it directly
    {
        auto cObj = std::launder(reinterpret_cast<ToStoreType*>(mStorage));
        if constexpr (std::copyable<ToStoreType>)
            *cObj = aInput; //copy-assign
        else //Not copy-assignable: Destroy and copy-construct
        {
            std::destroy_at(cObj);
            BaseClass::template Construct<ToStoreType>(aInput);
        }
    }

    return *this;
}

If it's a different type, then we defer the work to the Emplace() function. This method destroys our currently-stored object (if any), sets the AnyObject member variables, then invokes the AnyObjectBase::Construct() method to store the object:

//Class template parameters removed for brevity
template <typename InputType, typename... ArgTypes>
inline auto& AnyObject::Emplace(ArgTypes&&... aArgs)
    requires(Is_Storable<InputType>())
 {
    //Destroy our object first
    Destroy_StoredObject();

    //Set members
    using StoredType = std::decay_t<InputType>;
    mDispatcher = &Call_Method<StoredType>;
    mTypeIndex = Make_TypeIndex<StoredType>();

    //Construct
    return BaseClass::template Construct<StoredType>(
        std::forward<ArgTypes>(aArgs)...);
}

That's a lot of steps, but now we can copy-construct and copy-assign our objects safely in a type-erased manner. The move-construct and move-assign operations are very similar and are thus not shown. When implementing them, we'll need to const_cast the Call_Method() arguments to void* so that we can std::move() from the inputted objects.

AnyObject: Wrapping Up

We've covered the vast majority of AnyObject, only a few small things remain. First the primary constructors, which simply set the member variables and either construct or emplace the InputType object into its storage:

//Class template parameters removed for brevity    template
//Forward an object of InputType for storage in AnyObject
template <typename InputType>
AnyObject::AnyObject(InputType&& aInput)
    requires(!sThisIsBaseOf<InputType> && Is_Storable<InputType>()) : 
    BaseClass(Make_TypeIndex<std::decay_t<InputType>>()),       
    mDispatcher(&Call_Method<std::decay_t<InputType>)
{
    BaseClass::template Construct<InputType>(
        std::forward<InputType>(aInput));
}

//Emplace an object of InputType into AnyObject
template <typename InputType, typename... EmplaceArgTypes>
inline AnyObject::AnyObject(std::in_place_type_t<InputType>, 
    EmplaceArgTypes&&... aArgs) requires(Is_Storable<InputType>()) : 
    BaseClass(Make_TypeIndex<std::decay_t<InputType>>()), 
    mDispatcher(&Call_Method<std::decay_t<InputType>)
 {
    BaseClass::template Construct<InputType>(
        std::forward<EmplaceArgTypes>(aArgs)...);
}

The sThisIsBaseOf concept requirement is similar to that from AnyTrivial discussed in Part I of this blog series. The constructor, copy-assignment, and move-assignment operators accepting InputType&& have the !sThisisBaseOf<InputType> requirement.

template <int Size, MetaObjectPolicy Policy, int Alignment>
class AnyObject : public AnyObjectBase<Size, Alignment>
{
private:
    // 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<AnyObject, std::remove_reference_t<DataType>>;
//...
};

Conclusion

We now have a way of storing and retrieving any type-erased object in a type-safe manner (AnyObject). Unlike std::any, this object can be stored directly on the stack, which is significantly more efficient, especially when iterating through containers (std::any requires a lot of pointer-hopping, resulting in many cache misses).

In Part III of this series, we'll discuss how to have type-safe callables (improving on std::function), as well as how to add other type-erased functionality.

License & Disclaimers

Support This Blog!

Did you find this article valuable?

Support Paul Mattione by becoming a sponsor. Any amount is appreciated!