C++ object lifetimes iceberg


You need this article if you don't know enough to answer these questions:


The motivation for writing such long articles can be rather peculiar. I, for example, was sitting and writing a completely different article: How to write your own std::function. And at some point I caught myself realizing that I wasn't completely sure I could honestly defend some parts of the code in front of the reader.

And sure, everything worked, and the compiler was giving me a working solution, but I was haunted by a feeling that has visited me more than once while writing C++ code: "I don't know whether my C++ is canonical." And internally, you kind of understand that everything is fine, but in reality, you've never even looked into the standard yourself to know for sure. At some point, I started doubting many of my own knowledge and practices, all the way to paranoia.

What's interesting is that all the questionable places seemed like separate, unrelated cases: am I using reinterpret_cast correctly, will the destructor be called, do I need std::launder, how legal are various low-level memory tricks? Only after I started making some tentative attempts to figure things out did I realize: all of this revolves around object lifetime - a vast topic that hardly anyone can explain properly - and if I cover this area thoroughly, I'll get the key to C++.

Getting that key is difficult - hence the difficult article. But you can do it.

C

For a warm-up, let's start with the C language. In C, creating an object of type T means allocating enough memory through malloc and then interpreting it as a pointer to T. That's it - go ahead and use the object. Destruction works the same way: call free, and the object is gone. That's what we're talking about on the heap. With the stack, you don't even need these acrobatics.

The main idea I want to establish here is that, from the language's point of view, an object is ready for immediate use as soon as enough bytes have been allocated for it.

struct Point { int x; int y; };

struct Point* p = malloc(sizeof(*p));

// that's it - we can use it!
p->x = 10;

malloc returns a void - just raw bytes. You cast them to Point yourself and immediately start using the object. And everything will be fine as long as you've allocated enough memory for the object.

Of course, your objects can have logic that makes it impossible to use the object without some prior manual initialization. For example, to establish the necessary invariants. But that's no longer a story about the language - it's about the business logic of your classes.

C++: new and delete

Now C++. Here, objects are created in two phases: first, memory is allocated for the object, then the object's constructor is called. Destruction works similarly, but in reverse order: first the destructor is called, then the memory is released. The standard doesn't have clear terminology for naming the two-phase combinations as a whole, so I'll introduce my own terminology, which will be used throughout the article:

Object creation = memory allocation + object construction

Object deletion = object destruction + memory deallocation

In English:

Object creation = memory allocation + object construction

Object deletion = object destruction + memory deallocation

I consider this terminology useful because it immediately makes it clear at which point in time the constructor and destructor do their work.

The familiar new and delete are actually called new expression and delete expression - this is important because there are other new and delete operations, and they perform both phases of creation/destruction at once. In other words, they handle the system memory allocator for you and call the constructor/destructor themselves.

We use a new expression to create an object:

We use a delete expression to delete an object:

Since these are the most common ways to create an object on the heap, and they neatly abstract away the aforementioned "two-phase" process, many programmers, myself included, can go for years without realizing that this two-phase nature even exists. And yet it does, and it starts becoming explicit wherever allocators, in-place storage optimizations, std::shared_ptr control blocks, and so on live. This is where an unprepared programmer's mental model starts falling apart, leading to the classic "I've been learning C++ forever, and it's still bottomless."

Manual object lifetime management

If you want to perform both phases of object creation yourself, you:

In the diagram above, the memory was allocated through operator new - note that this is not a new expression - it's a different beast, related to malloc: it returns raw bytes. But you can also allocate memory for an object in other ways: use your own allocator, take a chunk of space from static storage, or even from the stack, as is done in SSO optimizations.

Now, how do we construct an object in memory that was allocated earlier? Essentially, we want to call the object's constructor, but do so at a specific, predetermined address. We can't do that with something simple like Point p(10, 15); - we need specialized tools here. As far as I can tell, before C++20 the only way to do this was and remains placement new. Starting with C++20, you can and should use std::construct_at - this function shields you from the frankly ugly syntax of placement new, and also expresses the intent more explicitly; that is, std::construct_at is more convenient both for the person writing the code and for the person reading it. The function doesn't do anything special - it simply sweeps the placement new call under the rug.


For two-phase object destruction, you:

The object destruction phase. This is that rare case where the destructor is called manually. Starting with C++17, you can do the same thing through the std::destroy_at function, which forms a symmetrical pair with std::construct_at and has the same advantages. Under the hood, it's still just a manual destructor call.

And now we've reached the point where the memory is released. This is the reverse of allocation - your options include operator delete or, for example, returning the memory to your allocator. If the memory wasn't allocated from the heap, you probably don't need to do anything at all - that memory will take care of itself. The important point is that, in the overwhelming majority of cases, deallocation must happen through the same source from which the allocation came. In other words, if you obtained memory through ::operator new, return it through ::operator delete; if through malloc, return it through free; if you got it from your own allocator, return it to that same allocator.

It's worth noting that there are cases where releasing memory isn't completely symmetrical with obtaining it. For example, this article about allocators describes a very simple and efficient Linear allocator that hands out memory on request but cannot free the memory of an individual object - it can only release all of its memory at once through a separate reset method. For example, throughout a game frame, we can use it to allocate memory for objects and then release the entire pool at the end of the frame. But this is, of course, an uncommon approach - normally, deallocation is symmetrical with allocation.

How C++ objects differ from C objects

Let's look at object creation and destruction using an ordinary C++ class as an example:

struct Foo
{
	const char* name;

	Foo(const char* name_) : name(name_) {
		std::cout << name << ": start\n";
	}

	~Foo() {
		std::cout << name << ": die\n";
	}
};

How does Foo differ from any C structure? In the context of our discussion, the fact that an object of this type:

In other words, we can no longer get away with the C approach, where allocating memory was enough for the object to be implicitly created:

Foo* t = static_cast<Foo*>(malloc(sizeof(Foo))); // 1
t->name = "foo"; // 2
free(t);         // 3

We can't do this, at least because Foo contains logic in its constructor and destructor. In the case of Foo, that's its only logic, since this is a classic example of an RAII class. The explicit stages of object construction and destruction are mandatory. Our test structure merely logs the stages of its lifetime to a stream, but classes can rely on much more serious logic in their constructors and destructors, and without that logic being executed, the program may simply stop functioning correctly:

struct MutexGuard
{
    std::mutex& m;

    MutexGuard(std::mutex& m_) : m(m_) {
        m.lock();
    }

    ~MutexGuard() {
        m.unlock();
    }
};

struct Timer
{
    using Clock = std::chrono::steady_clock;

    std::ostream& s;
    Clock::time_point start;

    Timer(std::ostream& s_) : s(s_) {
        start = Clock::now();
    }

    ~Timer() {
        s << Clock::now() - start;
    }
};

Let's look at the correct ways to handle the lifetime of Foo foo. The simplest and most common scenario is creating an object on the stack:

{
Foo foo("foo"); // 1
...
}               // 2

It's impossible to make a mistake here and shoot yourself in the foot. Both the constructor and destructor will be called, so the class will be created and destroyed correctly. And notice that you will be forced to pass all the required parameters to the constructor, because the class has no default constructor, and attempting to write Foo foo; will result in a syntax error.

Now let's allocate on the heap using the standard new and delete:

Foo* foo = new Foo("foo"); // 1

