Привет, Хабр!
Сегодня разберём мутный, но крайне важный инструмент — std::launder
. Мы поглядим, зачем его протащили в C++17 и что компилятор делает, когда видит launder.
Немного истории
До C++03 стандарт утешал нас иллюзией: если вы вызвали placement new
поверх старого объекта того же типа, старый указатель волшебно начинает указывать на новый объект. В 2004-м в стандарт вкрался пункт, запрещающий такое перерождение, если тип имел const
‑поля или был сабобъектом внутри другого типа. Но по факту около 40% placement‑конструкций в их кодовой базе игнорировали это правило и формально были Undefined Behavior.
Пришлось срочно латать дыру, не переписывая пол‑интернета. Так появился std::launder
— стационар в психбольнице для указателя: заходит грязным, выходит чистым и с новой историей.
Кейс: переконструкция объекта in-place
Без launder — классическое UB
struct Widget {
const int id;
std::string name;
};
alignas(Widget) std::byte buf[sizeof(Widget)];
auto *p = new (buf) Widget{1, "old"};
p->~Widget();
new (buf) Widget{2, "new"};
std::cout << p->id << '\n'; // UB: p указывает на покойника
Старый p
помнит старый объект. Компилятор вправе закэшировать id == 1
навсегда.
С launder все адекватней
auto *q = std::launder(reinterpret_cast<Widget*>(buf));
std::cout << q->id << '\n'; // 2, жизнь удалась
std::launder
формально не создаёт объект, а дает доступ к уже живущему объекту; его lifetime должен быть начат до стирки, это важно.
Что делает компилятор
Компилятор / версия | Как распознаёт | Что кладёт в IR / GIMPLE | Как это влияет на оптимизации | Что остаётся в asm |
---|---|---|---|---|
Clang 18 + LLVM 18 |
|
| убирает noalias‑метки, бросает vptr из Value‑Tracking, обнуляет Value‑Range, стоимость нулевая, поэтому inliner не колеблется | полностью DCE, в выпуске |
GCC 15 |
| узел | помечается как optimization barrier: в | на фазе RTL разворачивается в |
MSVC 19.40 | собственный | back‑end тоже переводит в | тот же alias‑barrier, плюс отключает ранний devirtualization внутри | убивается оптимизатором, кода нет |
Alias-barrier: что именно «забывается»
Value Range — константные значения const
‑полей, которые могли быть закэшированы в VRP
/GVN
.
Type‑based AA — привязка «lvalue — объект» разрывается; все последующие загрузки обязаны идти в память.
Devirtualization — если вы placement new
‑ом заменили объект с виртуальными методами, старый vptr
больше не легитимен; launder
принудительно откатывает результаты Devirt
‑pass.
Capture Tracking — LLVM‑intrinsic помечен HasUnknownCapture
, поэтому -flto
не вырезает его даже при межмодульном анализе.
Поведение в constexpr-контексте
Почти все фронтенды компилируются так:
constexpr int f() {
alignas(int) std::byte buf[sizeof(int)];
// new не вызвали → lifetime не начат
return *std::launder(reinterpret_cast<int*>(buf)); // hard error
}
__builtin_launder
в Clang ≥ 17 и GCC > 13 помечен как Immediate Invocation, т. е. проверяется ещё до константного фолдинга: если объекта нет — diagnostic на этапе semantic analysis. А если lifetime уже начат (например, через new
или std::start_lifetime_as
), код спокойно вычисляется в constexpr
.
launder ≠ std::start_lifetime_as
Функция | Что делает | Когда нужна |
---|---|---|
| Начинает lifetime объекта T внутри сырых байт / массива | Десериализация, кастомные аллокаторы |
| Сбрасывает оптимизационные знания о уже живущем объекте | Переконструкция in‑place, смена динамического типа, alias‑edge cases |
Т.е порядок действий классический:
start_lifetime_as<T>
→ загрузили снапшот/выделили арену.…манипулируем байтами…
std::launder
→ обращаемся к объекту и гарантируем, что оптимизатор не смотрит в прошлое.
Как проверить, что барьер действительно работает
Godbolt: сравните
-O3
дампы с и без launder — пропадёт ли константноеmov $1, %eax
при обращении кconst
‑полю.LLVM opt‐pipeline: после
-passes=devirt,gvn
вызов intrinsic, всё ещё на месте → значит, alias‑fence отработал.GCC: запустите
-fdump-tree-optimized
— увидите, что__builtin_launder
остаётся вплоть до RTL, а затем исчезает.
Практика
Итак, посмотрим, где можно юзать все это дело.
TinyOptional 2.0: с поддержкой перемещений и constexpr
template<class T>
class TinyOptional {
static constexpr std::size_t N = sizeof(T);
alignas(T) std::byte storage[N];
bool engaged = false;
T* ptr() noexcept {
return std::launder(reinterpret_cast<T*>(storage));
}
public:
constexpr TinyOptional() noexcept = default;
constexpr TinyOptional(const TinyOptional& rhs)
requires std::is_copy_constructible_v<T>
{
if (rhs.engaged) emplace(*rhs.ptr());
}
constexpr TinyOptional(TinyOptional&& rhs) noexcept
requires std::is_move_constructible_v<T>
{
if (rhs.engaged) emplace(std::move(*rhs.ptr()));
}
template<class... Args>
constexpr T& emplace(Args&&... args) {
reset();
::new (storage) T(std::forward<Args>(args)...);
engaged = true;
return *ptr();
}
constexpr void reset() noexcept {
if (engaged) {
std::destroy_at(ptr()); // C++20 helper
engaged = false;
}
}
constexpr explicit operator bool() const noexcept { return engaged; }
constexpr T& value() & {
if (!engaged) throw std::bad_optional_access{};
return *ptr(); // UB-safe: launder внутри
}
constexpr ~TinyOptional() { reset(); }
};
Без стирки value()
нарушает [basic.life]
: если у T
есть const
‑поля или он ― сабобъект, оптимизатор вправе считать, что указатель до переконструкции и после — тот же объект.
Почему не std::optional
? Иногда нужен trivial класс, который укладывается в std::atomic<TinyOptional<T>>
или живёт в шёрстке ядра драйвера, где нельзя тащить тяжёлый <optional>
.
Арена «all-in-one и ни шагу назад»
class LinearArena {
static constexpr std::size_t CAP = 4'096;
alignas(std::max_align_t) std::byte mem[CAP];
std::size_t head = 0;
public:
template<class T, class... Args>
requires (std::alignof_v(T) <= alignof(std::max_align_t))
T* make(Args&&... args) {
if (head + sizeof(T) > CAP) throw std::bad_alloc{};
void* here = mem + head;
head += sizeof(T);
return ::new (here) T(std::forward<Args>(args)...);
}
template<class T>
void destroy(T* obj) noexcept {
// «Стирка» рвёт alias-связи, чтобы оптимизатор не выкидывал dtor
std::destroy_at(std::launder(obj));
// head не откатываем: арена линейная, можно сбросить целиком
}
void reset() noexcept { head = 0; } // mass-free
};
Почему нужен launder в destroy
? Если клиент сохранил старый указатель и потом плэйсмент‑конструировал новый объект тем же типом поверх него, у компилятора возникнет соблазн оптимизировать повторный деструктор как dead store.
Фриз-снапшот / «холодная» десериализация
struct Header { std::uint32_t magic; std::uint32_t size; };
struct Payload { std::array<char, 64> data; };
auto blob = read_file("snapshot.bin"); // raw bytes
auto* raw = blob.data();
const Header* h = std::launder(reinterpret_cast<Header*>(raw));
if (h->magic != 0xDEADBEEF) throw BadFormat{};
const Payload* body = std::launder(
reinterpret_cast<Payload*>(raw + sizeof(Header)));
process_payload(*body);
Почему не std::bit_cast
? Мы не просто копируем биты: нам нужен живой объект со всеми конструкторскими инвариантами.
А что с alignment? Формат задаёт alignas(Header)
и alignas(Payload)
; если файл записан на другой платформе — проверяем.
C++23-версия. Более формально корректно:
auto* h = std::start_lifetime_as<Header>(raw);
auto* body = std::start_lifetime_as<Payload>(raw + sizeof(Header));
а launder
уже не нужен: lifetime начат правильным инструментом.
Variant-light
enum class StateTag { Idle, Busy };
struct Idle { /* … */ };
struct Busy { int job_id; /* … */ };
struct FSM {
StateTag tag = StateTag::Idle;
alignas(Busy) std::byte buf[sizeof(Busy)];
Idle* idle() { return std::launder(reinterpret_cast<Idle*>(buf)); }
Busy* busy() { return std::launder(reinterpret_cast<Busy*>(buf)); }
void enter_idle() {
if (tag == StateTag::Busy) std::destroy_at(busy());
::new (buf) Idle{};
tag = StateTag::Idle;
}
void enter_busy(int id) {
if (tag == StateTag::Busy) std::destroy_at(busy());
::new (buf) Busy{id};
tag = StateTag::Busy;
}
};
std::variant
здесь бы дал лишние 16 Б на тег + vtable‑подобную надбавку, а нам нужна компактность. При многократном переходе Busy
ту Busy
оптимизатор не кэширует старое job_id
.
Когда точно ставить launder
Сценарий | Нужно? | Альтернатива |
---|---|---|
| Да | — |
Перезапись памяти новым объектом, старые указатели гарантированно больше не живут | Можно не ставить | Удалить все старые lvalue |
Десериализация байтового blob‑а | Да (C++17/20) |
|
Punning между trivially‑copyable типами | Нет |
|
Просто | Нет | Переписать логику |
Стоит ли юзать launder в продакшене?
Коротко: почти никогда.
Если вы не пишете свой optional
/variant
/контейнер — забудьте. В обычном бизнес‑коде хватает RAII + смарт‑указателей. Ошибиться с launder легко: он не вызывает конструктор, не проверяет выравнивание, не начинает lifetime.
Но знать о нём нужно, чтобы:
Читать чужой low‑level код и не пугаться UB.
Уметь объяснить зачем комьюнити протащило такую деталь сборки.
В редких высокопроизводительных подсистемах (арены, ECS‑движки, custome allocators) выбрать правильный инструмент:
start_lifetime_as
,bit_cast
, launder или plain placement new.
Итоги
std::launder
— маленькая функция, закрывающая огромную дыру между моделью памяти стандарта и агрессивным оптимизатором. Она:
Инвалидирует прежние предположения компилятора о содержимом адреса.
Гарантирует корректный доступ к объекту, чья жизнь была начата «в обход» предыдущего указателя.
Не создаёт объект и не решает все проблемы lifetime; в 2023+ за это отвечает
std::start_lifetime_as
.
Делитесь своим опытом в комментариях.
Если вы когда-либо писали на C++, вы знаете: ошибка, пропущенная на этапе разработки, может аукнуться где угодно — от багрепорта в проде до ночного алерта. Особенно если код собирали в спешке, без времени на рефакторинг или валидацию.
Чтобы таких ситуаций было меньше — и багов, и бессмысленного дебага — загляните на открытые уроки, на которых будут разборы практик и приёмов, которые реально работают в боевом C++-коде:
9 июня в 20:00
Отлаживаем C++: от printf до asan и зеленых тестов
Разберёмся, как системно находить баги, где помогает core dump, когда стоит подключать valgrind и почему assert не устарел.19 июня в 20:00
Разделяй и абстрагируй: как создавать понятный C++ код
Пошаговый рефакторинг: меньше сломанных абстракций, больше читаемого кода и никаких компромиссов с производительностью.