Как стать автором
Обновить
767.82
OTUS
Цифровые навыки от ведущих экспертов

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

Уровень сложностиПростой
Время на прочтение7 мин
Количество просмотров6.7K

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

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

Теги:
Хабы:
+28
Комментарии12

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS