Search code examples
c++c++17undefined-behaviorreinterpret-caststdlaunder

std::launder with inplace polymorphic containers


I am doing a somewhat nontrivial project in C++ for the Game Boy Advance, and, being such a limited platform with no memory management at all, I am trying to avoid calls to malloc and dynamic allocation. For this, I have implemented a fair amount of, what a call, "inplace polymorphic containers", that store an object of a type derived from a Base class (parametrized in the type template), and then I have functions that new the object and use perfect forwarding to call the appropriate constructor. One of those containers, as example, is shown below (and is also accessible here):

//--------------------------------------------------------------------------------
// PointerInterfaceContainer.hpp
//--------------------------------------------------------------------------------
// Provides a class that can effectively allocate objects derived from a
// base class and expose them as pointers from that base
//--------------------------------------------------------------------------------
#pragma once

#include <cstdint>
#include <cstddef>
#include <algorithm>
#include "type_traits.hpp"

template <typename Base, std::size_t Size>
class alignas(max_align_t) PointerInterfaceContainer
{
    static_assert(std::is_default_constructible_v<Base>,
        "PointerInterfaceContainer will not work without a Base that is default constructible!");
    static_assert(std::has_virtual_destructor_v<Base>,
        "PointerInterfaceContainer will not work properly without virtual destructors!");
    static_assert(sizeof(Base) >= sizeof(std::intptr_t),
        "PointerInterfaceContainer must not be smaller than a pointer");

    std::byte storage[Size];

public:
    PointerInterfaceContainer() { new (storage) Base(); }

    template <typename Derived, typename... Ts>
    void assign(Ts&&... ts)
    {
        static_assert(std::is_base_of_v<Base, Derived>,
            "The Derived class must be derived from Base!");
        static_assert(sizeof(Derived) <= Size,
            "The Derived class is too big to fit in that PointerInterfaceContainer");
        static_assert(!is_virtual_base_of_v<Base, Derived>,
            "PointerInterfaceContainer does not work properly with virtual base classes!");

        reinterpret_cast<Base*>(storage)->~Base();
        new (storage) Derived(std::forward<Ts>(ts)...);
    }

    void clear() { assign<Base>(); }

    PointerInterfaceContainer(const PointerInterfaceContainer&) = delete;
    PointerInterfaceContainer(PointerInterfaceContainer&&) = delete;
    PointerInterfaceContainer &operator=(PointerInterfaceContainer) = delete;

    Base* operator->() { return reinterpret_cast<Base*>(storage); }
    const Base* operator->() const { return reinterpret_cast<const Base*>(storage); }

    Base& operator*() { return *reinterpret_cast<Base*>(storage); }
    const Base& operator*() const { return *reinterpret_cast<const Base*>(storage); }

    ~PointerInterfaceContainer()
    {
        reinterpret_cast<Base*>(storage)->~Base();
    }
};

After reading some articles about std::launder, I am still in doubt, but I guess those lines of code might cause a problem:

Base* operator->() { return reinterpret_cast<Base*>(storage); }
const Base* operator->() const { return reinterpret_cast<const Base*>(storage); }

Base& operator*() { return *reinterpret_cast<Base*>(storage); }
const Base& operator*() const { return *reinterpret_cast<const Base*>(storage); }

Especially if the Deriveds in question (or the Base itself) have const members or references. What I am asking is about a general guideline, not only for this (and the other) container, about the use of std::launder. What do you think here?


So, one of the proposed solutions is to add a pointer that would receive the contents of new (storage) Derived(std::forward<Ts>(ts)...);, like shown:

//--------------------------------------------------------------------------------
// PointerInterfaceContainer.hpp
//--------------------------------------------------------------------------------
// Provides a class that can effectively allocate objects derived from a
// base class and expose them as pointers from that base
//--------------------------------------------------------------------------------
#pragma once

#include <cstdint>
#include <cstddef>
#include <algorithm>
#include <utility>
#include "type_traits.hpp"

