← All posts
language

std::launder: Correct Per the Standard, Not Per What Breaks

There is a rule in the C++ standard that, as far as anyone can tell, no mainstream compiler actually enforces. That is not a rumor. It is the stated justification in an active proposal to the standards committee itself.

What Pointer Provenance Is

A C++ pointer carries more than an address. The compiler tracks what the standard and compiler implementers call provenance: which object a pointer originated from, the valid memory range it may access, and the lifetime that object is expected to have. This tracking is not visible in source code but exists in the compiler’s internal representation of your program.

Provenance exists because it enables the compiler to make safe assumptions. If a pointer provably originates from object X, the compiler can cache X’s value in a register and skip reloading it from memory on subsequent reads, because no other pointer that the standard permits to alias X has written to it. Remove that guarantee and the optimizer loses a significant class of safe transformations.

reinterpret_cast changes how you interpret a pointer’s bits. It changes nothing about provenance. Two pointers holding the identical memory address can still point at objects the compiler considers completely unrelated if they were obtained differently. This is the part that matters: it is not the cast that produces undefined behavior. It is the access.

float* pf = new (buf) float(3.14f);
int* pi_bad = reinterpret_cast<int*>(pf);
// *pi_bad;     // UB: the cast is fine. the read is not.
                // pi_bad has no int provenance, no int object exists here

The compiler has not seen an int object created at this address. Accessing through pi_bad asks the compiler to read an int from a location it believes holds a float. That is the access that is undefined, not the cast that produced pi_bad.

When Provenance Goes Wrong: Object Replacement

The provenance problem surfaces most sharply in placement new patterns, specifically the pattern every memory pool uses: destroy an object in a storage slot, construct a new one in its place, reuse the slot without a full deallocation cycle.

alignas(int) unsigned char buf[sizeof(int)];
using FloatT = float;

float* pf = new (buf) float(3.14f);
pf->~FloatT();                         // float object ended here

int* pi = new (buf) int(69);           // int object begins here, same bytes, different type

unsigned char* raw = buf;
int* stale = reinterpret_cast<int*>(raw);
// *stale;                             // UB: reinterpret_cast gives no int provenance

int* fixed = std::launder(stale);
std::cout << *fixed;                   // OK: provenance re-established, reads 69

pi is the pointer returned directly by placement new, which does have correct int provenance. stale was derived by casting the buffer’s address: it has the same bits as pi but was not obtained through the creation of the int object, so the compiler does not associate it with the current occupant. The standard says *stale is undefined behavior for this reason.

Transparent Object Replacement: When You Don’t Need It

The standard has a narrow exception where a pointer obtained before replacement stays valid without laundering. It is called transparently replaceable, and it requires all four of the following:

If all four hold, the original pointer remains valid. Miss any one and you are back to needing std::launder.

int* pi = new (buf) int(69);
pi->~int();
new (buf) int(99);   // same type, same storage, no const, no base — TOR applies
std::cout << *pi;    // valid without launder: transparently replaceable

The const member condition is the one that trips people. A class with a const int member is the original motivating example from the C++17 proposal that introduced std::launder. The compiler is permitted to treat const members as fixed after construction, and replacing the containing object with a new one of the same type does not lift that assumption without laundering.

What std::launder Actually Does (and Does Not Do)

std::launder(ptr) returns a pointer with the same address as ptr but with provenance reset to refer to whichever object currently occupies that storage. It does not cast. It does not allocate. It does not create an object that is not already there.

If no valid object of the requested type exists at the address, laundering it is still undefined behavior. std::launder can only restore access to an object that already exists.

On most implementations, std::launder compiles to nothing at the machine code level. The pointer value passes through unchanged. What changes is the compiler’s internal model: the returned pointer is treated as having fresh provenance, which prevents the compiler from reusing any cached read it derived from the pre-replacement object. The function is a compile-time barrier, not a runtime operation.

std::launder addresses object lifetime and provenance. The strict aliasing rule addresses type compatibility during access. They are related but distinct.

