Search code examples
c++language-lawyerc++20c++23

Which part of the C++ standard forbids destroying an object twice?


Which parts of a modern C++ standard (C++20 or C++23 are fine) say that it's not okay to destroy an object twice using placement new and an explicit destructor call?

alignas(T) std::byte storage[sizeof(T)];
T* const p = new (storage) T(...);
p->~T();
p->~T();

Are there any conditions this is allowed, e.g. if T has a trivial destructor?


I found this previous answer to a semi-related question that gives a relatively clear answer the the trivial destructor part of the question, but it uses C++17 and to my surprise the wording in C++20 seems to have changed to also forbid this for trivial destructors. This seems like it should be fine for trivial destructors, so I suspect I'm missing something.


Solution

  • I think this is how it breaks down for C++20.

    The first (pseudo)-destructor call ends the lifetime of the object.

    [basic.life]/1 says that the lifetime of an object ends when its destructor is called:

    The lifetime of an object o of type T ends when:

    • if T is a non-class type, the object is destroyed, or
    • if T is a class type, the destructor call starts, or
    • [...]

    There are two cases:

    • For non-class T, I think "object is destroyed" is defined in [expr.call]/5:

      If the postfix-expression names a pseudo-destructor (in which case the postfix-expression is a possibly-parenthesized class member access), the function call destroys the object of scalar type denoted by the object expression of the class member access.

    • For class types T it's straightforward that we're calling the destruct.

    [basic.life]/5 is perhaps even clearer that ~T ends the lifetime of the object in both cases though:

    A program may end the lifetime of any object by [...] explicitly calling a destructor or pseudo-destructor for the object.

    It's UB to call a destructor on an object whose lifetime has ended

    [basic.life]/6 says that it's not okay to call a non-static member function of an object whose lifetime has ended:

    [A]after the lifetime of an object has ended and before the storage which the object occupied is reused or released, any pointer that represents the address of the storage location where the object will be or was located may be used but only in limited ways. [...] Indirection through such a pointer is permitted but the resulting lvalue may only be used in limited ways, as described below. The program has undefined behavior if:

    • [...]
    • the pointer is used to access a non-static data member or call a non-static member function of the object, or
    • [...]

    Calling the destructor a second time is therefore undefined behavior because it involves calling a non-static member function of the object (the destructor).

    …At least if the object has class type. It's not clear this precisely applies to non-class objects, particularly given the distinction about destructors versus pseudo-destructors mentioned above. Is ~int really a member function of int? But HolyBlackCat points out that maybe this is covered instead by [expr.call]/5 again: it says that a pseudo-destructor call ends the lifetime of an object, but if the object's lifetime has already ended are the preconditions really met?

    This is more restrictive than in C++17.

    In C++17, [basic.life]/1 specifically carved out an exception to the first point, saying only that the lifetime of a class type with a non-trivial destructor ended when the destructor call started. Similarly with [basic.life]/5. So it was okay to double destroy trivially destructible types in C++17.

    This seems to have been an intentional change: CWG issue 2256 says that this was changed to make the lifetime model more consistent. It's short on details about the large context of why this is desirable, and I'm not sure I get it because you can still do weird lifetime things with trivial types like reuse their storage without destroying them as far as I can tell.