Owning type erasure for simple types
For this note, a "simple type" (not defined by the C++ standard), is a type that
-
does not have a user-defined destructor
-
does not have any user-defined constructor
-
where all member variables are simple types
-
where no member variable is a reference
-
where no member variable is marked
const
orvolatile
-
has no virtual functions
Most properties are covered by trivially copyable types, but I wanted to list some properties explicitly and set some additional restrictions.
This definition of "simple type" leaves classes like std::string
out, and accepts structures of integers, non-void
fundamental types, and pointers.
Until now, I’ve only described type erasure for non-owning types, mostly to be used as a parameter (for example std::string_view
, std::span
, map_view
, …), and stateless class hierarchies.
The implementation technique is robust, as the chances to invoke UB in the implementation are minimal (if any), and the overhead of the abstraction is negligible.
For owning types, abstractions like std::function
or std::any
have an overhead, for example, they generally both require an allocation, not controlled by the end-user.
Since for my use case, I only needed to work with such simple types and all with the same size; I could use a more lightweight abstraction and take some shortcuts in the implementation.
buffer of data, memcpy
, and implicit lifetimes
Warning ⚠️ | This section has a lot of references to the C++ standard; do not panic. |
Implicit lifetime is defined in the standard since P0593 🗄️ (thus in C++23). As it standardizes existing practice, chances are good that even when working with an older standard, what is being described would still work.
I’ve added all the references because the topic is not well known, errors are easy to make, and difficult do find. Thus I needed to recheck those multiple times to be sure I did everything right (I did not, multiple times).
First, the fact that an object can be created implicitly
The constructs in a C++ program create, destroy, refer to, access, and manipulate objects. An object is created by a definition […], by a new-expression […], by an operation that implicitly creates objects (see below), […]
the C++ standard
Then the relation between the creation of an object on an array of unsigned char
:
If a complete object is created […] in storage associated with another object e of type "array of N unsigned char" or of type "array of N std::byte" […], that array provides storage for the created object if:
the lifetime of e has begun and not ended, and
the storage for the new object fits entirely within e, and
there is no smaller array object that satisfies these constraints.
the C++ standard
The fact that memcpy
can create implicitly an object (emphasis mine):
The functions
memcpy
andmemmove
are signal-safe […]. Both functions implicitly create objects […] in the destination region of storage immediately prior to copying the sequence of characters to the destination.
<cstring>
synopsisthe C++ standard
And how functions that implicitly create objects work:
Some operations are described as implicitly creating objects within a specified region of storage. For each operation that is specified as implicitly creating objects, that operation implicitly creates and starts the lifetime of zero or more objects of implicit-lifetime types […] in its specified region of storage if doing so would result in the program having defined behavior.
the C++ standard
What are implicit-lifetime types
?
Arithmetic types […], enumeration types, pointer types, pointer-to-member types […], std::nullptr_t, and cv-qualified […] versions of these types are collectively called scalar types. Scalar types, trivially copyable class types […], arrays of such types, and cv-qualified versions of these types are collectively called trivially copyable types. Scalar types, trivial class types […], arrays of such types and cv-qualified versions of these types are collectively called trivial types. Scalar types, standard-layout class types […], arrays of such types and cv-qualified versions of these types are collectively called standard-layout types. Scalar types, implicit-lifetime class types […], array types, and cv-qualified versions of these types are collectively called implicit-lifetime types.
the C++ standard
And
A class
S
is an implicit-lifetime class if it is an aggregate or has at least one trivial eligible constructor and a trivial, non-deleted destructor.
the C++ standard
Thus one trivial constructor and destructor seems to be sufficient.
The definition of aggregate is
An aggregate is an array or a class […] with
no user-declared or inherited constructors […],
no private or protected direct non-static data members […],
no virtual functions […], and
no virtual, private, or protected base classes […].
[Note: Aggregate initialization does not allow accessing protected and private base class' members or constructors. - end note]
The elements of an aggregate are:
for an array, the array elements in increasing subscript order, or
for a class, the direct base classes are in declaration order, followed by the direct non-static data members
the C++ standard
Note that struct s {int i; std::string s;};
counts as aggregate, s
does not have user-declared constructors (or a user-declared destructor).
Thus s
is an implicit-lifetime
type, but it’s subobject not!
Additionally
An operation that begins the lifetime of an array of unsigned char or std::byte implicitly creates objects within the region of storage occupied by the array.
the C++ standard
This means that if an array is copied (for example if it is a member variable of a struct
or class
), and if there is an implicitly created object, the copy of the array will have an implicitly created object too.
Warning ⚠️ | There is no wording that applies to the assignment operator. This issue has been reported by mail to the ISO C++ committee. If the standard is updated accordingly, the assignment operator will work consistently, otherwise one needs to implement an assignment operator that uses std::memcpy internally. For brevity (and because I’m optimistic that the standard will be updated accordingly), examples in this note assume that the assignment operator does the right thing. |
To put together those quotes from the standard:
int i = 42;
unsigned char buffer[sizeof(int)];
std::copy(buffer, buffer + sizeof(int), &i);
unsigned char* ptr = buffer;
int* j = reinterpret_cast<int*>(ptr);
*j;
Is not valid, the main reason is that buffer
does not store an int
; there is no int
object at *j
.
int i = 42;
unsigned char buffer[sizeof(int)];
std::memcpy(buffer, &i, sizeof(int));
unsigned char* ptr = buffer;
int* j = reinterpret_cast<int*>(ptr);
*j;
Even if it generates the same assembly, it is better, as now an implicit lifetime has been started/an implicit object has been created.
I was made aware that even if the buffer stores an int
, using reinterpret_cast
to get an int*
is not enough:
An object pointer can be explicitly converted to an object pointer of a different type. When a prvalue v of object pointer type is converted to the object pointer type "pointer to cv T", the result is
static_cast<cv T*>(static_cast<cv void*>(v))
the C++ standard
and
If a program attempts to access […] the stored value of an object through a glvalue whose type is not similar […] to one of the following types the behavior is undefined
the dynamic type of the object,
a type that is the signed or unsigned type corresponding to the dynamic type of the object, or
a char, unsigned char, or std::byte type.
the C++ standard
Since int
and unsigned char
are not similar, casting the pointer, even if there is an int
object, is UB.
To overcome this issue, one needs to use std::launder
:
int i = 42;
unsigned char buffer[sizeof(int)];
std::memcpy(buffer, &i, sizeof(int));
unsigned char* ptr = buffer;
int* j = std::launder(reinterpret_cast<int*>(ptr));
*j;
This code has nearly well-defined behavior, there is one final piece that I’ve overlooked to take into account:
Attempting to create an object ([…]) in storage that does not meet the alignment requirements of the object’s type is undefined behavior
the C++ standard
Alignment can be set explicitly with alignas
:
int i = 42;
alignas(int) unsigned char buffer[sizeof(int)];
std::memcpy(buffer, &i, sizeof(int));
unsigned char* ptr = buffer;
int* j = std::launder(reinterpret_cast<int*>(ptr));
*j;
Even if it generates exactly the same assembly when using std::copy
instead of std::memcpy
, or when not using std::launder
, or when not using alignas(int)
, it is the only version supported by the C++ standard.
Use-case
Here I’m trying to come up with a simple use-case to show off how this technique can be used. Instead of the classical example of a shape
base class, what about a shape
class that type-erases other classes?
struct square{
double x;
double y;
void draw() const;
};
struct circle {
double radius;
void draw() const;
};
constexpr int size_alignment = 4 * alignof(float);
class shape{
alignas(size_alignment) unsigned char data[size_alignment]{}; // should be big enough for all shapes we want to type-erase
typedef void draw_fun(const unsigned char* buffer);
draw_fun* ptr;
public:
template <typename T>
shape(const T& t) noexcept :
ptr(+[](const unsigned char* buffer){
auto obj = std::launder(reinterpret_cast<const T*>(buffer));
obj->draw();
})
{
static_assert(std::is_trivially_copyable_v<T>, "not trivially copyable, required for memcpy");
static_assert(std::is_copy_assignable_v<T>, "not assignable, avoid strange types");
static_assert(std::is_trivially_destructible_v<T>, "not trivially destructible, required for implicit lifetime");
static_assert(std::is_trivial_v<T>, "not trivial, required for implicit lifetime");
static_assert(sizeof(T) <= sizeof(data), "buffer is not big enough");
static_assert(size_alignment % alignof(T) == 0, "wrong alignment");
std::memcpy(data, &t, sizeof(T));
}
void draw() const {
this->ptr(data);
}
};
In this example, shape
works with any type for which all static_assert
holds true, has an draw
member function, and has a size not bigger than the internal buffer.
Compared to the classic example with virtual functions and class hierarchies, notice that this class has value semantics, and poses more strict requirements on the interface.
If sizeof(data)
is becoming problematic (it is hard to determine a big-enough value, or the big-enough value is too big), it might make sense to expand shape
to allocate memory dynamically if the object passed in during construction is bigger than a certain threshold.
function_view
for member functions
#include <functional>
#include <type_traits>
#include <utility>
#include <cassert>
#include <cstring>
#include <cstdio>
template <typename TSignature>
class member_function_view;
template <typename TReturn, typename... TArgs>
class member_function_view<TReturn( TArgs... )>
{
private:
// type erased object
void* ptr;
// type erase member function pointer
using buffer_type = unsigned char[sizeof( void ( member_function_view::* )() )];
alignas( void ( member_function_view::* )() ) buffer_type buffer{};
// helper function
using signature_type = TReturn( void*, buffer_type&, TArgs... );
signature_type* erased_fn;
public:
template <typename T>
member_function_view( std::nullptr_t, T x ) = delete;
template <typename I, typename T>
member_function_view( I* obj, T member ) noexcept
: ptr( obj )
, erased_fn{ +[]( void* obj, buffer_type& buffer, TArgs... xs ) -> TReturn {
auto fptr = *std::launder(reinterpret_cast<T*>( buffer ));
return ( reinterpret_cast<I*>( obj )->*fptr )( std::forward<TArgs>( xs )... );
} }
{
static_assert( std::is_trivially_copyable_v<T>, "not trivially copyable, required for memcpy" );
static_assert( std::is_copy_assignable_v<T>, "not assignable, avoid strange types" );
static_assert( std::is_trivially_destructible_v<T>, "not trivially destructible, required for implicit lifetime" );
static_assert( std::is_trivial_v<T>, "not trivial, required for implicit lifetime" );
static_assert( sizeof(buffer) == sizeof(T), "buffer is not big enough" );
static_assert( std::is_member_function_pointer_v<T>, "is not pointer to member function" );
static_assert( alignof( void(member_function_view::*)() ) % alignof(T) == 0, "wrong alignment" );
assert( obj != nullptr );
assert( member != nullptr );
std::memcpy( this->buffer, &(member), sizeof(T) );
}
TReturn operator()( TArgs... xs )
{
return erased_fn( ptr, buffer, std::forward<TArgs>( xs )... );
}
};
struct s1{
int foo(bool){std::puts("42"); return 42;}
};
struct s2{
int baz(bool){std::puts("43"); return 43;}
};
void bar(member_function_view<int(bool)> mfv){
mfv(true);
}
int main(){
{
s1 instance1;
bar(member_function_view<int(bool)>(&instance1, &s1::foo));
}
{
s2 instance2;
bar(member_function_view<int(bool)>(&instance2, &s2::baz));
}
}
Note the line alignas( void(member_function_view::*)() )
currently does not compile in MSVC 🗄️. Since it is a parsing issue, a possible workaround is to create a typedef
for void(member_function_view::*)()
.
function_view
(or function_ref
), is a non-owning type erasure for a function-like object with a given function signature.
While member_function_view
is conceptually non-owning too, there does not exist the equivalent of void*
for pointers to member functions. In fact, a pointer to a member is generally bigger than sizeof(void*)
.
For this reason, I needed some storage to store a pointer; a trivial type.
If you are wondering how useful member_function_view
is: not much. You will hardly need it.
function_view
/function_ref
are already able to work with member function, althoug for anything that is not operator()
the syntax is slighty different.
Alternate approaches
std::variant
would be the main alternate approach, but only works well if all types are known when writing the class shape
.
In the given example, all values are known at compile-time. After all sizeof(T)
is resolved at compile time. But there is no need to encode somehow in the structure itself what types are permissible or not. The templated constructor does the heavy lifting and avoids adding direct dependencies to the class.
A union would not offer any advantages over std::variant
, as also, in that case, it is necessary to know all types beforehand.
std::any
would be the main alternate approach, but it has one main disadvantage: it might allocate memory. On the other hand, it has fewer preconditions (it supports any type that can be copied), and creating a shape
class based on it is less likely to have undefined behavior.
The current implementation has multiple gotchas.
First, the buffer needs to be of type unsigned char
or std::byte
. Using char
would lead to undefined behavior.
Second, we need to use memcpy
. Replacing it with std::copy
or something else might lead to undefined behavior.
Third, the type needs to be one whose lifetime can be created implicitly. Adding a constructor or destructor, virtual function, … might break this invariant, and lead to undefined behavior.
Fourth, we need to use std::launder
after casting the pointer to the first element of the buffer before being able to use it. Not doing so would lead to undefined behavior.
Fifth, we need to ensure that the alignment of the buffer is correct.
The biggest drawback to all these gotchas is that they are not trivial to catch. I missed most of them in my previous implementations, and some were found only after asking more expert people.
Do one thing incorrectly, the compiler might even generate the same code, and the program might work as expected, but it will still have undefined behavior.
Also, both std::launder
and implicit lifetime are relatively new (they were introduced respectively in C++17 and C++20), so there is not much experience or examples in the wild. alignas
was introduced with C++11, but there are not many use cases for it, so it is not commonly used.
From what I could see, no static analyzer or compiler reported anything when doing one of the mentioned errors.
Is it possible to lift some conditions or make the type-erased class more generic?
Remove the requirement to be assignable
There is one condition that can be lifted.
The fact that a type has an assignment operator.
Not requiring it means that structures with const
member variables are not an issue, like
struct s{
const int i;
};
Supporting them means that the owning type-erased type can reassign them, but the type itself is not able to do it.
struct s{
const int i;
};
void bar(){
auto instance = s{42}; // compiles, new instance
instance = s{42}; // fails to compile
auto instance2 = instance; // compiles, makes a copy
auto instance3 = type_erased(instance); // compiles, new instance
instance3 = type_erased(instance); // compiles, reassign
auto instance4 = instance3; // compiles, makes a copy
}
While not necessarily an issue, a type might not be reassignable for a good reason.
It would also be strange to "extend" what operations a type supports.
Note that on the other hand, std::any
is able to reassign types that are not assignable, thus there is at least one precedent in the standard library.
And last but not least, non-reassignable trivial types are rare.
Thus I did prefer to leave them out (did not have yet the requirement to type-erase a non-assignable type), but if needed they can be supported too, from the point of view of the standard, they are well supported.
Provide access to the underlying type
The shown classes are not as generic as std::any
or std::variant
, for two reasons.
The first one is that it does not provide a controlled way to access the underlying data.
The second one is that it ties the data with an interface (draw
in the first example, operator()
in the case of member_function_view
).
This makes it hard to provide a generic implementation that can be reused for other type-erased views.
It is possible to add a templated member function for accessing the underlying type with std::type_info
, for example
#include <new>
#include <utility>
#include <cstring>
#include <typeinfo>
#include <cassert>
struct square{
double x;
double y;
void draw() const;
};
struct circle {
double radius;
void draw() const;
};
constexpr int size_alignment = 4 * alignof(float);
class shape{
alignas(size_alignment) unsigned char data[size_alignment]{}; // should be big enough for all shapes we want to type-erase
typedef void draw_fun(const unsigned char* buffer);
draw_fun* ptr;
typedef void get_data_fun(unsigned char* buffer, const std::type_info&, void*& ptr);
get_data_fun* ptr2;
public:
template <typename T>
shape(const T& t) noexcept :
ptr(+[](const unsigned char* buffer){
auto obj = std::launder(reinterpret_cast<const T*>(buffer));
obj->draw();
}),
ptr2(+[](unsigned char* buffer, const std::type_info& ti, void*& ptr) {
if (ti == typeid(T*)){
ptr = std::launder(reinterpret_cast<T*>(buffer));
}
})
{
static_assert(std::is_trivially_copyable_v<T>, "not trivially copyable, required for memcpy");
static_assert(std::is_copy_assignable_v<T>, "not assignable, avoid strange types");
static_assert(std::is_trivially_destructible_v<T>, "not trivially destructible, required for implicit lifetime");
static_assert(std::is_trivial_v<T>, "not trivial, required for implicit lifetime");
static_assert(sizeof(T) <= sizeof(data), "buffer is not big enough");
static_assert(size_alignment % std::alignment_of<T>::value == 0, "wrong alignment");
std::memcpy(data, &t, sizeof(T));
}
void draw() const {
this->ptr(data);
}
template <class U>
U* get_data(){
void* ptr_i = nullptr;
this->ptr2(data, typeid(U*), ptr_i);
return reinterpret_cast<U*>(ptr_i);
}
};
void bar(shape s){
auto sq = s.get_data<square>();
if (sq != nullptr){
// do something meaningful with a square
}
}
void baz(){
auto s = shape(circle{});
bar(s);
}
If not required, I would prefer to not provide such a function, mainly because it breaks the encapsulation, and because it does not help to provide a minimal, safe, and reusable lower-level class for implementing type erasure.
Remove maximum buffer size
In the given examples, the buffer has a maximum size defined at compile time.
It is possible to allocate the buffer at runtime if it is bigger than a given threshold.
It might make sense for certain type-erased types, but definitively not for others.
In the case of member_function_view
it would be an overkill: the size of the pointer is always the same, it’s small, and it is known at compile-time.
std::any
and std::function
generally allocate memory, but they can offer a "small object" optimization, with a buffer of fixed size,
Do you want to share your opinion? Or is there an error, some parts that are not clear enough?
You can contact me anytime.