Strict aliasing says you cannot access an object through a pointer of a type the object was not created as (with exceptions for char, unsigned char, and std::byte). It is the rule that makes float f; int* p = reinterpret_cast<int*>(&f); *p; undefined: there is a live float object at that address, and you are reading through an int*.

std::launder cannot help with strict aliasing violations. If you destroy a float and construct an int in the same storage, laundering a pointer to that storage gives you a valid int* because a real int object now exists there. If the float is still alive and you attempt to read through an int* to the same address, std::launder would return an int* to… nothing, since no int object exists. The access would still be undefined.

The practical distinction: std::launder is for object lifetime correctness (wrong provenance after replacement). Strict aliasing is for type-access correctness (accessing through the wrong type). Both involve pointers to the same address behaving differently than expected, which is why they get conflated.

Trying to Make This Visibly Break

The natural next step after understanding the rule is to demonstrate it failing without std::launder. Various patterns were tested across optimization levels on GCC 13.3.0 and GCC 16.1.0, and every one produced the correct result regardless of whether std::launder was present.

The patterns tested:

Every test produced the expected output. Every time. On both compiler versions.

WG21 P3006R1: The Committee’s Own Analysis

This result is not a coincidence or a compiler-specific quirk. WG21 paper P3006R1, “Launder less,” submitted in 2024, proposes removing this exact undefined behavior from the standard. The paper states directly that popular compilers already produce the expected assembly without std::launder in this case: no cached load, no stale read, no difference in behavior. It points to widely deployed production code, parts of Boost, ClickHouse, and others, that has been skipping this rule since long before C++17 introduced std::launder in 2017, and has worked correctly throughout.

The one optimization that could exploit this gap exists: Clang’s -fstrict-vtable-pointers flag, which allows the compiler to assume that a virtual dispatch always goes to the same derived type for the lifetime of the pointer. This would allow it to cache the vtable lookup and skip the re-read after placement new replacement. P3006R1 explicitly names this as the mechanism that could make the UB visible. It ships disabled by default specifically because enabling it breaks existing code that relies on this UB never being exploited.

This is the situation: the standard mandates the UB exists, no default-configured mainstream compiler exploits it, the one optimizer that would exploit it is off by default due to breakage concerns, and an active standards proposal is asking to remove it from the language.

The One Place This Actually Breaks

Nothing triggered under GCC. Testing against Clang with -fstrict-vtable-pointers, the one optimization built specifically for this class of UB, broke immediately.

// without launder
Base* p = new (buf) DerivedA();
p->~Base();
new (buf) DerivedB();
std::cout << p->id() << "\n";               // prints 1 — wrong

p was constructed as DerivedA, destroyed, and a DerivedB placement-newed into the same storage. The object now sitting there is DerivedB, so id() should return 2. With -fstrict-vtable-pointers, Clang devirtualizes the call based on the stale provenance from the original DerivedA construction, skipping the vtable re-read entirely. It returns 1.

// with launder
std::cout << std::launder(p)->id() << "\n"; // prints 2 — correct

Identical code. The only change is std::launder(p). The answer flips from wrong to correct: laundering resets the provenance, forcing the vtable to be read from the actual current object.

This is a confirmed reproduction, not a hypothetical. The Clang tests were run separately since this repo’s environment is GCC-only, but the result is real: 1 without launder, 2 with it, under -fstrict-vtable-pointers on clang 21.1.8.

This changes what the GCC results mean. They are not evidence the UB does not matter. They are evidence that GCC, as currently implemented, chooses not to exploit something Clang already can and does the moment that flag is on. -fstrict-vtable-pointers being off by default is a policy decision about breaking existing code today, not a statement about what is safe or what future releases could do.

The Lesson: Unexploited Is Not Correct

Code that skips std::launder where the standard requires it is not correct. It is unexploited. Undefined behavior does not require a compiler to produce a wrong result. It only permits one.