delete foo;                // 2

This is exactly the same as what happens on the stack, except that the memory is now allocated on the heap, and object deletion is triggered explicitly through delete rather than automatically when leaving the scope.

Now let's try allocating memory and constructing the object ourselves. For example, imagine that new/delete expression doesn't suit us because we specifically want to allocate memory through malloc/free:

Foo* t = static_cast<Foo*>(malloc(sizeof(Foo))); // 1
new (t) Foo("foo"); // 2

t->~Foo();          // 3
free(t);            // 4

This is an improved version of the "C-style" example, but one that works correctly in C++.

Память vs объект

Now I want to show you some examples that will make it clear that it may be completely unclear what is supposed to happen if you don't know for sure how things work. That object lifetime and the memory in which an object lives are very tricky and complex things in C++. It's perfectly fine if you don't see any logic or consistency in what's happening here - I'd even say that's normal.

First example:

Foo* t = static_cast<Foo*>(malloc(sizeof(Foo)));
new (t) Foo("foo"); // ┓
                    // ┃ 1
t->~Foo();          // ┛
free(t);

The object's lifetime is marked as 1. Outside this range, the object doesn't exist - there is only memory allocated for it.


Now let's look at a more interesting example - declare a variable on the stack:

double d = 15.0;

We've already established that this line simultaneously allocates 8 bytes for d and "breathes life" into that memory.

Now let's do this:

double d = 15.0;
new (&d) int(4);

How legal do you think this code is, and what happened? In fact, it's perfectly legal. Let's look at the lifetimes of the double and int objects.

{
double d = 15.0; // ┓
                 // ┃ 1
new (&d) int(4); // ┫
                 // ┃ 2
}                // ┛

In total: two objects live in the same memory at different times.

The example is valid only on platforms where sizeof(double) >= sizeof(int) and alignof(double) % alignof(int) == 0

Notice that the int object will most likely occupy fewer bytes than are available in the memory allocated for the double. But the important thing is that it fits.


Similar example:

using Bytes = std::byte[sizeof(Point)];

...

alignas(Point) Bytes data;
Point* p = new (data) Point;

p->~Point();

In the previous example, we placed an int "inside" a double; now we're placing a Point inside an array of bytes - essentially, a similar operation. Let's look at the object lifetimes:

{
alignas(Point) Bytes data;   //    ┓
Point* p = new (data) Point; // ┓  ┃
                             // ┃1 ┃2
p->~Point();                 // ┛  ┃
                             //    ┃
}                            //    ┛

Now the "wrapper object" interestingly does not end its lifetime after placing the Point object inside it - this differs from what we saw in the previous example, although seemingly, what's the difference?


Another example:

struct Point { int x, y; };
struct Line  { Point p1, p2; };

{
Line l; // ┓
...     // ┃ 1
}       // ┛

An unexpected perspective - even I, when creating the example, didn't expect to count a whole seven objects. At the same time, this example is more or less intuitive to us, because it is natural for an aggregate object and the objects nested inside it to live simultaneously. Just like in the previous example, they share the same memory and live in it at the same time:

0x0  ┓i  ┓P  ┓
     ┃n  ┃o  ┃
     ┛t  ┃i  ┃
0x4  ┓i  ┃n  ┃
     ┃n  ┃t  ┃L
     ┛t  ┛   ┃i
0x8  ┓i  ┓P  ┃n
     ┃n  ┃o  ┃e
     ┛t  ┃i  ┃
0xC  ┓i  ┃n  ┃
     ┃n  ┃t  ┃
     ┛t  ┛   ┛

So, here's what we have:

Now, I think you can clearly see that the topic is not at all simple, and we haven't even really started - we merely took a quick look at the tip of a huge iceberg. What's more, I claim that a good understanding of object lifetimes in C++ is the key to understanding the language. A good chunk of the standard's convoluted rules revolves around object lifetimes. A good handful of UB is scattered around because of object lifetime rules.

And now we're finally going to start figuring this out, little by little...

Before Diving In

To keep the assorted rules of the standard from driving you mad, it is very useful to know the answers to these questions in advance: why is it so complicated, why does it have to be so complicated, and what is all this complexity for?

There are two fundamentally conflicting forces in C++:

It would seem that they have similar goals: generating the most performant machine code possible. But they try to achieve this in different ways.

A compiler wants to interpret behavior outside an object's lifetime as impossible whenever it can - this gives it the right to eliminate "dead" accesses, reuse memory on the stack, and not re-check which object currently resides at a given address. A programmer, on the other hand, intentionally reuses memory for efficiency - placement-new over an old object, union, allocators, buffers - and therefore constantly ends up exactly where the compiler expects UB. Many of these tricks date back to C, and programmers generally see them as perfectly natural.

The standard sacrifices neither side. Instead, it tries to navigate between them. That's why there are so many rules: each exception is a separate compromise between a specific low-level pattern used by the industry and a specific optimization that the compiler isn't willing to give up. In the end, we have what we have.

From here on, I will be quoting the standard extensively. But it's worth understanding that the C++ standard is a living organism and changes all the time. At the time of writing, I relied on the material at eel.is, where the current working draft is published. The paragraphs I quote are current as of September 28, 2026, but their contents and wording will inevitably change over time. In particular, the paragraph numbers themselves will change, so I decided to show you paragraphs like this:

Definition of scalar types

[basic.types.general] p7

Arithmetic types, enumeration types, pointer types, pointer-to-member types, std​::​meta​::​​info, std​::​nullptr_t, and cv-qualified versions of these types are collectively called scalar types. ...

By the way, I didn't choose this example by accident - we'll need the definition of scalar types more than once! So you might as well remember it right away :)

First comes a name I made up for the paragraph, which I will use whenever I need to refer to it again in the text. [basic.types.general] is the so-called stable name, intended to remain more or less unchanged. It's something like a chapter in a book consisting of many paragraphs. p7 is the paragraph number, and that, on the contrary, is an extremely volatile entity. A paragraph is uniquely identified by the combination of stable name + paragraph number: [basic.types.general] p7. I will give this number once, when I first refer to the paragraph, and on subsequent references I will use my own shorthand name. This should make the article easier to maintain over time.

Sometimes I will refer to historical paragraphs from the past, from a specific version of the standard. In that case, I will refer to them like this: C++17 [expr.pseudo] p1. No made-up name.

It may seem strange and disconnected from real life that I am using the bleeding-edge version of the standard as a guide to action and presenting it as the correct reality. After all, many of us are stuck on much earlier versions of the standard. Game development, for example - including me, as a representative of the gamedev community - is universally stuck on C++17 and probably won't move beyond it anytime soon.

There is some rational basis for this, but it's the best I can do for you for several reasons:

Let's begin.

Automatically Created Objects

The standard has a concept called implicit-lifetime types. These are types whose lifetime may begin automatically - without an explicit constructor call or placement new - under certain circumstances. The standard provides a tricky list of types that it considers to be such types. It's tricky because you have to assemble it from different parts of the standard:

Definition of implicit-lifetime types

[class.prop] p8

A class S is an implicit-lifetime class if

- it is an aggregate whose destructor is not user-provided or

- it has at least one trivial eligible constructor and a trivial, non-deleted destructor.

[basic types.general] p7

Scalar types, implicit-lifetime class types, array types, and cv-qualified versions of these types are collectively called implicit-lifetime types.

