Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Small object stack storage, strict-aliasing rule and Undefined Behavior

Tags:

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:

  • Will I eventually get burned ignoring the warning for this case?
  • Is there a better way for in-place object creation?

See full example: Live on Coliru

like image 791
WhiZTiM Avatar asked Sep 13 '16 19:09

WhiZTiM


People also ask

What is the strict aliasing rule and why do we care?

"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.)"

What is C++ aliasing?

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.

What is pointer aliasing?

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.

What is Type punning C++?

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.


2 Answers

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 reintepreted as a pointer-to-A (especially throught a void*). And classes with virtuals 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.

like image 99
Yakk - Adam Nevraumont Avatar answered Apr 13 '23 00:04

Yakk - Adam Nevraumont


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_casts: 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_casts 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.

like image 34
ecatmur Avatar answered Apr 12 '23 23:04

ecatmur