std::launder: зачем и когда нужен

от автора

Привет, Хабр!

Сегодня разберём мутный, но крайне важный инструмент — 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

__builtin_launder

llvm.launder.invariant_group(ptr) — спец‑intrinsic семейства invariant group

убирает noalias‑метки, бросает vptr из Value‑Tracking, обнуляет Value‑Range, стоимость нулевая, поэтому inliner не колеблется

полностью DCE, в выпуске -O2/-O3 ноль инструкций

GCC 15

__builtin_launder

узел GIMPLE_CALL с флагом CFN_BUILTIN_LAUNDER; в libstdc++ это прямой inline‑шаблон return __builtin_launder(p);

помечается как optimization barrier: в store_motion, DSE, VRP память приравнивается к may‑alias unknown, ранние CSE и DCE сохраняют вызов до финала

на фазе RTL разворачивается в asm("" : "+r"(p)) с memory clobber — т. е. чистый nop

MSVC 19.40

собственный __builtin_launder в C1XX

back‑end тоже переводит в launder.invariant_group; STL просто форвардит вызов

тот же alias‑barrier, плюс отключает ранний devirtualization внутри /O2

убивается оптимизатором, кода нет

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

Функция

Что делает

Когда нужна

std::start_lifetime_as<T>

Начинает lifetime объекта T внутри сырых байт / массива

Десериализация, кастомные аллокаторы

std::launder

Сбрасывает оптимизационные знания о уже живущем объекте

Переконструкция in‑place, смена динамического типа, alias‑edge cases

Т.е порядок действий классический:

  1. start_lifetime_as<T> → загрузили снапшот/выделили арену.

  2. …манипулируем байтами…

  3. 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

Сценарий

Нужно?

Альтернатива

placement new поверх существующего объекта и дальнейший доступ через старый указатель

Да

Перезапись памяти новым объектом, старые указатели гарантированно больше не живут

Можно не ставить

Удалить все старые lvalue

Десериализация байтового blob‑а

Да (C++17/20)

std::start_lifetime_as (C++23)

Punning между trivially‑copyable типами

Нет

std::bit_cast

Просто reinterpret_cast к более широкому типу

Нет

Переписать логику

Стоит ли юзать launder в продакшене?

Коротко: почти никогда.

Если вы не пишете свой optional/variant/контейнер — забудьте. В обычном бизнес‑коде хватает RAII + смарт‑указателей. Ошибиться с launder легко: он не вызывает конструктор, не проверяет выравнивание, не начинает lifetime.

Но знать о нём нужно, чтобы:

  1. Читать чужой low‑level код и не пугаться UB.

  2. Уметь объяснить зачем комьюнити протащило такую деталь сборки.

  3. В редких высокопроизводительных подсистемах (арены, ECS‑движки, custome allocators) выбрать правильный инструмент: start_lifetime_as, bit_cast, launder или plain placement new.


Итоги

std::launder — маленькая функция, закрывающая огромную дыру между моделью памяти стандарта и агрессивным оптимизатором. Она:

  • Инвалидирует прежние предположения компилятора о содержимом адреса.

  • Гарантирует корректный доступ к объекту, чья жизнь была начата «в обход» предыдущего указателя.

  • Не создаёт объект и не решает все проблемы lifetime; в 2023+ за это отвечает std::start_lifetime_as.

Делитесь своим опытом в комментариях.


Если вы когда-либо писали на C++, вы знаете: ошибка, пропущенная на этапе разработки, может аукнуться где угодно — от багрепорта в проде до ночного алерта. Особенно если код собирали в спешке, без времени на рефакторинг или валидацию.

Чтобы таких ситуаций было меньше — и багов, и бессмысленного дебага — загляните на открытые уроки, на которых будут разборы практик и приёмов, которые реально работают в боевом C++-коде:


ссылка на оригинал статьи https://habr.com/ru/articles/914126/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *