Consider this scenario:
#include <array>
#include <cstddef>
#include <cstdint>
#include <memory>
struct S {
alignas(std::uint64_t) alignas(std::uint32_t)
std::array<std::byte, sizeof(std::uint64_t)> storage =
{ /* some init values */ };
};
int main() {
S s;
// explicitly creating an uint64_t at the storage location
auto pi64 = std::start_lifetime_as<std::uint64_t>(s.storage.data());
// explicitly creating an uint32_t at the storage location
auto pi32 = std::start_lifetime_as<std::uint32_t>(s.storage.data());
}
For instance, is the second std::start_lifetime_as
enough to trigger end of lifetime of the previous std::uint64_t
? I know for sure that new (storage.data()) std::byte [sizeof(std::uint64_t)]
will do so, by explicit reuse of storage. What other operations can trigger such end of lifetime?
For simplicity’s sake, I used trivial types (implicit-lifetime, trivially destructible) but the question applies to all kinds of types.
What operations trigger end of lifetime of objects at a given location?
That's very concisely defined in [basic.life]/1, here quoted in the post-C++23 draft N4950's version:
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
- the storage which the object occupies is released, or is reused by an object that is not nested within o ([intro.object]).
For instance, is the second std::start_lifetime_as enough to trigger end of lifetime of the previous std::uint64_t?
Yes, because std::start_lifetime_as
is defined to implicitly create objects including one object of the specified type ([obj.lifetime]/3) and return a pointer to that specific object ([obj.lifetime]/4).
Creating the new uint32_t
object then ends the lifetime of the previous uint64_t
object overlapping in storage because of the last item in first quote, given that the new object can't be nested within the uint64_t
object.
However, both the uint32_t
and uint64_t
objects can be nested within an array of type std::byte
as it can provide storage for these objects. Consequently the lifetime of the std::array
object or the array object that is a member of it, doesn't end. (This is assuming that std::array<std::byte>
is actually implemented by having a std::byte
array as member. If one wanted to there is potential to argue pedantically about whether or not these semantics are guaranteed.)
But, the individual std::byte
's lifetime has already ended with the first std::start_lifetime_as
call, again by the third item in the list, because the uint64_t
is nested within the std::byte
array, but not the individual elements. That's also why you can't read/write through the std::byte
objects again after the std::start_lifetime_as
.
All of these rules effectively implement the strict aliasing rule in a broader sense. They guarantee that there is always only one scalar object of one type at a memory location that is in its lifetime and can be accessed. The way that reinterpret_cast
and std::launder
are specified and the specific aliasing rule guarantee then that any attempt to access as a different type than currently in its lifetime is UB (with minor exceptions as stated in the specific aliasing rule). The operations that (implicitly) create objects act as optimization barrier for the compiler for the purpose of aliasing.
Any operation that creates objects explicitly or implicitly has this effect. You mentioned a placement-new which explicitly creates a new object. Other operations that can potentially create new objects are functions which are specified to implicitly create objects, such as std::memcpy
/std::memmove
.
Another way to end the lifetime is with a pseudo-destructor call which is specified to destroy the object:
pi64->~uint64_t();
Here the first item in the list in the first quote would apply.
At the end of the scope of s
, the storage of the uint32_t
is also released implicitly, so that the third item would apply again.