So, the combined list of implicit-lifetime types is:

Starting with C++23, such types can be identified with the std::is_implicit_lifetime function. The standard has paragraphs describing the contexts in which implicit-lifetime types can be used:

Implicit-lifetime functions

[intro.object] p16, Note 6

Some functions in the C++ standard library implicitly create objects ([obj.lifetime], [c.malloc], [mem.res.public], [bit.cast], [cstring.syn])

[intro.object] p13

... 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.

...

[Note 4: Such operations do not start the lifetimes of subobjects of such objects that are not themselves of implicit-lifetime types. — end note]

The references mentioned here lead to the following functions/methods:

These are precisely those certain circumstances - i.e. it is always a call to some function that has special status in the eyes of the standard.

Let's look at some typical scenarios.

Memory allocation

struct Point { int x; int y; };

Point* p = static_cast<Point*>(malloc(sizeof(*p)));

// that's it - we're good to go!
p->x = 10;

WHOA! This is our C example from the beginning of the article. So it turns out this is actually allowed in C++ too, hooray! And looking at it from the other side, you realize that even to maintain compatibility with C, the C++ standard has to make exceptions to its own rules. After all, in the general case we have to explicitly construct the object! Just not this time. Since Point is an implicit-lifetime type, the call to malloc will trigger the creation of a Point object in the memory pointed to by Point* p.

In fact, pretty much any library function that allocates memory can implicitly create an object.

Deserializing data

You've received raw bytes over the network. You want to reconstruct the object you received.

You cannot do this:

std::byte* b = /*got the raw bytes*/;
Point* obj = reinterpret_cast<Point*>(b);
obj->x = 15; // UB!

There has never been, and is not currently, a Point object in these bytes, in this memory. reinterpret_cast does not create an object there - it has a different purpose - we'll talk about this cast later. Therefore, interpreting the bytes as a Point is UB, and the compiler is entitled to punish you for it at runtime.

Now let's look at the ways we can do it.

C++23 gives us the most logical and reasonable way:

std::byte* b = /*got the raw bytes*/;
Point* obj = std::start_lifetime_as<Point>(b);
obj->x = 15; // OK

We simply breathed life into the memory and got the coveted pointer.

But not everyone can use C++23, so production code that's a little closer to what we have today may contain other techniques.

We can copy the bytes using memcpy:

std::byte* b = /*got the raw bytes*/;
std::byte* obj = /*we have storage for the object*/;

std::memcpy(obj, b, sizeof(Point));
Point* p = reinterpret_cast<Point*>(obj);
p->x = 15; // OK

You might reasonably say: if we somehow allocated memory for obj, doesn't that mean the object could have been implicitly created at that stage already? Not necessarily - the memory doesn't have to be allocated with new or malloc, which can also implicitly create an object. Imagine that, suddenly, it's stack memory - some std::byte[sizeof(Point)], or even static storage. I simply took a pointer to it. In that case, the Point object will be implicitly created specifically at the std::memcpy stage.

There is an even more curious option:

std::byte* b = /*got the raw bytes*/;
std::memmove(b, b, sizeof(Point));
Point* obj = reinterpret_cast<Point*>(b);
obj->x = 15;

How about that? The compiler will probably optimize the self-copy away, but it will still be required to create the Point object.

Note: when memcpy and memmove come up, people usually talk about trivially copyable types, but we'll talk about those too. For now, let's just limit ourselves to the fact that our Point happens to be both an implicit-lifetime type and a trivially copyable type.

The Weirdness of Implicit Object Creation

There is one point worth noting. Look at these examples:

void* raw = std::malloc(8); // 1
Point* p = static_cast<Point*>(raw);

...

std::byte buf[8];
std::memcpy(buf, p, 8); // 2
Point* p2 = reinterpret_cast<Point*>(buf);

The standard did not formulate its rule this way by accident:

... 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

Roughly speaking, the compiler is forced to analyze the surrounding context and retroactively determine the type of the object being created. So the chronology of events can shift around a little.

What type of object do you think will be implicitly created here?:

struct A { int x; };
struct B { int x; };

alignas(A) std::byte storage[sizeof(A)];

std::memcpy(storage, data, sizeof(A));
B* p = reinterpret_cast<B*>(storage);

The answer is: B. Because it doesn't matter how diligently we pretended that the memory was intended for A if, in the end, we changed our minds and decided that it was B at the point of the cast.

Aggregates

It's interesting to see aggregate classes among the implicit-lifetime types. The motivation is fairly obvious - you can materialize entire blocks of data from raw bytes:

struct Header {
    uint32_t magic;
    uint16_t version;
    uint16_t flags;
};

...

std::byte* b = /*got the raw bytes*/;
Header* h = std::start_lifetime_as<Header>(b);

If you read the Implicit-lifetime functions paragraph carefully, you can see that all the subobjects - magic, version, flags - will also be implicitly created by this code: "zero or more objects of implicit-lifetime types in its specified region of storage".

However, there are significant pitfalls here. An aggregate without a user-provided destructor can also be a structure like this:

struct S {
    int i;
    std::string str;
};

And this is where complications arise. The types S and int are implicit-lifetime types according to the Definition of implicit-lifetime types, but std::string is not. What happens in such cases is explicitly stated in the last note of the Implicit-lifetime functions paragraph: "Such operations do not start the lifetimes of subobjects of such objects that are not themselves of implicit-lifetime types.".

So what we get is this - the S object itself can be implicitly created; its nested S::i will also be implicitly created; but S::str will remain an empty shell with no object inside.

std::byte* b = /*got the raw bytes*/;
S* s = std::start_lifetime_as<S>(b);
s->i = 3;       // 1
s->str.clear(); // 2

What do we do? I know only one option:

S* s = std::start_lifetime_as<S>(b);
new (&s->str) std::string;

And now our aggregate is fully populated with living inhabitants.

These Tricks Aren't for Everyone!

Don't forget that all of the techniques above apply only to implicit-lifetime types! And there actually aren't all that many of those. In the general case, this does not work.

Chapter Takeaways

Trivially Copyable Types

We've already seen what memcpy is capable of. As you can see, the mere fact that it is used can breathe life into bytes that had no life in them before.

There is, however, a nuance: to legally use memcpy or std::bit_cast to copy an object, the object must be a trivially copyable type. Such types can be identified with std::is_trivially_copyable.

The standard describes such types as:

Definition of trivially copyable

[class.prop] p1

A trivially copyable class is a class:

- that has at least one eligible copy constructor, move constructor, copy assignment operator, or move assignment operator,

- where each eligible copy constructor, move constructor, copy assignment operator, and move assignment operator is trivial, and

- that has a trivial, non-deleted destructor.

Despite the concise and concentrated description, it's easy to get confused here, so I'll rephrase it in the way that's clearer to me - a trivially copyable type:

Such a type has hit the jackpot of trivial members - this is the strictest trait when it comes to triviality. And because of that, it is the closest thing to "C-like types" in terms of what you can get away with doing to the bit representation of objects of that type. The most important permission these types get is the ability to copy their objects bitwise without violating the integrity of the object. In other words, instead of calling the copy constructor, you can copy the object bitwise.

