I am writing a type-erased function wrapper similar to std::function
. (Yes, I have seen similar implementations and even the p0288r0 proposal, but my use-case is quite narrow and somewhat specialized.). The heavily simplified code below illustrates my current implementation:
class Func{ alignas(sizeof(void*)) char c[64]; //align to word boundary struct base{ virtual void operator()() = 0; virtual ~base(){} }; template<typename T> struct derived : public base{ derived(T&& t) : callable(std::move(t)) {} void operator()() override{ callable(); } T callable; }; public: Func() = delete; Func(const Func&) = delete; template<typename F> //SFINAE constraints skipped for brevity Func(F&& f){ static_assert(sizeof(derived<F>) <= sizeof(c), ""); new(c) derived<F>(std::forward<F>(f)); } void operator () (){ return reinterpret_cast<base*>(c)->operator()(); //Warning } ~Func(){ reinterpret_cast<base*>(c)->~base(); //Warning } };
Compiled, GCC 6.1 warns about strict-aliasing :
warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing] return reinterpret_cast<T*>(c)->operator()();
I also know about the strict-aliasing rule. On the other hand, I currently do not know of a better way to make use of small object stack optimization. Despite the warnings, all my tests passes on GCC and Clang, (and an extra level of indirection prevents GCC's warning). My questions are:
See full example: Live on Coliru
"Strict aliasing is an assumption, made by the C (or C++) compiler, that dereferencing pointers to objects of different types will never refer to the same memory location (i.e. alias each other.)"
In C, C++, and some other programming languages, the term aliasing refers to a situation where two different expressions or symbols refer to the same object.
Pointer aliasing is a hidden kind of data dependency that can occur in C, C++, or any other language that uses pointers for array addresses in arithmetic operations. Array data identified by pointers in C can overlap, because the C language puts very few restrictions on pointers.
Type Punning In a systems language like C++ you often want to interpret a value of type A as a value of type B where A and B are completely unrelated types. This is called type punning. Take for example the ever popular Fast Inverse Square Root.
First, use std::aligned_storage_t
. That is what it is meant for.
Second, the exact size and layout of virtual
types and their decendants is compiler-determined. Allocating a derived class in a block of memory then converting the address of that block to a base type may work, but there is no guarantee in the standard it will work.
In particular, if we have struct A {}; struct B:A{};
there is no guarantee unless you are standard layout that a pointer-to-B
can be reintepret
ed as a pointer-to-A
(especially throught a void*
). And classes with virtual
s in them are not standard layout.
So the reinterpretation is undefined behavior.
We can get around this.
struct func_vtable { void(*invoke)(void*) = nullptr; void(*destroy)(void*) = nullptr; }; template<class T> func_vtable make_func_vtable() { return { [](void* ptr){ (*static_cast<T*>(ptr))();}, // invoke [](void* ptr){ static_cast<T*>(ptr)->~T();} // destroy }; } template<class T> func_vtable const* get_func_vtable() { static const auto vtable = make_func_vtable<T>(); return &vtable; } class Func{ func_vtable const* vtable = nullptr; std::aligned_storage_t< 64 - sizeof(func_vtable const*), sizeof(void*) > data; public: Func() = delete; Func(const Func&) = delete; template<class F, class dF=std::decay_t<F>> Func(F&& f){ static_assert(sizeof(dF) <= sizeof(data), ""); new(static_cast<void*>(&data)) dF(std::forward<F>(f)); vtable = get_func_vtable<dF>(); } void operator () (){ return vtable->invoke(&data); } ~Func(){ if(vtable) vtable->destroy(&data); } };
This no longer relies upon pointer conversion guarantees. It simply requires that void_ptr == new( void_ptr ) T(blah)
.
If you are really worried about strict aliasing, store the return value of the new
expression as a void*
, and pass that into invoke
and destroy
instead of &data
. That is going to be beyond reproach: the pointer returned from new
is the pointer to the newly constructed object. Access of the data
whose lifetime has ended is probably invalid, but it was invalid before as well.
When objects begin to exist and when they end is relatively fuzzy in the standard. The latest attempt I have seen to solve this issue is P0137-R1, where it introduces T* std::launder(T*)
to make the aliasing issues go away in an extremely clear manner.
The storage of the pointer returned by new
is the only way I know of that clearly and unambiguously does not run into any object aliasing problems prior to P0137.
The standard did state:
If an object of type T is located at an address A, a pointer of type cv T* whose value is the address A is said to point to that object, regardless of how the value was obtained
the question is "does the new expression actually guarantee that the object is created at the location in question". I was unable to convince myself it states so unambiguously. However, in my own type erasure implementions, I do not store that pointer.
Practically, the above is going to do much the same as many C++ implementations do with virtual functions tables in simple cases like this, except there is no RTTI created.
The better option is to use the Standard-provided facility for aligned storage for object creation, which is called aligned_storage
:
std::aligned_storage_t<64, sizeof(void*)> c; // ... new(&c) F(std::forward<F>(f)); reinterpret_cast<T*>(&c)->operator()(); reinterpret_cast<T*>(&c)->~T();
Example.
If available, you should use std::launder
to wrap your reinterpret_cast
s: What is the purpose of std::launder?; if std::launder
is not available you can assume that your compiler is pre-P0137 and the reinterpret_cast
s are sufficient per the "points to" rule ([basic.compound]/3). You can test for std::launder
using #ifdef __cpp_lib_launder
; example.
Since this is a Standard facility, you are guaranteed that if you use it in accordance with the library description (i.e. as above) then there is no danger of getting burned.
As a bonus, this will also ensure that any compiler warnings are suppressed.
One danger not covered by the original question is that you're casting the storage address to a polymorphic base type of your derived type. This is only OK if you ensure that the polymorphic base has the same address ([ptr.launder]/1: "An object X
that is within its lifetime [...] is located at the address A
") as the complete object at construction time, as this is not guaranteed by the Standard (since a polymorphic type is not standard-layout). You can check this with an assert
:
auto* p = new(&c) derived<F>(std::forward<F>(f)); assert(static_cast<base*>(p) == std::launder(reinterpret_cast<base*>(&c)));
It would be cleaner to use non-polymorphic inheritance with a manual vtable, as Yakk proposes, as then the inheritance will be standard-layout and the base class subobject is guaranteed to have the same address as the complete object.
If we look into the implementation of aligned_storage
, it is equivalent to your alignas(sizeof(void*)) char c[64]
, just wrapped in a struct
, and indeed gcc can be shut up by wrapping your char c[64]
in a struct
; although strictly speaking after P0137 you should use unsigned char
rather than plain char
. However, this is a rapidly evolving area of the Standard, and this could change in future. If you use the provided facility you have a better guarantee that it will continue to work.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With