GCC has no default optimization that exploits this. Clang has one, -fstrict-vtable-pointers, and the confirmed Clang results above show it works exactly as expected: the UB produces the wrong answer, std::launder fixes it. The flag is off by default because enabling it breaks existing code at scale, including the named libraries in P3006R1. That is a policy decision about what can ship as a default, not a statement that the optimization is incorrect or that it could not become a default in some future release.

Use std::launder where the standard requires it. It compiles to nothing at runtime. The cost is zero. The protection is against an optimization already implemented, already confirmed to produce wrong results when this rule is violated, and disabled only because the ecosystem is not yet compatible with it being on.

Run: main.cpp

g++ -O2 -std=c++26 main.cpp -o main
./main

Expect all patterns to print correct values with and without std::launder. That agreement is the confirmed expected result.

Run: LTO cross-translation-unit build

g++ -O2 -flto -std=c++26 lib.cpp lto_main.cpp -o lto_test_O2
./lto_test_O2
g++ -O3 -flto -std=c++26 lib.cpp lto_main.cpp -o lto_test_O3
./lto_test_O3

-flto gives the compiler full cross-unit visibility. Expect matching results at both optimization levels.

Run: vtable_strict (Clang only)

clang++ -O2 -fstrict-vtable-pointers -std=c++26 vtable_strict.cpp -o vtable_strict
./vtable_strict
clang++ -O2 -fstrict-vtable-pointers -std=c++26 vtable_strict_fixed.cpp -o vtable_strict_fixed
./vtable_strict_fixed

Requires Clang. Not reproducible with GCC, which has no equivalent flag. Expect vtable_strict to print 1 (wrong — DerivedA’s answer for a DerivedB object) and vtable_strict_fixed to print 2 (correct, after std::launder resets provenance).

Output

$ ./main
float_to_int_example: via reinterpret_cast (UB) = 69, via std::launder = 69
const_member_example: a=1 b=2 c=2
loop_example (no launder): 0 1 2 3 4
virtual_dispatch_example: first=1 no_launder=2 with_launder=2 direct=2

$ ./lto_test_O2
a=1 b=2 c=2

$ ./lto_test_O3
a=1 b=2 c=2

$ clang++ -O2 -fstrict-vtable-pointers -std=c++26 vtable_strict.cpp -o vtable_strict
$ ./vtable_strict
1

$ clang++ -O2 -fstrict-vtable-pointers -std=c++26 vtable_strict_fixed.cpp -o vtable_strict_fixed
$ ./vtable_strict_fixed
2

Every GCC attempt tried Two compiler versions. One result: this UB does not manifest under GCC as currently implemented. Against Clang with -fstrict-vtable-pointers: the unlaundered version printed 1 (wrong), the laundered version printed 2 (correct). The UB is standard-mandated, unexploited by GCC under default flags, and confirmed exploited by Clang under one specific currently-off-by-default flag.

Quick Reference

Coming from other languages

Most languages do not expose pointer provenance at all. Managed languages handle object lifetime automatically, and unmanaged languages like C have similar aliasing rules but the object replacement scenario is less common given C’s lack of placement new and constructors. The specific provenance tracking C++ relies on exists because C++ has deterministic object lifetimes with constructors and destructors, making it possible and useful to place new objects in storage previously occupied by a different object. The rule exists to protect optimizations that cache values based on assumptions about which object is live at a given address. The gap between the rule and current compiler behavior exists because the relevant optimization is not yet enabled by default in any widely used compiler.

The 90% mental model

Pointers carry provenance: which object they came from and when. Destroying an object and constructing a new one in the same storage invalidates pointers derived before the destruction if they were not obtained through the new object’s creation. reinterpret_cast does not restore provenance; it only changes how the pointer bits are interpreted. std::launder(ptr) restores provenance, telling the compiler to treat the returned pointer as referring to whatever object currently occupies that storage. It compiles to nothing at runtime. If the four conditions for transparent object replacement are met (same type, same storage, no const members, no base class subobjects), the original pointer stays valid without laundering. No mainstream compiler currently exploits the UB from skipping std::launder in the non-TOR case, and WG21 paper P3006R1 proposes removing it from the standard for exactly that reason. The UB is still UB until the standard changes.