Note that a trivially copyable type is not the same thing as an implicit-lifetime type from the previous chapter. The former allows you to legally use memcpy to copy an object, while the latter allows you to implicitly create an object in the buffer that memcpy copies into (with the former giving you permission to do so). These properties overlap very heavily, but they are still not identical and don't even have a parent-child relationship, because types can be:

Scalar types and simple structures recursively composed of scalar types are almost always both implicit-lifetime and trivially copyable, which makes these traits easy to confuse.

Chapter Takeaways

Pointer provenance

Now let's approach the question of object lifetimes from a completely different angle. Let's ask ourselves - what is a pointer? Or, what information does it carry?

The first thing we associate with the concept of a pointer is its address. Simply because a pointer is inseparably connected to memory - literally the place where data is stored, and a pointer is our knowledge of that place.

The second thing we associate with a pointer is its type. In the notation T, we have not only the asterisk but also the mention of T. And that T is a direct instruction for how to interpret the data located at that address. An int and a float* pointing to the same address will interpret the data at that address differently. And as the following chapters will show, there are cases where pointers can validly point to the same address using different types, and this "will work" without causing UB.

As far back as C++03, the combination of "address + type" was a complete description of a pointer as an entity. The standard put it like this:

C++03 [basic.compound] p3

if an object of type T is located at address A, a pointer of type T* whose value is address A points to that object.

In C++11 and C++14, these words remained in the standard, but at the same time a new, third factor that indirectly affected pointers began to emerge - the object and its lifetime. In these versions of the standard, however, this concept existed in parallel with the "address + type" combination and did not really claim to directly affect the identity of a pointer.

Then proposal P0137R1 came along and entered the C++17 standard. The paper substantially rewrote the object model of the language. Objects and their lifetimes began to play a key role in pointer semantics - in addition to the address and type, a pointer now has pointer provenance. This is metadata that the compiler keeps track of for each pointer, primarily so that it can use this additional information to make assumptions about the live (or not-so-live) object that lies behind the pointer. Based on this information, the compiler can perform so-called pointer optimizations: make assumptions about whether specific pointers can or cannot alias; track the object a pointer currently points to - this is the aspect that matters most to us. So it turns out that a pointer is now: "address + type + object behind the pointer". You can cast a pointer to a pointer of another type, but the information about the object remains unchanged - you can't escape it.

The most interesting part is that the concept of pointer provenance never actually made it into the standard. The standard describes intricate rules for object lifetimes and for how pointers can and cannot interact with those objects. The combination of these rules, painstakingly assembled piece by piece, outlines the need for a compiler to track pointer provenance and implicitly gives rise to the concept of pointer provenance, which exists in compilers and even frequently appears in proposals for the standard. In other words, even people who are as close to the standard as you can get use the term pointer provenance, because it is a convenient concept encompassing any additional metadata associated with a pointer. I think that at some point it will officially become part of the standard's terminology.

We're talking about pointers here, of course, but it is important to understand that provenance applies not only to pointers, but also to entities that are implicitly pointers at the level where the compiler operates on the addresses of objects in memory. These are:

