c++compiler-optimizationundefined-behaviorc++23stdlaunder

Does C++23 guarantee std::launder can be omitted in placement new scenerios?


#include <iostream>
#include <new>

struct A {
    int const n;

    void f() {
        new (this) A{2};
    }

    void g() {
        std::cout << this->n;
    }

    void h() {
        std::cout << std::launder(this)->n;
    }
};

int main() {
    auto a = A{1};
    a.f();
    std::cout << std::launder(&a)->n; // This is guaranteed to print 2.
    a.h();                            // This is guaranteed to print 2.   
    a.g();                            // Is it guaranteed to print 2?
    std::cout << a.n;                 // Is it guaranteed to print 2?

    new (&a) A{3};
    std::cout << std::launder(&a)->n; // This is guaranteed to print 3.
    a.h();                            // This is guaranteed to print 3.
    a.g();                            // Is it guaranteed to print 3?
    std::cout << a.n;                 // Is it guaranteed to print 3?
}

Consider the following excerpt from another answer:

The compiler is allowed to assume that a truly const variable (not merely a const&, but an object variable declared const) will never change.

std::cout << std::launder(&a)->n; is guaranteed to behave as expected, though, it is too tedious and too ugly, especially for this pointers in the member function body.

Does C++23 guarantee std::cout << a.n; and std::cout << this->n; behave as expected even if without std::launder?


Solution

  • All of these cases are well-defined (even without std::launder) in this case because an object of type A is transparently replaceable by another object of type A.

    This is covered by:

    [basic.life (8)]
    (8) If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if the original object is transparently replaceable (see below) by the new object.
    An object o1 is transparently replaceable by an object o2 if:
    (8.1) the storage that o2 occupies exactly overlays the storage that o1 occupied, and
    (8.2) o1 and o2 are of the same type (ignoring the top-level cv-qualifiers), and
    (8.3) o1 is not a const, complete object, and
    (8.4) neither o1 nor o2 is a potentially-overlapping subobject ([intro.object]), and
    (8.5) either o1 and o2 are both complete objects, or o1 and o2 are direct subobjects of objects p1 and p2, respectively, and p1 is transparently replaceable by p2.

    In this case:

    1. the storage that o2 occupies exactly overlays the storage that o1 occupied
      => ✅ sizeof(A) == sizeof(A), so all bytes are overlayed
    2. o1 and o2 are of the same type (ignoring the top-level cv-qualifiers)
      => ✅ A and A are the same type
    3. o1 is not a const, complete object
      => ✅ auto a = A{1}; a is not const in this example
    4. neither o1 nor o2 is a potentially-overlapping subobject
      => ✅ A is neither a baseclass of A nor a no_unique_address member of A ([intro.object (7)])
    5. either o1 and o2 are both complete objects, or o1 and o2 are direct subobjects of objects p1 and p2, respectively, and p1 is transparently replaceable by p2.
      => ✅ both o1 and o2 are complete objects.

    => A is transparently replaceable by A, so any pointer that referred to the old A will automatically point to the new A after placement new, without requiring a std::launder.


    The compiler is allowed to assume that a truly const variable (not merely a const&, but an object variable declared const) will never change.

    That is generally true, but only for complete objects that are const and which are not allocated on the heap (have dynamic storage duration).
    (All objects that are not subobjects of other objects are called complete objects)

    This is covered by [basic.life] (10):

    (10) Creating a new object within the storage that a const complete object with static, thread, or automatic storage duration occupies, or within the storage that such a const object used to occupy before its lifetime ended, results in undefined behavior.

    Example:

    struct A { const int i; };
    
    // foo is a complete object, but not const. (storage can be reused)
    // foo.i is const, but not a complete object. (storage can be reused)
    A foo {1};
    
    // bar is a const complete object. (storage cannot be reused)
    // bar.i is const, but not a complete object.
    //   (but it is part of a const complete object => storage cannot be reused)
    const A bar {1};
    
    // ptrFoo points to a const complete object.
    // (but its storage could be reused because it has dynamic storage duration)
    A* ptrFoo = new const A{1};
    

    A more interesting case would be if you're creating a new object in the storage of a that is not transparently replaceable.

    e.g.:

    struct A {
        int const n;
    
        void f();
        void g() { std::cout << this->n; }
        void h() { std::cout << std::launder(this)->n; }
    };
    
    struct B : A {};
    
    void A::f() {
      new (this) B{2};
      // using this here (calling a member method, 
      // accessing a data member, etc...) would be UB
    }
    
    static_assert(sizeof(B) == sizeof(A));
    
    int main() {
        auto a = A{1};
        a.f();
        std::cout << std::launder(&a)->n;   // OK
        a.h();                              // UB   
        a.g();                              // UB
        std::cout << a.n;                   // UB
        std::cout << std::launder(&a)->h(); // OK
    
        new (&a) B{3};
        std::cout << std::launder(&a)->n;   // OK
        a.h();                              // UB   
        a.g();                              // UB
        std::cout << a.n;                   // UB
        std::cout << std::launder(&a)->h(); // OK
    }
    

    Note that in this case calling a.h() and a.g() is already UB - you can't use a at all to access the object (that includes invoking member methods on it) unless you launder it first.

    This is covered by:

    [basic.life] (7)
    (7) Similarly, before the lifetime of an object has started but after the storage which the object will occupy has been allocated or, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, any glvalue that refers to the original object may be used but only in limited ways. For an object under construction or destruction. Otherwise, such a glvalue refers to allocated storage, and using the properties of the glvalue that do not depend on its value is well-defined. The program has undefined behavior if:
    (7.1) the glvalue is used to access the object, or
    (7.2) the glvalue is used to call a non-static member function of the object, or
    (7.3) the glvalue is bound to a reference to a virtual base class, or
    (7.4) the glvalue is used as the operand of a dynamic_cast or as the operand of typeid.