Object lifetime
Memory and objects are two things. A block of bytes exists from allocation, an object exists from its constructor to its destructor, and most C++ keeps the two together. A container or a variant has to separate them.
The tools are placement new, which constructs an object at an address the program already owns, an explicit destructor call, and std::launder for the pointer after the object is replaced. This lesson builds the smallest version of what std::optional and std::vector do internally.
Raw storage
A buffer of unsigned char with the right size and alignment is storage for one T: it has room for the bytes and starts at an address the type accepts. No object exists in it yet, which is why reading a T from it would be undefined.
The program declares a buffer for a point and prints the size of the buffer and of the point. They are equal, and the alignment is the one of the type because the buffer is declared with alignas.
std::aligned_storage was the old way to write this. Since C++23 the ordinary alignas buffer is the recommended form, and it says exactly what it is: bytes with a guarantee about their address.
#include <iostream>
struct point
{
int x;
int y;
};
int main()
{
alignas(point) unsigned char buffer[sizeof(point)];
std::cout << sizeof(buffer) << ' ' << sizeof(point) << ' ' << alignof(point) << '\n';
}
g++ -std=c++23 -Wall -Wextra storage.cpp -o storage
./storage
8 8 4
Placement new
new (buffer) point{3, 4} constructs a point inside the buffer and returns its address. It allocates nothing: the placement form of new takes the address from the caller and only runs the constructor.
The program constructs the point in the buffer and prints its fields and its address compared with the buffer's. The two addresses are equal, which shows the object lives in the storage.
The buffer is still just bytes as far as the language is concerned; the object inside it is a separate thing with a lifetime that started at the constructor. From here the program must keep the two in step.
#include <iostream>
#include <new>
struct point
{
int x;
int y;
};
int main()
{
alignas(point) unsigned char buffer[sizeof(point)];
point* p = new (buffer) point{3, 4};
std::cout << p->x << ' ' << p->y << ' ' << (p == reinterpret_cast<point*>(buffer)) << '\n';
}
g++ -std=c++23 -Wall -Wextra placement.cpp -o placement
./placement
3 4 1
An explicit destructor
The destructor of an object in a buffer has to be called by hand: p->~point(). The buffer is not freed, only the object in it ends, and the bytes can then hold something else.
The program constructs an object with a destructor that prints, calls the destructor and then prints a marker. The output shows the destruction before the marker, and the storage is untouched.
This is what a vector does when it erases an element or shrinks: the elements are destroyed, the buffer stays. It is also why a container's destructor loops over its elements instead of just freeing the block.
#include <iostream>
#include <new>
struct tracker
{
tracker()
{
std::cout << "constructed\n";
}
~tracker()
{
std::cout << "destroyed\n";
}
};
int main()
{
alignas(tracker) unsigned char buffer[sizeof(tracker)];
tracker* t = new (buffer) tracker;
t->~tracker();
std::cout << "marker\n";
}
g++ -std=c++23 -Wall -Wextra destroy.cpp -o destroy
./destroy
constructed
destroyed
marker
std::launder
When a new object is constructed in the storage of an old one, a pointer to the old object does not point at the new one. std::launder tells the compiler that the pointer refers to the object now in that storage.
The program constructs a small object in a buffer, destroys it and constructs a new one in the same place, then reads it through a laundered pointer. The value is the second one, 2.
The rule matters in generic code that reuses storage. In ordinary code the object is created by a declaration and the question never appears, which is the simpler state to stay in.
#include <iostream>
#include <new>
struct box
{
int value;
};
int main()
{
alignas(box) unsigned char buffer[sizeof(box)];
box* first = new (buffer) box{1};
first->~box();
box* second = new (buffer) box{2};
box* same = std::launder(reinterpret_cast<box*>(buffer));
std::cout << same->value << ' ' << (same == second) << '\n';
}
g++ -std=c++23 -Wall -Wextra launder.cpp -o launder
./launder
2 1
Reading before the object exists
The bytes in a fresh buffer are not an object. Reading a point out of the buffer before placement new gives whatever the bytes happen to be, and the standard calls it undefined behaviour.
The program reads the fields of a buffer that was never constructed. The numbers printed are not a value of the program; they are whatever the stack held, and the compiler may do worse than print them.
The fix is the placement new of the second section. A container that reuses storage keeps a flag or a size that says which slots hold a live object, which is exactly the bookkeeping std::optional does with one bool.
#include <iostream>
struct point
{
int x;
int y;
};
int main()
{
alignas(point) unsigned char buffer[sizeof(point)];
point* p = reinterpret_cast<point*>(buffer);
std::cout << p->x << '\n';
}
g++ -std=c++23 -Wall -Wextra unconstructed.cpp -o unconstructed
./unconstructed
unconstructed.cpp: In function 'int main()':
unconstructed.cpp:15:21: warning: 'buffer' is used uninitialized [-Wuninitialized]
15 | std::cout << p->x << '\n';
| ^
unconstructed.cpp:11:34: note: 'buffer' declared here
11 | alignas(point) unsigned char buffer[sizeof(point)];
| ^~~~~~
-864366616
Exercises
- Write a small optional-like class that stores a T in a buffer and a bool, and destroys the value only when the bool is true.
- Add a second placement new to launder.cpp and check which pointer the laundered one equals.
- Construct an array of three points in one buffer with a loop of placement new and destroy them in reverse order.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does
new (buffer) point{3, 4}do?Answer
Constructs a point at the address of the buffer
The placement form places the object at the address the caller gives and allocates nothing.
-
When does an object in raw storage come into existence?
Answer
When its constructor runs
The lifetime of an object starts when its constructor completes, not when the bytes appear.
-
What does this program print?
alignas(point) unsigned char b[sizeof(point)]; point* p = new (b) point{3, 4}; std::cout << p->x << ' ' << p->y;Answer
3 4
The placement new runs the constructor with the braces, so the fields hold 3 and 4.
-
How is the object in a buffer destroyed?
Answer
By calling its destructor explicitly
The buffer is not an object of the type, so its destructor does not run automatically; the code calls it by hand.
-
What does
p->~point()do?Answer
Ends the lifetime of the object at p and leaves the bytes
The explicit destructor call ends the object; the storage stays as it was.
-
Why is
std::launderneeded after a new object is constructed in old storage?Answer
To tell the compiler that the pointer refers to the new object
A pointer to the old object does not automatically refer to the new one; launder states the fact to the compiler.
-
What is read from a fresh buffer that was never constructed?
Answer
Whatever bytes are there, which is not a value of the program
The bytes have no object, so reading them as a point is undefined behaviour.
-
What does
alignas(point)on a buffer guarantee?Answer
That the buffer starts at an address suitable for a point
The alignment of the storage must satisfy the alignment of the type that will live in it.
-
Which standard facility does the same bookkeeping as this lesson, with a bool that says whether a value exists?
Answer
std::optional
optional stores a buffer and a flag; it constructs and destroys the value in place, as this lesson does by hand.
-
What does this program print?
#include <iostream> struct point { int x; int y; }; int main() { alignas(point) unsigned char buffer[sizeof(point)]; std::cout << sizeof(buffer) << ' ' << sizeof(point) << ' ' << alignof(point) << '\n'; }Answer
8 8 4
The buffer is the size of one point, so both sizes are 8, and the alignment of two ints is 4.
The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev