c++language-lawyerundefined-behaviorreinterpret-cast

Placement new + reinterpret_cast + pointer arithmetic = UB?


Similar to the example from the std::aligned_storage page here, imagine I have the following:

template <typename T, std::size_t N>
class SlotArray {
  static_assert(!std::is_array_v<T>);
  static_assert(!std::is_void_v<T>);
  using StorageType = std::aligned_storage_t<sizeof(T), alignof(T)>;

 public:
  template<typename ...Args>
  T* alloc(Args... args) {
    std::size_t index = claimIndex();
    return ::new(&slots[index]) T(std::forward<Args>(args)...);
  }

  void free(T* value) {
    static_assert(sizeof(std::ptrdiff_t) <= sizeof(std::size_t));
    StorageType* storagePtr = reinterpret_cast<StorageType*>(value);
    free(storagePtr - slots);
  }

  void free(std::size_t index) {
    std::destroy_at(std::launder(reinterpret_cast<T*>(&slots[index])));
    freeIndex(index);
  }
 private:
  std::size_t claimIndex() {
    // Get a unique index, doesn't matter here how (say, bitset + throw if empty)
    ...
  }
  void freeIndex(std::size_t index) {
    ...
  }

  class alignas(T) Storage {
    std::byte buffer[sizeof(T)];
  };

  StorageType slots[N];
};

Is there any UB here?

The alloc member function is standard placement-new and the free member function overload with an index parameter is textbook std::launder stuff. Pointer arithmetic is well defined. The part that's harder to verify is the reinterpret_cast to the underlying storage type. Outside of some corner cases with arrays, there's a lot of indication that the pointer type from placement new should be the same address. Furthermore, the result of the cast clearly has the same alignment and actually points to something in the array.

Certainly, this all assumes the user doesn't pass some random T* but only pointers returned from alloc.


Solution

  • As pointed out in a comment by @AhmedAEK, std::aligned_storage is deprecated and quite possibly causes UB (see P1413R3). In addition, the comment contained a link to the "An (In-)Complete Guide To C++ Object Lifetimes" presentation by Jonathan Müller, which led me to a longer version here. It was very helpful in understanding the relevant parts of the standard that apply to this question.

    First, let's get rid of the use of std::aligned_storage. For the replacement, P1413R3 suggests using a properly aligned byte array. This is explicitly defined behavior, the details of which are covered in [intro.object] (it even has an example of using placement-new in the exact manner that follows).

    // A slot contains a buffer that can "provide storage" for the created
    // objects. It's alignment and size exactly match that of T, which ensures
    // "the storage for the new object fits entirely within" it.
    struct Slot {
        alignas(T) std::byte storage[sizeof(T)];
    }; 
    

    With that, alloc becomes:

    template<typename ...Args>
    T* alloc(Args... args) {
      std::size_t index = claimIndex();
      return ::new(static_cast<void*>(&slots[index].storage)) T(std::forward<Args>(args)...);
    }
    

    The void* cast is used to ensure any overloads of placement-new are avoided.

    Next, the free overload with the size_t parameter becomes:

    void free(std::size_t index) {
      std::byte* storagePtr = slots[index].storage;
      T* valuePtr = std::launder(reinterpret_cast<T*>(storagePtr));
      std::destroy_at(valuePtr);
      freeIndex(index);
    }
    

    Note that the use of std::launder is needed, at least as of C++23. For details, see P3006R0.

    Finally, we arrive at the core of the question. The answer lies in the notion of pointer-interconvertibility, the details of which are covered in [basic.compound]. A pointer to the object and the pointer to the storage aren't (yet) pointer-interconvertible but can be safely converted from one to the other as indicated in P3006R0. However, a pointer to the storage member of a Slot is pointer-interconvertible with a pointer to the containing Slot because "one is a standard-layout class object and the other is the first non-static data member of that object".

    void free(T* value) {
      std::byte* storagePtr = std::launder(reinterpret_cast<std::byte*>(value));
      Slot* slotPtr = reinterpret_cast<Slot*>(storagePtr);
    
      static_assert(sizeof(std::ptrdiff_t) <= sizeof(std::size_t));
      std::size_t index = static_cast<std::size_t>(slotPtr - slots);
      free(index);
    }