From Undefined to Defined: Using std::launder in C++
In today's post, I will continue with the overall topics of the last two months. Today you'll learn when and where you need to apply C++17's std::launder and where the difference to this utility is to reinterpret_cast or std::start_lifetime_as.
The fields where you can apply today's learning vary. The embedded domain is usually one where std::launder is used, but if you're writing library code, laundering occurs as well.
When things may break
I'm using the example from the paper P0532R0:
1 2 3 4 5 6 7 8 9 10 11 | |
You're looking at several different pieces that need to come together. Notice that the struct X declares the member n as const.
Next, with the help of new, an object is created in B and the resulting pointer is stored in p. So far, so good.
The interesting part starts next in C with the placement new. If you've never done this, great, then you might not need to do the laundry, at least not in your C++ code.
The placement new itself is also fine. The issue occurs later in D and E. Here is access to the values of the pointer p. But the compiler is allowed to assume that after B the contents of p are unchanged, as p itself was never used to change the contents of the object. Even worse, one of Xs data members is const. This is a free ticket for the optimizer to rightfully assume that the value of n never changes after construction.
But this is precisely what I've done in C. I change a const value! The values of i and d are unknown. You're looking at undefined behavior.
The undefined behavior in the previous example can be avoided easily, even without laundering, by updating the pointer and those telling the compiler that the values behind p have changed:
1 2 3 4 5 6 | |
So the question is, why not simply do that and forget about std::launder? Well, all the time you can do that, please do, and forget about std::launder.
Unfortunately, there are times when updating the pointer would sacrifice precious resources.
When things get more complicated
Suppose you implement a custom allocator like below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
You're looking at two functions that the allocator provides: Construct and Get. The concept of Buffer is that the data stored is type erased. A possible simplified usage might be:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
You see two data types, Point and Point3D as an example for arbitrary types in A. storage represents stack memory, which can store arbitrary data. As long as users know which datatype is behind an index (and said type isn't too large in capacity), everything can be stored.
At the first element of the array C, I construct a Point object, while at the second element I create a Point3D. It is only later in the program D that the objects are actually used. Yet nobody stored a pointer to the freshly constructed objects for a good reason. Such a pointer would require storage capacity, and you already know where this pointer points .
From the abstract machine perspective, I poked holes into the type system. The compiler can rightfully assume that the data behind Get does not change unless it sees a direct write access D, for example. Such an access goes unnoticed by the compiler (or may go since it is undefined behavior) in the code below in E, when I create a fresh Point object at an existing location.
1 2 3 4 5 6 7 8 9 10 | |
Obviously, the easy way to avoid issues here is to store the pointer returned by each Construct call. If that isn't feasible, as here, then the correct thing to do is to launder the pointer with std::launder. This special utility acts as a devirtualization fence, inhibiting compiler optimizations and assumptions.
std::launder to the rescue
Let's update the Buffer implementation in a safe manner. First, Construct, my initial implementation was:
1 2 3 4 5 6 | |
You can make this code safe, even without std::launder by returning the resulting pointer from new:
1 2 3 4 5 | |
The second function you need to fix is Get, this time by adding std::launder:
1 2 3 4 5 | |
The code now assumes that you call Construct before you ever invoke Get. Those mBuffer contains a valid object, and because you never make mistakes, the object correlates with the one that was created with Construct.
Summary
With std::launder you can update a pointer that points to an already existing object at that memory location. So the object's lifetime there has already been started.
Adding std::launder to the summary table I shared in my previous post, the table becomes:
| Conversion utility | When to use | Standard |
|---|---|---|
reinterpret_cast | Converting the pointer type | C++98 |
std::bit_cast | Obtaining the bit representation of an object | C++20 |
std::start_lifetime_as | Starting the lifetime of an object in a chunk of memory | C++23 |
std::launder | Update pointer view for an already in-lifetime object | C++17 |
Andreas
