Cursed C++: reinterpret_cast (yes, all of it)

Let’s start with an example deserializing a bitfield, having already accounted for endianness.

uint32_t parse_bitfield32_LE(char* buffer)
{
    return *reinterpret_cast<uint32_t*>(buffer);
}

This is a crash waiting to happen. First, uint32_t has alignment requirements the buffer likely doesn’t fit, and if so any attempt to access it will raise a hardware exception. Second, it’s technically undefined behavior.

Even if most commonly-used compilers act the same, an esoteric compiler could put anything it wants here, because the main well-defined usage of reinterpret_cast is that casting back to the original type yields the original value. In other words, if static_casting to that type would raise a compile error, it’s not well-defined to access in its reinterpreted form.

int foo = 42;

float* a = reinterpret_cast<float*>(&foo);
*a = 43.0f; // Undefined behavior, foo could be garbage

int* b = reinterpret_cast<int*>(a);
*b = 43; // Well-defined behavior

a = reinterpret_cast<float*>(0xDEADBEEF); // EXTREMELY undefined behavior: likely not even mapped

These rules are similar to a memcpy, or well-defined behavior for unions. In C you may read or write any union member at any time, but in C++ you can only read the most recently-written member.

union
{
    int foo;
    char bar;
    void* ptr;
};

foo = 0xDEADBEEF; // Ok
cout << foo; // Ok
cout << bar; // Undefined behavior but fairly benign: prints either 0xDE or 0xEF

bar = 24;
cout << bar; // Ok
cout << foo; // Undefined behavior and dangerous: are the higher-address bits zero, or untouched?

cout << ptr; // Undefined and extremely dangerous

The standard also defines reinterpret_cast’s behavior to allow access after:

  • Changing signedness of integral types, like static_cast.
  • Casting from derived to base, as if by static_cast.
  • Adding the const or volatile qualifiers to the same type or pointed-to type, like const_cast.
  • Casting to a new type that has a field matching the original type, sort of like a reverse-access.
  • Casting to a maybe-unsigned char, since no types may be narrower. Result may be endianness-dependent.

Interestingly, you may use typeid after reinterpreting, but when the new type is non-polymorphic the typeid will simply be for the declared type.

class Base { virtual ~Base() = default; };
class Derived : public Base {};

class Unrelated { virtual ~Unrelated() = default; };

Derived d;
Base* b = reinterpret_cast<Base*>(d); // Cast from derived to base: OK
// Well-defined: typeid(*b) is Derived

Unrelated* u = reinterpret_cast<Unrelated*>(d); // Cast to unrelated: UB to access value
// Well-defined: typeid(*u) is Derived

float* f = reinterpret_cast<float*>(d); // Cast to unrelated non-polymorphic: UB to access value
// Well-defined: typeid(*f) is float, not Derived

So how is it supposed to be used?

For the same reason that C-style casts are often considered dangerous, reinterpret_cast should generally be avoided where static_cast or const_cast do the job. The main place I see it used correctly is in type erasure.

First, let’s take a look at how std::any can handle deletion on an unknown type without requiring its contents be virtual.

class any
{
    void* obj = nullptr;
    void(*deleter)(void* context) = nullptr;
    
    template<typename T>
    static void call_delete(void* obj)
    {
        // Cast back to original type and delete
        T* typed = reinterpret_cast<T*>(obj);
        delete typed;
    }
    
public:
    template<typename T>
    any& operator=(T&& new_obj)
    {
        clear();
        T* typed = new T(std::move(new_obj));
        obj = reinterpret_cast<void*>(typed); // Erase type: obj is only well-defined when cast back to T*
        deleter = &call_delete<T>; // Will handle casting back to T* before deletion
    }
    
    void clear()
    {
        if(obj) deleter(obj);
        obj = nullptr;
    }
    
    ~any() { clear(); }
};

The same concept applies when implementing std::function. It accepts anything with an operator(), whether that be static function, lambda, or bind expression.

template<typename Ret, typename... Args>
class function
{
    void* callable;
    Ret (*invoker_fn)(void*, Args...);

    template<typename Callable>
    static Ret invoke(void* callable, Args... args)
    {
        Callable& typed = *reinterpret_cast<Callable*>(callable); // Unerase type back to original: well-defined
        return typed( std::forward<Args>(args)... ); // Call operator()
    }

public:
    template<typename Callable>
    function(Callable&& c)
    {
        Callable* typed = new Callable(std::move(c));
        callable = reinterpret_cast<void*>(typed); // Erase type: callable is only well-defined when cast back to Callable*
        invoker_fn = &invoke<Callable>; // Will handle casting back to T* before calling operator()
    }
    
    Ret operator()(Args... args)
    {
        return invoker_fn( std::forward<Args>(args)... );
    }
};

This is also how my reflection library STIX stores its functions while completely erasing both argument type and quantity. First, an outer thunk checks argument quantity and type. Then an inner thunk unrolls the initializer list using an index sequence and calls the function pointer while also handling return types.