3

Is it allowed by the C++ Standard to use storage provided by an object beyond its destruction but before its deallocation?

It is clear that storage can be reused after destroying a regular object. But in the case below, object B provides storage for an object defined as template parameter T, and constructs that object in the provided storage during its own construction.

Further, the object in the provided storage is not destroyed when the B object is destroyed, and the lifetime of the array which is used to provide storage does not end as it has a trivial destructor without effect, which leads to the question if the lifetime of the object of type A ends with B or not.

#include <cstddef>
#include <iostream>
#include <memory>
#include <new>

struct A
{
  int i = 23;
};

template<class T>
struct B
{
  B()
  {
    // construct object T in the provided storage
    new (&c) T;
  }

  // return a reference to the object in the provided storage
  T & t()
  {
    return *std::launder(reinterpret_cast<T *>(&c));
  }

  // reserved storage
  alignas(T) std::byte c[sizeof(T)];
};

int
main()
{
  using Alloc = std::allocator<B<A>>;
  using AllocTraits = std::allocator_traits<Alloc>;
  Alloc alloc;

  // storage duration starts
  B<A> * b = AllocTraits::allocate(alloc, 1u);

  // construction of both B and A
  AllocTraits::construct(alloc, b);

  // Get a reference to A
  A & a = b->t();

  std::cout << "At this point A is alive. a.i = " << a.i << std::endl;
  a.i = 42;

  // object of type B is destroyed, but A is not
  AllocTraits::destroy(alloc, b);

  // Is it undefined behaviour to read from 'a'?
  std::cout << "Is A still alive? a.i = " << a.i << std::endl;

  // A is destroyed
  a.~A();

  // storage duration ends
  AllocTraits::deallocate(alloc, b, 1u);

  return 0;
}

Q: Why would anyone do that?
A: It would allow to implement an intrusive control block for a weak pointer without additional overhead as done in https://github.com/john-plate/pntr.

woodstock
  • 89
  • 4
  • 1
    The _"is allive check"_s are worthless because if the object is not alive the code has Undefined Behaviour which will render the checks meaningless. – Richard Critten Mar 22 '23 at 17:58
  • 2
    Using the storage is fine. *reading* from it is UB though – Mooing Duck Mar 22 '23 at 17:58
  • @MooingDuck: Can you elaborate on the difference between "using" and "reading from"? – Scott Hunter Mar 22 '23 at 18:01
  • Possible duplicate: https://stackoverflow.com/questions/61067402/what-is-the-effect-of-call-to-a-trivial-destructor – chrysante Mar 22 '23 at 18:07
  • @RichardCritten Thank you for the comment. The outputs are not meant to be checks. They are questions. I'm just showing that it is possible to read from the storage and to write to it, without causing compiler warnings or runtime issues. If there is any undefined behaviour is what I am asking, not checking. – woodstock Mar 22 '23 at 18:19
  • @ScottHunter: You can *write* to the storage. It's the same rules as `char a[sizeof(B)]; //uninitialized` – Mooing Duck Mar 22 '23 at 19:42
  • I have just now noticed that the `A` is constructed in the memory and not destroyed, which makes this question *far* more interesting than I'd first thought. – Mooing Duck Mar 22 '23 at 19:47
  • @MooingDuck I agree that it is a very interesting question! :) – woodstock Mar 22 '23 at 20:35

3 Answers3

5

Once you've run the destructor (which is what destroy does), there is no longer an object at the location pointed to by b. There's just storage.

At that point, a is a dangling reference, and using it is UB.

It's not UB to do something with the storage (for example, you could create a new object there with placement new or allocator_traits::construct).

Marshall Clow
  • 15,972
  • 2
  • 29
  • 45
  • 1
    Thank you for the answer. I'm not sure if it is that easy in this case. `a` is a reference to an object of type `A`, which has been constructed in storage provided by `b`, but it has not been destroyed as `A` is not a subobject of `B`. Further, the lifetime of subobject `c` has not ended as it has a trivial destructor. The storage duration for the object of type `A` has also not ended. So, `a` is a reference to an object which has not been destroyed, and the storage duration is not expired, which should mean that `a` is a valid reference to a valid object. Or not? – woodstock Mar 22 '23 at 19:22
1

Since the destructor of owner is called prior to owned subobject, there is a probable UB mine planted. The sequence of instructions in B::B must be negated correctly.std::allocator::destroy may just call B::~B, but there is no guarantee that it doesn't write something as preparation for a reallocation, thus overwriting the none destructed A instance. If a different allocator is used in future revisions, the behavior after destruction changes. I guess you will end up with a time-bomb.

In fact in a cryptographic context, such as SSL, I would customize the destroy method to scramble the deleted object using an optimization fence to force the scrambler.

Red.Wave
  • 2,790
  • 11
  • 17
  • Thank you, good point. In the real implementation the storage is a private member in a base class, so in the destructor of any derived class you would have no access to modify the storage provided for `A`. In my experience scrambling doesn't happen after the destruction, but before the deallocation, which would be no problem for my case. Also, a trivial destructor is defined as a destructor without effect. If the trivial destructor of the storage does anything else than nothing, it would violate the definition. – woodstock Mar 22 '23 at 20:19
  • I liked the question. But resource dependency can be more abstract than that. you should consider exact sequence of events. And now that you're trying to shortcut, you may hit the wall. – Red.Wave Mar 22 '23 at 20:23
1

According to https://en.cppreference.com/w/cpp/language/lifetime

Lifetime of an object is equal to or is nested within the lifetime of its storage, see storage duration.

I think when you destroy b, c is also destroyed, it's lifetime ends. Since c is the storage of a, a's lifetime also ends.

So it should indeed be undefined behaviour to use a after b is destroyed.

deezo
  • 11
  • 2
  • The storage duration is from `allocate` until `deallocate`, so that would confirm that the lifetime did not end. But the lifetime can be "nested within the lifetime of its storage" , which means the actual lifetime can be shorter than the storage duration. The question is in this case, when the lifetime of `c` actually ends. – woodstock Mar 22 '23 at 20:26