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

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

Комментарии (12)


  1. Apoheliy
    03.06.2025 17:59

    Сарказм:

    Ну вы же на С++ пишете! Откуда такое пренебрежительное отношение к Idle?:

    struct Idle { /* … / };

    struct Busy { int job_id; / … */ };

    ...

    alignas(Busy) std::byte buf[sizeof(Busy)];

    Хотя бы минимальную защиту от дурака:

    static_assert(sizeof(Idle) <= sizeof(Busy));

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

    Мало ли какие программисты в будущем будут? Захотят в Idle сохранять состояния пяти предыдущих Busy?

    ---

    Не-Сарказм:

    За статью спасибо! Эти постирушки ... они такие, что чёрт ногу сломит!


  1. me21
    03.06.2025 17:59

    Насчёт десериализации: что будет, если здесь не применить std::launder?

    const Header* h = std::launder(reinterpret_cast<Header*>(raw));


    1. Nemoumbra
      03.06.2025 17:59

      Если честно, хочется поподробнее все примеры, что будет, если убрать std::launder)))


  1. IUIUIUIUIUIUIUI
    03.06.2025 17:59

    Жаль, что TinyOptional работать в constexpr не будет: ptr() не constexpr, да и placement new нельзя (до С++26, по крайней мере, да и после там есть тонкости, которые этим примером нарушаются).


  1. vityo
    03.06.2025 17:59

    Иногда нужен tinyoptional для atomic, ага.. да тут atomic бывает нужен раз в 5-10 лет..

    Да прикольно на самом деле.


  1. 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", пока ничего необычного.


  1. 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 в помощь


  1. ZirakZigil
    03.06.2025 17:59

    Иногда нужен trivial класс, который укладывается в std::atomic<TinyOptional<T>>

    Так а вы свой код-то проверяли? А то нужно уложить в атомик, но требованиям его ваш optional не удовлетворяет.


  1. ixSci
    03.06.2025 17:59

    Очередная статья, которая обещает сорвать все покровы с std::launder, но не делает этого.

    Почему нужен launder в destroyЕсли клиент сохранил старый указатель и потом плэйс­мен­т‑конструировал новый объект тем же типом поверх него, у компилятора возникнет соблазн оптимизировать повторный деструктор как dead store.

    Что это вообще значит? Какой соблазн? Какая строка в стандарте позволяет эту оптимизацию? Где ассемблер, показывающий, что какой-то компилятор это делает? Поймите меня правильно, я не утверждаю, что этот абзац некорректен — я не знаю, — но в нём, на мой взгляд, содержится недостаточно информации; просто «trust me, bro».

    И цитату я эту взял просто в пример: вся статья такая. На мой взгляд, std::launder в большинстве примеров вообще не нужен. Прав я? Не уверен, но статья меня совершенно не убедила в том, что написанное в ней соответствует действительности. В конце концов, можно посмотреть реализацию std::variant/`optional` в libc++/libstdc++, и там не будет launder. Совпадение? Не думаю.


    1. Uporoty
      03.06.2025 17:59

      Где ассемблер, показывающий, что какой-то компилятор это делает?

      Воу-воу, вот с этим полегче. Если стандарт не запрещает такую оптимизацию, то тот факт, что компиляторы сейчас это не делают, вовсе не означает, что так делать можно, потому что нет никаких гарантий что они не начнут делать это в любой момент будущем.

      А с остальным согласен.


      1. ixSci
        03.06.2025 17:59

        Воу-воу, вот с этим полегче. Если стандарт не запрещает такую оптимизацию, то тот факт, что компиляторы сейчас это не делают, вовсе не означает, что так делать можно, потому что нет никаких гарантий что они не начнут делать это в любой момент будущем.

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


        1. Siemargl
          03.06.2025 17:59

          Как будто ассемблер легче читать чем стандарт =)