Привет, Хабр!
Сегодня разберём мутный, но крайне важный инструмент — 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++ код
Пошаговый рефакторинг: меньше сломанных абстракций, больше читаемого кода и никаких компромиссов с производительностью.
Комментарии (12)
IUIUIUIUIUIUIUI
03.06.2025 17:59Жаль, что
TinyOptional
работать вconstexpr
не будет:ptr()
неconstexpr
, да и placement new нельзя (до С++26, по крайней мере, да и после там есть тонкости, которые этим примером нарушаются).
vityo
03.06.2025 17:59Иногда нужен tinyoptional для atomic, ага.. да тут atomic бывает нужен раз в 5-10 лет..
Да прикольно на самом деле.
Tuxman
03.06.2025 17:59Ваш код
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->~Widget(); auto *q = new (buf) Widget{2, "new"}; std::cout << q->id << '\n'; // И здесь всё хорошо
Это простой "use after destruction" или "stale pointer", пока ничего необычного.
Tuxman
03.06.2025 17:59Вы главное забыли. Статья должна была быть про
strict aliasing, -Wstrict-aliasing=3 вам в помощь, правда при -O2 и выше.
object lifetime violations (ваш std::launder тут спасает, но ваши примеры плохи), тут санитайзеры в помощь.
type-punning (классика UB, особенно через union или reinterpret_cast между несовместимыми структурами)
alignment issues (на x86 пофиг, но я на продакшене словил от более нового gcc movdqu вместо movdqa, хотя они стоят одинаковое количество тактов) -Wcast-align в помощь
ZirakZigil
03.06.2025 17:59Иногда нужен trivial класс, который укладывается в
std::atomic<TinyOptional<T>>
Так а вы свой код-то проверяли? А то нужно уложить в атомик, но требованиям его ваш optional не удовлетворяет.
ixSci
03.06.2025 17:59Очередная статья, которая обещает сорвать все покровы с
std::launder
, но не делает этого.Почему нужен launder в
destroy
? Если клиент сохранил старый указатель и потом плэйсмент‑конструировал новый объект тем же типом поверх него, у компилятора возникнет соблазн оптимизировать повторный деструктор как dead store.Что это вообще значит? Какой соблазн? Какая строка в стандарте позволяет эту оптимизацию? Где ассемблер, показывающий, что какой-то компилятор это делает? Поймите меня правильно, я не утверждаю, что этот абзац некорректен — я не знаю, — но в нём, на мой взгляд, содержится недостаточно информации; просто «trust me, bro».
И цитату я эту взял просто в пример: вся статья такая. На мой взгляд,
std::launder
в большинстве примеров вообще не нужен. Прав я? Не уверен, но статья меня совершенно не убедила в том, что написанное в ней соответствует действительности. В конце концов, можно посмотреть реализациюstd::variant
/`optional` в libc++/libstdc++, и там не будетlaunder
. Совпадение? Не думаю.Uporoty
03.06.2025 17:59Где ассемблер, показывающий, что какой-то компилятор это делает?
Воу-воу, вот с этим полегче. Если стандарт не запрещает такую оптимизацию, то тот факт, что компиляторы сейчас это не делают, вовсе не означает, что так делать можно, потому что нет никаких гарантий что они не начнут делать это в любой момент будущем.
А с остальным согласен.
ixSci
03.06.2025 17:59Воу-воу, вот с этим полегче. Если стандарт не запрещает такую оптимизацию, то тот факт, что компиляторы сейчас это не делают, вовсе не означает, что так делать можно, потому что нет никаких гарантий что они не начнут делать это в любой момент будущем.
Многим людям тяжело читать стандарт, поэтому наличие такой реализации было бы хорошим стартом, потому что такие оптимизации просто так не делают. Если такая реализация есть, то весьма вероятно, она разрешена стандартом. Отсюда и запрос: предоставить хоть какие-то доказательства слов.
Apoheliy
Сарказм:
Ну вы же на С++ пишете! Откуда такое пренебрежительное отношение к Idle?:
Хотя бы минимальную защиту от дурака:
static_assert(sizeof(Idle) <= sizeof(Busy));
если лень с максимальным размером возиться.
Мало ли какие программисты в будущем будут? Захотят в Idle сохранять состояния пяти предыдущих Busy?
---
Не-Сарказм:
За статью спасибо! Эти постирушки ... они такие, что чёрт ногу сломит!