template <typename Base, std::size_t Size>
class alignas(max_align_t) PointerInterfaceContainer
{
    static_assert(std::is_default_constructible_v<Base>,
        "PointerInterfaceContainer will not work without a Base that is default constructible!");
    static_assert(std::has_virtual_destructor_v<Base>,
        "PointerInterfaceContainer will not work properly without virtual destructors!");
    static_assert(sizeof(Base) >= sizeof(std::intptr_t),
        "PointerInterfaceContainer must not be smaller than a pointer");

    // This pointer will, in 100% of the cases, point to storage
    // because the codebase won't have any Derived from which Base
    // isn't the primary base class, but it needs to be there because
    // casting storage to Base* is undefined behavior
    Base *curObject;
    std::byte storage[Size];

public:
    PointerInterfaceContainer() { curObject = new (storage) Base(); }

    template <typename Derived, typename... Ts>
    void assign(Ts&&... ts)
    {
        static_assert(std::is_base_of_v<Base, Derived>,
            "The Derived class must be derived from Base!");
        static_assert(sizeof(Derived) <= Size,
            "The Derived class is too big to fit in that PointerInterfaceContainer");
        static_assert(!is_virtual_base_of_v<Base, Derived>,
            "PointerInterfaceContainer does not work properly with virtual base classes!");

        curObject->~Base();
        curObject = new (storage) Derived(std::forward<Ts>(ts)...);
    }

    void clear() { assign<Base>(); }

    PointerInterfaceContainer(const PointerInterfaceContainer&) = delete;
    PointerInterfaceContainer(PointerInterfaceContainer&&) = delete;
    PointerInterfaceContainer &operator=(PointerInterfaceContainer) = delete;

    Base* operator->() { return curObject; }
    const Base* operator->() const { return curObject; }

    Base& operator*() { return *curObject; }
    const Base& operator*() const { return *curObject; }

    ~PointerInterfaceContainer()
    {
        curObject->~Base();
    }
};

But that would mean essentially an overhead of sizeof(void*) bytes (in the architecture in question, 4) for each PointerInterfaceContainer present in the code. That seems not to be a lot, but if I want to cram, say, 1024 containers, each having 128 bytes, this overhead can add up. Plus, it would require a second memory access to access the pointer and, given that, in 99% of the cases, Derived will have Base as a primary base class (that means static_cast<Derved*>(curObject) and curObject are the same location), this would mean that pointer would always point to storage, meaning all that overhead is completely unnecessary.


Solution

  • The std::byte object that storage in

    reinterpret_cast<Base*>(storage)
    

    will point to after array-to-pointer decay, is not pointer-interconvertible with any Base object located at that address. This is never the case between an element of an array providing storage and the object it provides storage for.

    Pointer-interconvertibility basically only applies if you are casting pointers between standard-layout classes and their members/bases (and only in special cases). These are the only cases where std::launder is not required.

    So in general, for your use case where you try to obtain a pointer to an object from the array which provides the storage for the object, you always need to apply std::launder after reinterpret_cast.

    Therefore you must always use std::launder in all cases in which you are using reinterpret_cast at the moment. E.g.:

    reinterpret_cast<Base*>(storage)->~Base();
    

    should be

    std::launder(reinterpret_cast<Base*>(storage))->~Base();
    

    Note however that from a C++ standard's perspective what you are trying to do still isn't guaranteed to work and there is no standard way of enforcing it to work.

    Your class Base is required to have a virtual destructor. That means Base and all classes deriving from it are not standard-layout. A class that is not standard-layout has practically no guarantees on its layout. That means that you have no guarantee that the address of the Derived object is equal to the address of the Base subobject, no matter how you let Derived inherit from Base.

    If the addresses don't match up, std::launder will have undefined behavior because there won't be a Base object at that address after you did new(storage) Derived.

    So you need to rely on the ABI specification to make sure that the address of the Base subobject will equal the address of the Derived object.