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

  1. 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.
  2. Add a second placement new to launder.cpp and check which pointer the laundered one equals.
  3. 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.

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

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

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

  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.

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

  6. Why is std::launder needed 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.

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

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

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

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