Привет, Хабр!
Сегодня разберём мутный, но крайне важный инструмент — 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++ код
Пошаговый рефакторинг: меньше сломанных абстракций, больше читаемого кода и никаких компромиссов с производительностью.
ссылка на оригинал статьи https://habr.com/ru/articles/914126/
Добавить комментарий