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.