A pointer can be associated with a live object, in which case dereferencing it gives you trouble-free access to that object. A pointer can exist without a live object behind it, and that by itself is not a violation. However, you can only use such a pointer with serious restrictions: you can perform pointer arithmetic on it, cast it to whatever you want, copy it, pass it around, take its address; but as soon as you try to access the object (which doesn't exist), you enter forbidden territory.

And once you understand this, the problem with the bit tricks beloved by low-level programming enthusiasts immediately becomes apparent:

float f = 529.4;                    // 1
int* p = reinterpret_cast<int*>(&f);// 2
int bytes = *p;                     // 3

And yes, all these tricks have been practiced for decades, and compilers allow them in most cases. But they have every right to introduce a subtle, hard-to-find bug into such a program at any moment, because at some particular point the compiler may find a nice optimization opportunity precisely where your hack happens to be. That's how it goes.


Here's an interesting thing that follows from provenance. If you have:

this does not mean that your pointer is associated with that object. In other words, the compiler, with the standard's blessing, can consider that not to be the case and speculate on it however it sees fit.

This is strange and counterintuitive, and it gets in the way of understanding some important concepts on which the language standard relies. And it also completely overturns what the C++03 standard guaranteed, but, you know, we don't live in 2003 anymore.

To properly internalize the concept introduced in C++17, we need to prepare the ground. I'll now describe my personal mental model of how pointers are connected to objects in memory. It's a kind of visualization that helps make this mechanism easier to understand.

Imagine that memory is not a one-dimensional sequence of addresses, but a two-dimensional matrix, where the Y axis represents different memory addresses, while the X axis represents some space into which objects can be placed. Alternatively, instead of the X axis, you could imagine layers for the same memory addresses - both approaches work equally well as a mental experiment, but a two-dimensional matrix is easier to draw. Let's execute this code:

T* t1 = new T;

Graphically, memory will look like this:

The object is located at address 0x0A, the pointer is associated with the object and points directly to it. Now let's destroy the object:

t1->~T();

The object is gone, but the pointer still exists. It still has the same address 0x0A, but now it points to nowhere. Let's create a new object at address 0x0A:

T* t2 = new (t1) T;

Look at the interesting picture we get. A new object has appeared at address 0x0A. And it would seem that t1 has the same address, so we should be able to dereference it and use the new object. But no - the object was not created quite where we expected it to be - it ended up "in a new layer" at the same address, while t1 is looking past it. And the arrow from t1 will not move itself to the new object! As a result, the compiler may consider t1 to point to an invalid object. Counterintuitive, isn't it?

Chapter Takeaways

Transparently Replaceable Objects and std::launder

Actually, when I said "the arrow from t1 will not move itself to the new object", I was frankly lying to you. Because in the general case, of course it won't move, but the very same C++17 standard introduced the concept of transparently replaceable objects:

Definition of transparently replaceable

[basic.life] p9

An object o1 is transparently replaceable by an object o2 if either

- o1 and o2 are complete objects for which:

- o1 is not const,

- the storage that o2 occupies exactly overlays the storage that o1 occupied, and

- o1 and o2 are of the same type (ignoring the top-level cv-qualifiers), or

- o1 and o2 are corresponding direct subobjects for which:

- the complete object of o1 is not const or

- o1 is a mutable member subobject or a subobject thereof.

The application of this concept:

Effect of transparent replacement

[basic.life] p10

After the lifetime of an object has ended and before the storage which the object occupied is reused or released, if a new object is created at the storage location which the original object occupied and the original object was transparently replaceable by the new object, 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.

Note: If these conditions are not met, a pointer to the new object can be obtained from a pointer that represents the address of its storage by calling std​::​launder.

In other words, if two objects are transparently replaceable, then when one is recreated in place of the other, the little arrow moves automatically from one object to the other. And notice that in my example above:

T* t1 = new T;
t1->~T();
T* t2 = new (t1) T;

t1 and t2 are indeed pointers to transparently replaceable objects, because they have the same type, occupy exactly the same storage, and the object behind t1 is non-const. In other words, my deception is obvious: first I gave you the basic rule without mentioning the big fat exception, and now I'm introducing that exception retroactively. All for the sake of a smooth learning curve.

The correct picture should be this:

Also notice the note at the end of the Effect of transparent replacement paragraph - if it so happens that your objects are not transparently replaceable, the situation can be fixed by calling std::launder.

You know, I couldn't understand std::launder for a very long time. But with the concept of memory layers, everything falls into place: std::launder simply adjusts the arrow. Here's how it does it:

Definition of std::launder

[ptr.launder] p2-p3

Preconditions: p represents the address A of a byte in memory. An object X whose type is similar to T is located at the address A, and is either within its lifetime or is an array element subobject whose containing array object is within its lifetime.

All bytes of storage that would be reachable through the result are reachable through p.

Returns: A value of type T* that points to X.

More simply: if somewhere at the pointer's address there is a live object of a type similar to T, std::launder will give you a new pointer with its arrow moved to that object. In terms of our model, you can think of this as "fixing the provenance": you get a pointer associated with the live object located at the same address. If there is no suitable object at that address, using std::launder is not permitted. If there is an object and the original pointer already points to it, the result will point to the same object.

As you can probably tell, in normal language terms the function does nothing - it is more of a hint to the compiler than an ordinary function. The most concise description of the function's purpose is the human-readable name of paragraph [ptr.launder]: "Pointer optimization barrier". And indeed - fixing the pointer provenance of a "fishy" pointer simply takes away the compiler's ability to speculate about its lack of an associated object.

Most often, modern std::launder appears where you want to obtain a pointer to a valid object while initially holding a pointer of a different type that is located at the same address - a specific case we'll discuss in another chapter. If we're talking about recreating objects of the same type at the same address, then in 90% of cases the objects will be transparently replaceable because their types are identical, and there is never any need to call std::launder. However, you can contort things and construct a synthetic example where an object of the same type is placed in memory but the conditions for transparent replacement are not met:

struct Wrapper { T t; };

static_assert(sizeof(Wrapper) == sizeof(T));
static_assert(alignof(Wrapper) <= alignof(T));

...

T* t1 = new T;

There is a live T object behind t1

t1->~T();

The object is gone, and t1 is an orphan

Wrapper* w = new (t1) Wrapper;

By creating a Wrapper object at the address of t1, we have effectively also placed a new object of type T at that same address, because w and w->t have zero offset and therefore both reside at the address held by t1. But T t1 is still an orphan, because the old object behind t1 and the new w->t are not transparently replaceable - neither is a complete object, and they have no object-subobject relationship, so they do not satisfy the Definition of transparently replaceable in any way.

T* t2 = std::launder(t1);

We cannot use t1 normally, so we create a new pointer t2 through std::launder, with provenance pointing to the w->t object

Chapter Takeaways

Char and type-accessibility

There are two types:

struct T { int x; };
struct U { int x; };

Now, knowing about pointer provenance, we understand why an object of type T cannot be used by interpreting it as type U - if you had an object of type T, and there has never been an object of type U, no knowledge that they have the same layout, etc. will save you from UB, because the compiler knows better.

T t;
U u = *reinterpret_cast<U*>(&t); // UB

However, by surrounding itself with all these UBs, the standard would have deprived itself of the ability to access objects byte-by-byte - after all, to inspect an object, you need to cast it to a one-byte type through which you can look inside the object:

T t;
char* bytes = reintepret_cast<char*>(&t);
std::cout << bytes[1];

But there are no char objects there where we're trying to look through them!

So the standard gave the types char, unsigned char, and std::byte special status and introduced the concept of type-accessible types:

Definition of type-accessible

[basic.lval] p11

An object of dynamic type Tobj is type-accessible through a glvalue of type Tref if Tref is similar to:

- Tobj,

- a type that is the signed or unsigned type corresponding to Tobj, or

- a char, unsigned char, or std​::​byte type.

If a program attempts to access the stored value of an object through a glvalue through which it is not type-accessible, the behavior is undefined

What can this be useful for:

For example, checking the endianness of the machine on which the code is running:

bool little_endian() {
    int i = 1;
    return *reinterpret_cast<unsigned char*>(&i) == 1;
}

The standard even allows you to rewrite an object's bytes through a char*. The standard merely specifies that if modifying the object representation results in a bit pattern that is not valid for the given type, attempting to read the value of such an object may result in UB:

Bit consistency rule

[conv.lval] p3.3

Otherwise, if the bits in the value representation of the object to which the glvalue refers are not valid for the object's type, the behavior is undefined.

Chapter takeaways

Provides Storage

I've already shown you examples like:

double d = 15.0;
new (&d) long long(4);

where creating a long long in the memory occupied by a double ends the lifetime of the double object. Such destruction is perfectly normal, and works in the general case:

Condition for the end of an object's lifetime

[basic.life] p2

...

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

...

The third point is specifically about reusing memory occupied by an object.

But there are cases where this behavior gets in the way: for example, when you create an object inside a byte array to implement SBO or type erasure. The standard has addressed this case separately as well, creating yet another exception to the rules through the definition of provides storage:

Definition of provides storage

[intro.object] p3

If a complete object is created in storage associated with another object e of type “array of N unsigned char” or of type “array of N std​::​byte”, that array provides storage for the created object if

- the lifetime of e has begun and not ended, and

- the storage for the new object fits entirely within e, and

- there is no array object that satisfies these constraints nested within e.

Objects of type unsigned char[] or std::byte[] do not die when objects are created in their memory, because the standard explicitly designated them as storage types. Remember the example?:

alignas(Point) std::byte[sizeof(Point)] data;

Point* p = new (data) Point;
p->~Point();

The std::byte[] object does not die even though a Point object was created in its memory.

Notice that objects of type char[] were not given the same powers, even though char is perfectly suitable for type-accessibility.

alignas(T) unsigned char buf1[sizeof(T)];  // provides storage
alignas(T) std::byte     buf2[sizeof(T)];  // provides storage
alignas(T) char          buf3[sizeof(T)];  // does NOT provide storage

The standard made sure that the storage object does not die when other objects are placed inside it. But why? You might think - let it die, and we'll just keep placing our variables into this dead buffer. After all, if we allocate memory with malloc on the heap, that's exactly what happens - we're simply working with a chunk of memory that contains nothing, and that's perfectly fine.

alignas(T) std::byte[sizeof(T)] buf;

T* t = new (buf) T;

vs

std::byte* buf = static_cast<std::byte*>::operator new(sizeof(T), std::align_val_t(alignof(T))));

T* t = new (buf) T;

Functionally, these are the same thing. In both cases, you want a buffer into which you will place an object. Except that when implementing SBO (small buffer optimization), you want to put it on the stack - that's the whole point of the optimization. And you choose std::byte[].

