Implementation of std::start_lifetime_as()
Asked Answered
S

2

11

After P0593R6 ('Implicit creation of objects for low-level object manipulation') was accepted in C++20, C++23 will get std::start_lifetime_as() which 'completes the functionality proposed in [P0593R6]' (cf. P2590R2, P2679R2 and the cppreference C++ 23 feature testing page).

How could a reference implementation of std::start_lifetime_as() look like?

Would something like this be sufficient, or is there more to it?

#include <cstddef>
#include <new>

template<class T>
    T* start_lifetime_as(void* p) noexcept
{
    new (p) std::byte[sizeof(T)];
    return static_cast<T*>(p);
}
Shadshadberry answered 10/6, 2023 at 10:43 Comment(7)
Created T would have undefined value here, you need to recopy previous value of buffer.Sacristan
@Sacristan well, that should be fine as long as T is a 'sufficiently' trivial type and I don't read the uninitialized value but write to the created object first before reading from it. I mean this isn't much different from creating an uninitialized struct on the stack/heap and passing a pointer to it around. In that case any user of that point also has to be careful to not read fields before they are initialized.Shadshadberry
Think about the example process(Stream *stream). previous value might have importance.Sacristan
@Jarod42, ok, the text there describes start_lifetime_as() as preserving an existing object representation over the new lifetime start. So yes, there is more to it and I'm curious whether a C++20 compiler already allows for an efficient implementation of std::start_lifetime_as().Shadshadberry
A simple cast should do the job, implementation side.Sacristan
@Sacristan hm, but wouldn't a simple cast violate strict-aliasing rules?Shadshadberry
For user yes, for compiler, not.Sacristan
P
5

These functions are purely compiler magic: there is no way to implement these effects in C++ (strictly construed), which is why they were added to the library.

Implementations have often "looked the other way", or been unable to prove that undefined behavior was occurring, but it was only a matter of time before they caught on to any given example and "miscompiled" it.

Poinsettia answered 10/6, 2023 at 12:42 Comment(8)
gcc.gnu.org/bugzilla/show_bug.cgi?id=95349 perhaps gives some idea on the required magic.Shadshadberry
std::start_lifetime_as is implementable, and the proposal for it makes a very concrete suggestion for how that can be done in §3.8 Direct object creationBushido
@JanSchultke: That suggestion is illustrative, but not equivalent: std::start_lifetime_as doesn't access the storage at all, which prevents data races in some cases.Poinsettia
@DavisHerring: Of course, an abstraction model where any storage that exists inherently contains all PODS that could fit, but limits how they can be accessed, would match reality far more than the C++ model, and would allow sound implementations to perform more optimizations than is currently possible (note that clang and gcc are presently unsound under the current model, and could not be made sound without foregoing many optimizations that should be allowable and useful).Heterophyte
@supercat: It sounds like you have an interesting model to propose, but I can’t tell what it is just from that comment (what kinds if limits on what kinds of access?).Poinsettia
@DavisHerring: Many abstraction models would be compatible with the same basic idea, which is that a compiler would only need to treat accesses with different lvalues as sequenced if one lvalue was derived from the other, or both were freshly visibly derived from a common base. In the cases where a compiler would be interested in reordering accesses, it should be able to see what happens between them, and if a compiler isn't going to look at what happens between accesses, it shouldn't be reordering them.Heterophyte
@DavisHerring: If one were willing to add a new construct to the language, and accept the idea that code where speed is important should be expected to use it when practical, adding a mem_view<T> type whose constructor takes a void* and which acts like a T*, with the semantics that actions using lvalues whose address is definitely linearly derived from a mem_view<T> instance p would be sequenced relative to the creation and destruction of p, and to actions using lvalues whose address is at least potentially derived from p, but unsequenced relative to anything else...Heterophyte
...would eliminate much of the need for other rules related to TBAA and PODS lifetime. Of particular significance is that once the lifetime of a mem_view<T> ends, the storage could be accessed in whatever ways it could be accessed before that lifetime began.Heterophyte
B
13

std::start_lifetime_as cannot be implemented fully by hand, because it has the special property that it doesn't access the storage. Any implementation we can provide ourselves will have to access the storage in theory, even if this can be optimized out in practice.

However, disregarding this detail, we can implement it as follows:

C++20 - Implementation in terms of std::memmove

Since C++20, std::memmove and std::memcpy are "magic" in the sense that they implicitly begin the lifetimes of objects at the destination. std::memmove can have the same source and destination, so we can hijack its magic properties and implement std::start_lifetime_as easily:

template<class T>
requires (std::is_trivially_copyable_v<T> && std::is_implicit_lifetime_v<T>)
T* start_lifetime_as(void* p) noexcept
{
    return std::launder(static_cast<T*>(std::memmove(p, p, sizeof(T))));
}

The reason why this works is that:

Both functions [std::memcpy and std::memmove] implicitly create objects in the destination region of storage immediately prior to copying the sequence of characters to the destination.

- [cstring.syn] p3

std::memmove turns the memory region [p, p + sizeof(T)) into one where an object is implicitly created. You may ask what the type of that object is:

For each operation that is specified as implicitly creating objects, that operation implicitly creates and starts the lifetime of zero or more objects of implicit-lifetime types in its specified region of storage if doing so would result in the program having defined behavior.

- [intro.object] p10

Without std::launder, it may be possible that objects of a type other than T are created in this memory region. However, std::launder has the precondition that there must be a T at p (see [ptr.launder] p2), so the compiler must create T there to satisfy the former paragraph.

C++17 - Implementation in terms of placement new

