Is it UB to keep a pointer to a destroyed object and then use it to access re-created objects, possibly pointing to the first subobject?

Consider

struct full
{
    struct basic
    {
        int a = 1;
    } base;
    int b = 2;
};

void example()
{
    alignas(full) std::byte storage[/* plenty of storage */];
    full * pf = new (storage) full;
    basic * pb = &pf->base;

    new (storage) basic; // supposedly ends ​lifetime of *pf (right?)
    // if doesn't, suppose we did pf->~full(); before this line

    pb->a; // is this legal?

    new (storage) full; // supposedly ends ​lifetime of *pb (right?)
    // if doesn't, suppose we did pb->~basic(); before this line

    pb->a; // is this still legal?
    pf->b; // is this legal?
    pf->base.a; // is this legal?
}

I would like to know if any of the above is legal or not, including understanding whether the destructor call is necessary before each step.

Answer

The way it is written, your code has undefined behavior because both pf and pb stop pointing to an object as soon as it is destroyed (i.e. at the point of new (storage) basic;). In practical terms, the compiler is free to speculate the values that are accessible through these pointers across the new (storage) basic; expression. For example, reading through these pointers could produce values that the compiler speculated based on the previous writes through these pointers, but not necessarily through pointers to the newly constructed object.

The standard has std::launder function to mitigate this. The function effectively acts as a barrier for compiler speculations based on the pointer and the object it points to. Basically, it “erases” any knowledge the compiler might have had about the pointed object, and returns a pointer that was as if obtained anew. The corrected code would look like this:

void example()
{
    alignas(full) std::byte storage[/* plenty of storage */];
    full * pf = new (storage) full;
    basic * pb = &pf->base;

    new (storage) basic;

    pb = std::launder(pb); // prevent speculation about the object pb points to
    pb->a; // ok now

    new (storage) full;

    pf = std::launder(pf); // prevent speculation about pf and pb
    pb = std::launder(pb);

    // These are ok now
    pb->a;
    pf->b;
    pf->base.a;
}