But there is an important point about std::byte[] - it is an ordinary variable, you have simply decided to use it in a way that is not quite typical for the language. In reality, I could use it for its intended purpose, as an array: buf[3] = 3;. But I can do that only if there is a live object behind buf. In the case of a buffer dynamically allocated as raw memory, I cannot do that at all (except for implicit-lifetime cases, but that's different).

Let's continue the code:

alignas(T) std::byte[sizeof(T)] buf;

T* t = new (buf) T; // 1
t->~T();            // 2
t = new (buf) T;    // 3

Without the provides-storage exception, we would have this picture:

But in reality, buf is always alive. And that means we can always return to it and use it as an array, which makes more sense if we keep in mind that buf is not just a byte buffer, but also an ordinary variable.

Interestingly, you can formally implement SBO with char[] - or even uint8_t[] or bool[] - and not run into a single instance of UB, but as soon as you try to copy the buffer with memcpy or read it as bytes, you immediately get UB, because the array object dies as soon as you first place anything into it and never comes back to life.

And when working with a provides-storage buffer, std::launder suddenly shows up again, because sometimes we need to jump from the std::byte[] object to the target object T:

template<typename T>
class Buffer {
public:
    void reset() {
        new (buf) T;                  // 1
    }

    T& get() {
        return *std::launder(         // 3
            reinterpret_cast<T*>(buf) // 2
        );
    }
private:
    alignas(T) std::byte[sizeof(T)] buf;
};

void f() {
    Buffer<T> buf;
    buf.reset();
    T& t = buf.get();
}

In the diagram, t₁ is the pointer after the cast; t₂ is the pointer after std::launder:

I should note that there is currently a proposal P3006R1 aimed at eliminating the need for std::launder with provides-storage buffers. If this happens, std::launder will become a function with vanishingly few use cases, because in most situations everything will work without it anyway.

Chapter takeaways

Pointer-interconvertibility + reinterpret_cast

I always thought that reinterpret_cast was a cast of last resort that could cast absolutely anything to absolutely anything if the other casts couldn't handle it. That was, of course, rather naive.

Over time, I learned that the powers of reinterpret_cast are far from omnipotent. Quite the opposite - there is a narrow set of scenarios where you can use it without subsequently getting UB.

In this chapter, I want to focus specifically on casting pointers. The standard explicitly specifies what reinterpret_cast of pointers means:

reinterpret_cast for pointers

[expr.reinterpret.cast] p7

... When a prvalue v of object pointer type is converted to the object pointer type “pointer to cv T”, the result is static_cast<cv T>(static_cast<cv void>(v)).

So it all boils down to a static_cast to a pointer of another type through void*. And the standard says the following about such a cast:

static_cast for void

[expr.static.cast] p12

... If the original pointer value represents the address A of a byte in memory and A does not satisfy the alignment requirement of T, then the resulting pointer value is unspecified.

Otherwise, if the original pointer value points to an object a, and there is an object b of type similar to T that is pointer-interconvertible with a, the result is a pointer to b.

Otherwise, the pointer value is unchanged by the conversion.

This brings us to a key concept: pointer-interconvertible objects. Notice how cleverly the standard shifts from the concept of a "pointer" to the concept of an "object" to define pointer-interconvertibility in terms of objects:

Definition of pointer-interconvertible

[basic.compound] p7

Two objects a and b are pointer-interconvertible if

- they are the same object, or

- one is a union object and the other is a non-static data member of that object, or

- one is a standard-layout class object and the other is the first non-static data member of that object or any base class subobject of that object, or

- there exists an object c such that a and c are pointer-interconvertible, and c and b are pointer-interconvertible.

If two objects are pointer-interconvertible, then they have the same address, and it is possible to obtain a pointer to one from a pointer to the other via a reinterpret_cast.

Note: An array object and its first element are not pointer-interconvertible, even though they have the same address. — end note

As a result, we get a fairly narrow set of scenarios where a pointer to one object can be converted into a pointer to another object via reinterpret_cast. To another object, specifically! static_cast for void* is not saying "Otherwise, the pointer value is unchanged by the conversion" for no reason. In other words, if the objects are not pointer-interconvertible, the pointer you get back will retain the provenance of the original object. And you most likely won't be able to use such a pointer, because the pointer type and the type of the object it points to don't match.

Here are some illustrative examples of pointer-interconvertible objects:

union T {
    Point p;
    int i;
};

T t;

int& i = *reinterpret_cast<int*>(&t);   // 1
i = 15;

*reinterpret_cast<Point*>(&t) = {3, 2}; // 2
struct Point {
    int x;
    int y;
};

Point p;

int& x = *reinterpret_cast<int*>(&p); // 1
x = 15;
struct Entity { };
struct Player : Entity { int health; };

Player* p = new Player{200};

Entity* e = reinterpret_cast<Entity*>(p); // 1
int* h = reinterpret_cast<int*>(p); // 2
int* h2 = reinterpret_cast<int*>(e); // 3

In addition to pointer-interconvertible objects, don't forget about type-accessible objects: any pointer to an object can safely be cast via reinterpret_cast to char, unsigned char, or std::byte* and used to inspect the raw bytes.

Beyond these cases, the territory of valid reinterpret_cast gets downright exotic: pointer round-trips. This is when we cast a pointer into some complete nonsense and then cast it back to our original type. The standard guarantees that such a pointer returns from its casting journey safe and sound:

Pointer round-trip

[expr.reinterpret.cast], p7, Note 7

Converting a prvalue of type "pointer to T1" to the type "pointer to T2" (where T1 and T2 are object types and where the alignment requirements of T2 are no stricter than those of T1) and back to its original type yields the original pointer value.

Point* p1 = new Point(12, -2);
Chair* p2 = reinterpret_cast<Chair*>(p1);
Point* p3 = reinterpret_cast<Point*>(p2);

p3 will be fully equivalent to p1, and you can use it as usual.

Another kind of round-trip scenario is converting a pointer value to an integer and back:

Pointer-integral round-trip

[expr.reinterpret.cast] p5

A value of integral type or enumeration type can be explicitly converted to a pointer. If the value is one that can be produced by converting one or more pointer values to an integral type, the result is an unspecified choice among all such values that would result in the program having defined behavior. If no such value exists, the behavior is undefined.

T* p = new T;
uintptr_t addr = reinterpret_cast<uintptr_t>(p);
T* q = reinterpret_cast<T*>(addr);

p is equivalent to q. However, this is only the case when there is a single such pointer with the specific address addr. Otherwise, converting back to a pointer will give you any of the pointers that exist at that address: an unspecified choice, as the standard calls it.

What reinterpret_cast cannot Do

Definition of pointer-interconvertible has a note at the end that explicitly says you cannot reinterpret_cast an array to its first element - this is not a pointer-interconvertible case. But you don't need to! The semantics of pointer and array conversions let you avoid the cast altogether:

int a[10];
int* pGood = a;                         // 1
int* pBad = reinterpret_cast<int*>(&a); // 2

Another case where reinterpret_cast is helpless is casting from derived classes to base classes and back. reinterpret_cast generally cannot handle this task:

struct Base { int i; };
struct Derived : Base { float f; };

Derived* d = new Derived;

Base* b1 = static_cast<Base*>(d);             // 1
Derived* d1 = static_cast<Derived*>(b1);      // 2

Base* b2 = reinterpret_cast<Base*>(d);        // 3
Derived* d2 = reinterpret_cast<Derived*>(b1); // 4

However, you may remember that the third point in Definition of pointer-interconvertible described a case involving inheritance. But this only works for standard-layout classes, and for a class hierarchy to remain standard-layout, specific conditions must be met - in particular, only one class in the hierarchy may have non-static data members. That's a fairly narrow case that you shouldn't rely on in normal development.

In any case, it is worth saying that reinterpret_cast is simply not the tool we use to "walk" class hierarchies. That's what static_cast and dynamic_cast are for. In particular, they understand multiple inheritance and can even adjust the resulting address. reinterpret_cast simply cannot do that.

Chapter takeaways

Destructor Nuances

A destructor is a function called during the destruction of an object. Despite its important role in an object's lifetime, to my surprise, the relationship between the destructor and the object's lifetime is described rather imperfectly in the current working draft of the standard (C++26).

While studying the standard, I encountered quite a few logical gaps and omissions that leave room for ambiguous interpretations. Moreover, comparing it with previous editions shows that the wording describing this relationship has been actively changing and continues to be refined. I would say this is the least worked-out part of the standard I've encountered while writing this article. Most of it is located in [basic.life], where the major cutting-edge changes concerning object lifetimes are happening.

Therefore, as of the date of writing this article - September 26, 2026 - this part of the standard should be divided into two rough categories. There are established rules that you can confidently rely on, and there is a rougher part whose interpretations have changed frequently and will likely continue to change in the near future.

For the latter, I will formulate more practical warnings - what you should avoid doing so that you stay in the safe zone even where the standard itself does not yet provide sufficiently reliable and unambiguous rules.

Immutable Rules

The first immutable rule is fairly obvious: calling a destructor on a dead object is UB:

Prohibition of repeated destruction

[class.dtor] p18

Once a destructor is invoked for an object, the object's lifetime ends; the behavior is undefined if the destructor is invoked for an object whose lifetime has ended.

[Example 3: If the destructor for an object with automatic storage duration is explicitly invoked, and the block is subsequently left in a manner that would ordinarily invoke implicit destruction of the object, the behavior is undefined. — end example]

Example 3 in the standard shows one way of getting into such a UB situation. And this particular situation is made possible by the second immutable rule - the rule concerning implicit destructor calls:

Implicit destructor calls

[class.dtor] p14

A destructor is invoked implicitly

- for a constructed object with static storage duration at program termination,

- for a constructed object with thread storage duration at thread exit,

- for a constructed object with automatic storage duration when the block in which an object is created exits,

- for a constructed temporary object when its lifetime ends.

The most familiar of these calls is, of course, the implicit destructor call when a variable leaves its scope; the automatic storage scenario:

{
std::vector<int> v(100);
} // 1

Such implicit destructor calls are planned by the compiler in advance, and there is no way to cancel them: the call will happen no matter what.

In principle, these two rules alone - the combination of Implicit destructor calls + Prohibition of repeated destruction - are enough to live quite comfortably, because from them we can derive situations where nothing good can possibly come of it, or perhaps something good can happen, but that's not exactly certain (this is a little nod toward the rougher part of the standard, where interpretations can take us pretty much anywhere):

It is also worth noting that at the same moments when an implicit destructor call is scheduled, another part of the standard describes the release of storage occupied by an automatic, static, or thread_local variable. These rules are spread throughout [basic.stc.auto], but the corresponding points in time coincide with the moments when implicit destructor calls are scheduled. Moreover, the standard explicitly specifies that for an object with a destructor, storage is released after the destructor has executed. Thus, the expected sequence is preserved: first the object's lifetime ends, then the memory it occupies is released.

As a bonus, here's something a little more borderline, but still with some degree of stability. We already mentioned the Condition for the end of an object's lifetime paragraph in the chapter about provides-storage types. I want to show it again, but with different aspects emphasized:

Condition for the end of an object's lifetime

[basic.life] p2

...

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

...

Let me say right away that the paragraph itself is not exactly a model of stability and has a tendency to be rewritten, but the part I've highlighted is practically rock-solid, while the unhighlighted part creates an important "background". Here's what we can squeeze out of this paragraph:

First conclusion: calling the destructor causes the object's death. This is the official connection between the destructor and the object's lifetime.

Second conclusion: a destructor call is not necessarily what causes an object's death - it is merely one of the possible scenarios. Put differently: the destructor is not always called when an object dies, even if the object has one. And this is already interesting, because the destructor contains part of the logic that the program relies on. But as you can see, if you simply free the object's memory or, for example, create another object in its storage, we get destruction of the object without calling its destructor. I won't boldly claim whether this is UB or not, because on the one hand, such a scenario invalidates the program's logic whenever the destructor contains actual code; on the other hand, at the moment the standard does not treat this behavior as UB as such, although that can easily change.

Third conclusion: the phrase "if T is a class type" rather emphatically reminds us that destructors exist only for class types. So what about non-class types? And here we find something interesting...

Pseudo-destructor

A destructor is a concept that exists only for classes. The standard says this not only in the Condition for the end of an object's lifetime paragraph. Here's a more appropriate paragraph:

Types with (pseudo-)destructors

[expr.prim.id.dtor] p1

An id-expression that denotes the destructor of a type T names the destructor of T if T is a class

type, otherwise the id-expression is said to name a pseudo-destructor.

Interesting: for classes, destructors; for non-class types, pseudo-destructors. The next paragraph provides an important clarification:

Scalars and pseudo-destructors

[expr.prim.id.dtor] p2

If the id-expression names a pseudo-destructor, T shall be a scalar type...

So pseudo-destructors exist only for scalar types.

This concept - destructors belonging to class types and pseudo-destructors belonging to scalar types - leaves a narrow group of types without even a hint of any (pseudo-)destructor:

Those that have a pseudo-destructor can explicitly call it:

Using pseudo-destructors

[class.dtor] p19, Note 10

The notation for explicit call of a destructor can be used for any scalar type name. Allowing this makes it possible to write code without having to know if a destructor exists for a given type. For example:

`cpp

typedef int I;

I* p;

p->I::~I();

`

The typedef in this example is necessary because ~int is a syntax error (!).

Notice the words "without having to know if a destructor exists for a given type". This is referring to template code. With pseudo-destructors, the code will work without syntax errors even when ~T() is called with T = int. And that's convenient.

Starting with C++20, pseudo-destructors were given semantic meaning: they now end the lifetime of a scalar object, just as calling a regular destructor does for class objects. Back in C++17, a pseudo-destructor was literally a no-op and did nothing - it merely allowed such a construct to exist without upsetting the compiler - simply a placeholder for making template code compilable:

// C++17
using I = int;
int i = 14;
i.I::~I();
std::cout << i; // OK

C++17 had the [expr.pseudo] paragraph, which explicitly specified this:

Pseudo-destructors in C++17

C++17 [expr.pseudo] p1

The use of a pseudo-destructor-name after a dot . or arrow -> operator represents the destructor for the non-class type denoted by type-name or decltype-specifier. The result shall only be used as the operand for the function call operator (), and the result of such a call has type void. The only effect is the evaluation of the postfix-expression before the dot or arrow.

In the case of i.I::~I();, the postfix-expression is simply i, so the pseudo-destructor itself does absolutely nothing in C++17.

In C++20, the object-lifetime rules kick in: touch the (pseudo-)destructor - destroy the object. The [expr.pseudo] paragraph no longer exists in the standard, but modern [expr.call] specifies the new semantic behavior for pseudo-destructors:

Pseudo-destructors in C++20

C++20 [expr.call] p4

... If the postfix-expression names a pseudo-destructor ..., the function call destroys the object of scalar type denoted by the object expression of the class member access.

// C++20
using I = int;
int i = 14;
i.I::~I();      // object is destroyed
std::cout << i; // UB

Touching a destroyed object is UB.

It is important that the standard watches its terminology very closely. If the standard says "destructor", it always means a real class destructor and nothing else. Pseudo-destructors are always called pseudo-destructors; the standard does not generalize these concepts.

In particular, paragraphs such as Implicit destructor calls do not apply to pseudo-destructors. In other words, you can only call a pseudo-destructor manually - the compiler does not secretly generate these calls for you. Destruction of a scalar object when leaving scope is caused exclusively by releasing the storage occupied by the object.

Trivial destructor

There is a category of types called trivially destructible. These are types with a trivial destructor:

Definition of trivial destructor

[class.dtor] p8

A destructor for a class X is trivial if it is not user-provided and if

- the destructor is not virtual,

- all of the direct base classes of X have trivial destructors, and

- either X is a union or for all of the non-variant non-static data members of X that are of class type (or array thereof), each such class has a trivial destructor.

Otherwise, the destructor is non-trivial.

The key/main point here is not user-provided. Put simply, if you don't explicitly declare a destructor, it will be trivial. And, surprisingly, pseudo-destructors join this club too - they are trivial as well, so all scalar types are trivially destructible. Trivially destructible types can be identified with std::is_trivially_destructible.

The main feature of a trivial destructor is that it contains no logic or side effects. At the point where it is called, the compiler will generate a no-op. And theoretically, you can speculate based on that. For example, deliberately destroying an object without calling its destructor, "because there's nothing there anyway"; or assuming that the implicit destructor call when a variable leaves scope is no longer a concern, and therefore you can do whatever you want within that scope. I certainly wouldn't speculate that way - because the standard is sitting in a gray area here, and tomorrow that speculation may become UB.

What Not to Do

I won't cite contradictory or insufficiently settled paragraphs of the standard, because the article would become obsolete within a few years, if not months. I'll only explain how to protect yourself and write code that is unlikely to be reclassified from valid to UB-producing after a few revisions of the standard.

Don't Rely on a Trivial Destructor

You should not speculate that "calling a trivial destructor" is equivalent to "not calling a destructor", although the temptation to think so is certainly strong. At present, the standard makes no distinction between the compiler generating a call to an ordinary destructor and a trivial destructor - the call happens in either case.

Yes, it may become a no-op in the final assembly, but don't forget that calling a destructor also means ending the object's lifetime and all the consequences associated with that.

In other words, a trivial destructor is not exempt from UB after an attempt to destroy an object twice.

The only types you can treat somewhat more freely here are scalar types. They don't have a destructor - only a pseudo-destructor. Remember that a pseudo-destructor is not called implicitly. Accordingly, you cannot get into the trap of implicitly destroying a scalar object twice. And types with neither destructors nor pseudo-destructors are completely exempt from this problem.

Don't Manually Destroy an Object on the Stack

This actually applies to static and thread_local variables as well. Simply don't call the destructor of such variables manually, and you will protect yourself from a whole host of problems.

Why might you need to explicitly call the destructor of a stack variable in real code? For example, to implement SBO. But there is already a well-trodden path for implementing SBO: use provides-storage arrays - they already have the standard's forgiveness and give you good protection against UB:

In other words, with stack-allocated std::byte[] and unsigned char[], you are completely safe.

With ordinary types, manually calling the destructor is safe only if you allocated the object on the heap - for such an allocation, no implicit destructor call is scheduled, so you can completely control the situation: call the destructor manually, perform placement new, deallocate the memory.

Destructor and Object Lifetime

I want to draw attention to one important and slightly strange point. The standard associates calling the destructor with the end of an object's lifetime and tries to make them go hand in hand, almost identical in time. However, there is a fundamental problem that makes their absolute temporal coincidence simply impossible in the general case.

The thing is, an object's lifetime is a runtime concept, as the standard explicitly states:

Runtime nature of object lifetime

[basic.life] p2

The lifetime of an object or reference is a runtime property of the object or reference.

...

A destructor call, on the other hand, is a function call generated by the compiler at the program compilation stage. In other words, these are concepts from different worlds that the standard is doing everything it can to bring together and align in time.

And it is very easy to get a mismatch - in fact, we've already seen examples where this is happening in full force:

The Object Dies, but the Destructor Never Runs

// birth
void* p = ::operator new(sizeof(std::vector<int>));
auto* v = new (p) std::vector<int>(100);

// death
::operator delete(p); // 1

The Destructor Is Called, but the Object Is Already Dead

We've already seen this:

{
std::string s("qwertyuiopasdfg");
s.~string(); // 1
...
}            // 2

The Destructor Is Called, but a Different Object Already Occupies the Storage

{
    T t;
    new (&t) T; // 1
}               // 2

It must be said that this is a very slippery topic - whether this is legal, and whether point 2 results in UB. The main problem here is the standard's lack of detail on the subject. I want to examine this story in more detail because it is illustrative and demonstrates the current state of affairs rather vividly.

Let's reread our fundamental paragraph on implicit destructor calls:

Implicit destructor calls

[class.dtor] p14

A destructor is invoked implicitly

- for a constructed object with static storage duration at program termination,

- for a constructed object with thread storage duration at thread exit,

- for a constructed object with automatic storage duration when the block in which an object is created exits,

- for a constructed temporary object when its lifetime ends.

"For a constructed object" - that's all we've got. Some interpret this in favor of the originally constructed object. In other words, we constructed an object at the beginning of the scope, and a destructor call was scheduled for it. Under this interpretation, when the time comes to call the destructor, the only correct option is to call it for the original live object.

Another interpretation is that "constructed object" means any live, constructed object. The current, rather rough paragraphs in [basic.life] support this interpretation, but they are just as open to broad interpretations due to their wording.

One of the most interesting things I stumbled across during my research was a Stack Overflow post. Yes, I ended up there in 2026, because I wanted to double-check the convincingly dubious theories produced by AI chats and read what actual people had to say about it. One of the answers mentioned that the author had contacted the Core team of the standardization committee about this question:

[class.dtor]p12 isn't very accurate. I asked Core about it and Mike Miller (a very senior member) said:

I wouldn't say that it's a contradiction [[class.dtor]p12 vs [basic.life]p9], but clarification is certainly needed. The destructor description was written slightly naively, without taking into consideration that the original object occupying a bit of automatic storage might have been replaced by a different object occupying that same bit of automatic storage, but the intent was that if a constructor was invoked on that bit of automatic storage to create an object therein - i.e., if control flowed through that declaration - then the destructor will be invoked for the object presumed to occupy that bit of automatic storage when the block is exited - even it it's not the "same" object that was created by the constructor invocation.

Incidentally, you can see that since then (2018), the paragraph numbers have shifted somewhat - which is exactly why I don't want to overuse them in the article.

As we can see, the theory that "constructed object" means any live, constructed object seems to be correct. But I still urge you: don't manually destroy an object on the stack, don't create situations like this, DON'T PROVOKE IT - this area is murky, unstable, and requires personal explanations from committee members.

And besides, everything starts falling apart anyway if you take one step to the side. Look closely:

{
std::vector<double> v(10);
new (&v) std::vector<int>; // 1

...
}                          // 2

Can you guess where the UB comes from? We changed the type. From std::vector<double> to std::vector<int>. And everything falls apart - after all, the implicit destructor call was scheduled at compile time for the type std::vector<double>, and when reaching line 2 you will be attempting to call the destructor of std::vector<double> for an object of type std::vector<int>.

Chapter takeaways

The End

This article was primarily written for myself. If it clarified something for someone else as well, all the better. If you found a glaring error - write/call me immediately. If you found a minor issue - write to me, but there's no need to rush.