std::start_lifetime_as is implementable even without std::memmove being "magic", and P0593R6 explains exactly how to do it. The explanation in that paper predates std::memmove being given magic properties, which is why it suggests a more complicated implementation:

If the destination type is a trivially-copyable implicit-lifetime type, this can be accomplished by copying the storage elsewhere, using placement new of an array of byte-like type, and copying the storage back to its original location, then using std::launder to acquire a pointer to the newly-created object.

- §3.8 Direct object creation

template<class T>
requires (std::is_trivially_copyable_v<T> && std::is_implicit_lifetime_v<T>)
T* start_lifetime_as(void* p) noexcept
{
    // 1. Copy the storage elsewhere.
    std::byte backup[sizeof(T)];
    std::memcpy(backup, p, sizeof(T));
    
    // 2. Use placement new of an array of byte-like type
    //    according to [intro.objec] p13, this implicitly begins the lifetime
    //    of an object within the byte storage.
    //    However, it also turns the memory at p indeterminate.
    new (p) std::byte[sizeof(T)];

    // 3. Copy the storage back to its original location.
    //    This turns the object representation determinate again,
    //    while keeping the implicit object creation at p.
    std::memcpy(p, backup, sizeof(T));

    // 4. Return a laundered pointer.
    //    Because T being at the address that p represents is a
    //    precondition of std::launder, this forces the implicit
    //    object created via placement new to be of type T.
    return std::launder(static_cast<T*>(p));
}

Note 1: Your implementation has the problem that the memory turns indeterminate when you do placement new of a std::byte[], so the object representation won't be preserved.

Note 2: The C++17 version isn't complete, because it doesn't allow for starting the lifetime of volatile types.

Bushido answered 29/7, 2023 at 15:59 Comment(5)
According to this answer https://mcmap.net/q/23293/-quot-constructing-quot-a-trivially-copyable-object-with-memcpy , memcpy and memmove implicitly creating objects is a change that came from a defect report, and therefore that can be considered to be applied retroactively to older standards, back to C++98. Is that correct, and if so wouldn't the C++20 example above actually apply to any C++ version? And a follow-up question would be whether any of the techniques above really matter for any real compiler. It seems the defect report in question is only attempting to put legalese around something everybody was assuming to be ok already.Preemie
I assume that none of the proposed implementations are possible on a const pointer without UB?Amadeo
@RyanMcCampbell not in all cases but you're pretty much right. See eel.is/c++draft/basic.life for restrictions on creating new objects in the storage occupied by const objects. I believe it would be valid to start the lifetime of a new object in the storage of a const object with automatic storage duration. This wouldn't transparently replace it, but you could access it by laundering or using the result of start_lifetime_as.Bushido
@JanSchultke do you mean that the memcpy/memmove versions are valid on automatic-storage objects even if e.g. it is backed by a local const char[]? And inversely, would it be invalid for a static const array? And if so, would the compiler magic implementation still be valid due to the stipulation that "the storage is not actually accessed"? (I was assuming the latter due to the fact that there is a const overload)Amadeo
@RyanMcCampbell yes, yes, and the implementation wouldn't be valid in a standard sense; it might just work in a "works on my machine" sense.Bushido
P
5

These functions are purely compiler magic: there is no way to implement these effects in C++ (strictly construed), which is why they were added to the library.

Implementations have often "looked the other way", or been unable to prove that undefined behavior was occurring, but it was only a matter of time before they caught on to any given example and "miscompiled" it.

Poinsettia answered 10/6, 2023 at 12:42 Comment(8)
gcc.gnu.org/bugzilla/show_bug.cgi?id=95349 perhaps gives some idea on the required magic.Shadshadberry
std::start_lifetime_as is implementable, and the proposal for it makes a very concrete suggestion for how that can be done in §3.8 Direct object creationBushido
@JanSchultke: That suggestion is illustrative, but not equivalent: std::start_lifetime_as doesn't access the storage at all, which prevents data races in some cases.Poinsettia
@DavisHerring: Of course, an abstraction model where any storage that exists inherently contains all PODS that could fit, but limits how they can be accessed, would match reality far more than the C++ model, and would allow sound implementations to perform more optimizations than is currently possible (note that clang and gcc are presently unsound under the current model, and could not be made sound without foregoing many optimizations that should be allowable and useful).Heterophyte
@supercat: It sounds like you have an interesting model to propose, but I can’t tell what it is just from that comment (what kinds if limits on what kinds of access?).Poinsettia
@DavisHerring: Many abstraction models would be compatible with the same basic idea, which is that a compiler would only need to treat accesses with different lvalues as sequenced if one lvalue was derived from the other, or both were freshly visibly derived from a common base. In the cases where a compiler would be interested in reordering accesses, it should be able to see what happens between them, and if a compiler isn't going to look at what happens between accesses, it shouldn't be reordering them.Heterophyte
@DavisHerring: If one were willing to add a new construct to the language, and accept the idea that code where speed is important should be expected to use it when practical, adding a mem_view<T> type whose constructor takes a void* and which acts like a T*, with the semantics that actions using lvalues whose address is definitely linearly derived from a mem_view<T> instance p would be sequenced relative to the creation and destruction of p, and to actions using lvalues whose address is at least potentially derived from p, but unsequenced relative to anything else...Heterophyte
...would eliminate much of the need for other rules related to TBAA and PODS lifetime. Of particular significance is that once the lifetime of a mem_view<T> ends, the storage could be accessed in whatever ways it could be accessed before that lifetime began.Heterophyte

© 2022 - 2024 — McMap. All rights reserved.