The C++ logo, by Jeremy Kratz, licensed under CC0 1.0 Universal

Owning type erasure for simple types

Notes published the
14 - 18 minutes to read, 3571 words
Categories: c++
Keywords: c++ type erasure

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 or volatile

  • 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), […​]

— Object model, from [intro.object]
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.

— Object model, from [intro.object]
the C++ standard

The fact that memcpy can create implicitly an object (emphasis mine):

The functions memcpy and memmove 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.

— Header <cstring> synopsis
the 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.

— Object model, from [intro.object]
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.

— Types, from [basic.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.

— Properties of classes, from [class.prop]
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

— Aggregates, from [dcl.init.aggr]
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.

— Memory model, from [intro.memory]
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))

— Reinterpret cast, from [expr.reinterpret.cast]
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.

— Value category, from [basic.lval]
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

— Alignment, from [basic.align]
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: alone not much.

But together with function_view/function_ref: much more. It makes it easier to avoid having as function parameter whole classes that are needed for calling only one member function.

Granted, it is possible to write a lambda, but it might get verbose, especially if there are multiple parameters.

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, for example, 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 error in the implementation.

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, probably 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.