Про C++ часто шутят, что любую вещь можно сделать пятью разными путями, четыре из которых компилируются, три работают, а два правильные, но один зависит от фазы луны. Часто такие шутки и идиомы откладываются в коллективной памяти сообщества какой именно из этих путей правильный в каждой конкретной ситуации.

Большинство этих примеров родилось в эпоху до C++11, когда у языка ещё не было ни умных указателей в стандарте, ни move-семантики, ни constexpr, ни концептов, и приходилось руками собирать из шаблонов и перегрузок некоторые конструкции, которые в более поздних стандартах язык даёт почти бесплатно. Многие идиомы, примеры и идеи стоит читать в двух смыслах сразу, как исторический артефакт, объясняющий «почему старый код выглядит вот так», и как живой приём, который всё ещё применяется в движках и играх.

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

Когда я собирал оглавление Game++, раздел про идиомы, идеи, паттерны и механизмы C++ планировался шестым и завершающим, и должен был занять страниц сто, по одной на каждый пункт, но чем дальше я собирал материал, тем яснее становилось, что каждая секция тянет за собой историю, а каждая история требует контекста, а каждый контекст в игрострое никогда не бывает простым. В итоге текст разросся до размеров, при которых он просто сломал бы структуру книги, и мне пришлось выбирать между «урезать до неузнаваемости» и «отпустить жить отдельно». Пришлось выбрать второе.

Перед вами то, что могло бы стать половиной Game++, но стало самостоятельным материалом. Здесь собраны идиомы, идеи, паттерны и механизмы C++, которые сложились в сообществе за несколько десятилетий и продолжают жить в кодовых базах игровых движков, иногда под своими именами, иногда под другими, иногда вообще без имён, потому что их давно перестали объяснять. У большинства имена все же есть, есть и история с ответом почему именно так, а не иначе.

Вероятно вам потребуется вспомнить некоторые правила вывода шаблонов, и что такое конструктор и деструктор, чем стек отличается от кучи, что у объекта есть время жизни, но это не точно. Я также намеренно убрал обработку краевых случаев и разные проверки из кода, которые в реальном коде заняли бы половину всего текста и утопили бы саму мысль в деталях. Паттерн механизма в моем случае важнее конкретной реализации, и почти каждая из них существует в десятке вариаций под разные компиляторы и стандарты.


Содержание

RAII (Resource Acquisition Is Initialization)

RAII, пожалуй, самая фундаментальная идиома C++, и одновременно хуже всех осознаваемая новичками, потому что её не видно. Идея в том, что захват любого ресурса (памяти, файла, мьютекса, дескриптора текстуры, GPU-фенса) привязывается к конструктору объекта, а освобождение, что логично, к деструктору. Дальше язык сам гарантирует, что когда объект покидает область видимости, деструктор будет вызван, как бы вы оттуда ни вышли, через нормальным return, с исключением посреди функции или break из середины цикла.

Главная ценность тут не в том, что вы не забудете написать free или unlock, хотя и в этом тоже, а что освобождение становится корректным при исключениях. Без RAII любой throw между захватом и ручным освобождением будет утечкой, и в коде, который кидает исключения из десятка мест, отследить все пути выхода вручную физически сложно, так что RAII перекладывает всю эту бухгалтерию на компилятор, который раскручивает стек и зовёт деструкторы в строго обратном порядке конструирования.

Подводный камень в том, что RAII работает ровно настолько хорошо, насколько корректно написан деструктор, и кидать исключения из деструктора в C++ это все равно билет в один конец к станцииstd::terminate. Поэтому RAII-обёртки обязаны освобождать ресурс без права на ошибку, а вся логика, которая может упасть, должна жить в обычных методах.

Термин ввёл Бьёрни Страуструп ещё на заре языка, изначально думая в основном про память и время жизни, и название получилось, по его же признанию, не самым удачным, потому что «захват ресурса есть инициализация» подчёркивает половину идеи (захват в конструкторе) и совсем не подчёркивает вторую, гораздо более важную (освобождение в деструкторе).

Со временем появились более выразительные имена для частных случаев: Scoped Locking для мьютексов в работах Дугласа Шмидта или Execute-Around Object для обёрток, которые делают что-то на входе и что-то на выходе из области видимости.

RAII
class ScopedTextureBind {
public:
    explicit ScopedTextureBind(GLuint texture) {
        glBindTexture(GL_TEXTURE_2D, texture);
    }
    ~ScopedTextureBind() {
        glBindTexture(GL_TEXTURE_2D, 0);  // освобождаем что бы ни случилось
    }
    ScopedTextureBind(const ScopedTextureBind&) = delete;
    ScopedTextureBind& operator=(const ScopedTextureBind&) = delete;
};

void draw_hud() {
    ScopedTextureBind bind(hud_atlas);
    submit_quads();   // даже если тут вылетит исключение,
                      // текстура будет отвязана при выходе
}

В играх RAII живёт буквально везде, но часто под другими именами. Любой ScopedTimer, который меряет, сколько занял кусок кадра, и пишет результат в профайлер в деструкторе тоже будет RAII. Любой RenderPassScope, открывающий render pass в конструкторе и закрывающий в деструкторе, тоже будет RAII. Командные буферы, привязки состояния GPU, блокировки на время доступа к стримингу ресурсов из фонового потока: всё это обёртки, которые гарантируют симметрию «открыл/закрыл» даже когда логика внутри кадра ветвится неисповедимой программерской мыслью.

Scope Guard

Это RAII, доведённый до логического олимпа, когда вместо того чтобы писать отдельный класс-обёртку под каждый ресурс, вы создаёте универсальный объект, которому в конструкторе передаёте произвольное действие (лямбду, функтор, указатель на функцию), и это действие выполняется в деструкторе. Получается «отложенный код», который гарантированно сработает при выходе из области видимости.

Есть возможность «обезвредить» guard вызовом чего-то вроде dismiss(), если операция в итоге прошла успешно и откатывать ничего не надо, что превращает Scope Guard в идеальный инструмент для транзакционной логики. Теперь можно делать шаг, ставить guard на его откат, делать следующий шаг, и если в конце всё хорошо, то снимать все guard-ы, а если посреди что-то упало, деструкторы откатят уже сделанные шаги в обратном порядке.

Идиому популяризировали Александреску и Маргинин в статьях начала 2000-х, и оттуда пошло название ScopeGuard. Позже Александреску переосмыслил её с учётом move-семантики и в C++11 представил SCOPE_EXIT/SCOPE_FAIL/SCOPE_SUCCESS как варианты, срабатывающие соответственно всегда, только при исключении и только при нормальном выходе. В современном виде это живёт в библиотеке folly от Facebook и просочилось в std::experimental::scope_exit.

Скрытый текст
template <class F>
class ScopeGuard {
    F func_;
    bool active_ = true;
public:
    explicit ScopeGuard(F f) : func_(std::move(f)) {}
    ~ScopeGuard() { if (active_) func_(); }
    void dismiss() { active_ = false; }
    ScopeGuard(ScopeGuard&& o) : func_(std::move(o.func_)), active_(o.active_) { 
      o.dismiss(); 
    }
};

template <class F>
ScopeGuard<F> make_guard(F f) { return ScopeGuard<F>(std::move(f)); }

void load_level(Level& lvl) {
    auto* mem = pool.allocate(lvl.size);
    auto cleanup = make_guard([&]{ pool.free(mem); });  // откат по умолчанию

    decompress_into(mem, lvl.archive);
    register_resources(mem);
    cleanup.dismiss();   // дошли до конца, память остаётся за уровнем
}

Scope Guard незаменим там, где загрузка ресурса состоит из нескольких шагов, каждый из которых может провалиться, например, выделили память в пуле, начали декомпрессию, зарегистрировали хендлы в менеджере ресурсов. Если декомпрессия упала на середине, нужно вернуть память в пул и снять уже зарегистрированные хендлы, иначе пул потечёт, а менеджер ресурсов будет ссылаться на мусор.

Или тулчейны и редакторы уровней, потому что там операций с откатом ещё больше. Импорт ассета, который трогает несколько систем сразу, должен либо примениться целиком, либо не оставить следов своей работы. Scope Guard позволяет писать такую транзакционную логику линейно, сверху вниз, без вложенных «лестниц очистки», что резко снижает количество багов.

Resource Return

Идиома про то, как функция отдаёт наружу владение ресурсом, не давая вызывающему коду случайно его потерять. В наивном варианте функция возвращает сырой указатель (Texture* load_texture(...)), и дальше начинается лотерея, если вызывающий забыть удалить, или удалил дважды, или вовсе потерял указатель в промежуточной переменной при исключении. Resource Return требует возвращть не сырой ресурс, а объект, который сам знает, как этот ресурс освободить.

В классическом до-C++11 виде это означало возврат по значению объекта-владельца с продуманной семантикой копирования (часто через auto_ptr, со всеми его странностями передачи владения при копировании). Идея была в том, чтобы сделать невозможным сценарий «получил ресурс и потерял его». С приходом C++11 идиома почти полностью растворилась в языке в виде std::unique_ptr или std::shared_ptr, а move-семантика гарантирует, что владение передаётся без копий и потерь.

То, что раньше было осознанной идиомой с тонкостями, стало настолько естественным, что современный программист даже не думает об этом как о приёме RR, а просто пишет функцию, возвращающую умный указатель. Исторически Resource Return вырос из проблем с std::auto_ptr девяностых годов, который пытался решить эту задачу, но из-за «копирования, которое на самом деле перемещение» создавал столько ловушек, что его в итоге признали ошибкой дизайна и удалили из стандарта. Move-семантика C++11 это, по сути, правильно сделанный auto_ptr, и идиома Resource Return наконец дождалась, что язык её догнал.

Скрытый текст
// возвращает владение явно и безопасно
std::unique_ptr<Mesh> load_mesh(const std::string& path) {
    auto mesh = std::make_unique<Mesh>();
    mesh->upload(read_vertices(path));
    return mesh;            // move, ни одной лишней копии буфера вершин
}

void build_scene(Scene& scene) {
    scene.add(load_mesh("rock.obj"));   // владение перетекает в сцену
    // потерять меш физически негде
    // либо он в scene, либо в temp, который мувнулся
}

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

На практике многие движки идут дальше и возвращают даже не указатель, а лёгкий хендл-значение (что-то вроде TextureHandle с индексом и поколением), но идея остаётся той же самой и наружу отдаётся не голый ресурс, который легко потерять, а объект с понятной семантикой владения и временем жизни.

Copy-and-swap

Это идиома для написания оператора присваивания, который корректно работает при исключениях и заодно решает проблему самоприсваивания, не требуя его явной проверки. Суть в том, чтобы сделать локальную копию правого операнда, обменять её внутренности с *this через swap, и дать деструктору локальной копии унести старое содержимое. Если копирование упадёт с исключением, то оно упадёт до того, как мы тронули *this, и объект останется в исходном валидном состоянии.

Идиома бесплатно даёт сильную гарантию исключений (strong exception guarantee), когда присваивание либо прошло целиком, либо объект не изменился вообще, а промежуточного «полуразрушенного» состояния не существует. И всё это без дублирования логики освобождения старых ресурсов вручную.

Цена за это лишняя копия. Copy-and-swap всегда делает полную копию, даже когда можно было бы обойтись переприсваиванием полей на месте, и в хотпасе это иногда неприемлемо, поэтому в C++11 идиому часто дополняют, передавая параметр по значению, тогда для lvalue срабатывает копия, а для rvalue будет перемещение, и один оператор operator=(T value) покрывает и копирующее, и перемещающее присваивание сразу.

Идиома кристаллизовалась в сообществе на рубеже двухтысячных, её активно разбирал Херб Саттер в Exceptional C++ (1999) в контексте exception safety, и она прочно вошла в канон как «правильный способ писать operator=». Само сочетание «copy and swap» как устойчивое название закрепилось чуть позже, во многом благодаря обсуждениям на Stack Overflow.

Скрытый текст
class Buffer {
    std::size_t size_ = 0;
    float* data_ = nullptr;
public:
    friend void swap(Buffer& a, Buffer& b) noexcept {
        std::swap(a.size_, b.size_);
        std::swap(a.data_, b.data_);
    }
    Buffer(const Buffer& o) : size_(o.size_), data_(new float[o.size_]) {
        std::copy(o.data_, o.data_ + size_, data_);
    }
    // by-value параметр: ловит и copy, и move
    Buffer& operator=(Buffer other) noexcept {
        swap(*this, other);
        return *this;             // самоприсваивание корректно само собой
    }
    ~Buffer() { delete[] data_; }
};

В разработке игр copy-and-swap встречается редко, потому что лишние копии часто непозволительны и предпочитается move, но инструменты, редакторы и сериализация, где важнее корректность могут её тащить к себе. Классический сценарий это загрузка конфигурации или ассета поверх уже существующего, когда вы строите новый объект целиком, и только если он построился успешно, подменяете им старый через swap.

Это даёт редакторам свойство «горячей перезагрузки» ресурсов, и пока новый материал или шейдер грузится и парсится, рабочая копия остаётся нетронутой, а если парсинг упадёт на битом файле, то игра продолжит крутиться на старой версии, вместо того чтобы остаться с наполовину перезаписанным объектом или скрашиться.

Non-throwing swap

Это уже и требование и идиома одновременно. swap для вашего типа обязан быть noexcept, то есть никогда не кидать исключений, что, возможно, звучит как мелкая техническая деталь, но на ней держится половина гарантий безопасности в C++, включая только что разобранный copy-and-swap, который без noexcept-свопа теряет свою сильную сторону.

Реализуется идиома через обмен указателей и примитивных полей, а не через копирование содержимого. Теперь полагаясь на этот контракт мы можем менять местами два указателя, не провоцируя аллокацию или исключение, потому что обмен указателей это операция, которая просто не способна упасть. Правильный swap для класса с динамической памятью никогда не копирует буферы, а только перекидывает владение ими между объектами.

Обычно стандарт предписывает определять свободную функцию swap в том же namespace, что и тип (чтобы её нашёл ADL), и вызывать её через паттерн using std::swap; swap(a, b);. Это позволяет стандартным контейнерам и алгоритмам подхватывать ваш swap вместо дефолтного, который делает три копии и потому и медленный.

Важность non-throwing swap осознали после работ Скотта Майерса, который посвятил этому отдельный пункт в Effective C++, настаивая на специализации swap. В C++11 это формализовали через move-конструктор и move-присваивание, помеченные noexcept, позволяют контейнерам вроде std::vector при перевыделении перемещать элементы вместо копирования.

Скрытый текст
class Image {
    int w_ = 0, h_ = 0;
    std::uint8_t* pixels_ = nullptr;
public:
    // обмен указателей не может кинуть, noexcept
    friend void swap(Image& a, Image& b) noexcept {
        using std::swap;
        swap(a.w_, b.w_);
        swap(a.h_, b.h_);
        swap(a.pixels_, b.pixels_);
    }
};

// Где-то в контейнере движка
std::vector<Image> textures;
textures.reserve(1024);   // благодаря noexcept-move вектор перемещает

Стандартные контейнеры: std::vector и игровые сущности при росте и перевыделении должны перемещать элементы, а не копировать, и сделать это безопасно он может, только если ваш move/swap помечены noexcept. Забыли noexcept и получаете копирование, превращая невинный push_back в источник лишних аллокаций и просадок на ровном месте.

Это один из тех случаев, когда крошечная аннотация на типе компонента меняет производительность всей системы, которая этим типом оперирует. В ECS-архитектурах, где сущности и компоненты постоянно перекладываются между пулами и пересортировываются для лучшей локальности кеша, дешёвый non-throwing swap становится фундаментом всей логики.

Smart Pointer

Умный указатель ведёт себя как обычный указатель (и его можно разыменовать через * и ->), но при этом управляет временем жизни того, на что указывает. Это прямое применение RAII к самому распространённому ресурсу, т.е. памяти из кучи. Теперь вместо того чтобы помнить о парном delete к каждому new, вы заворачиваете указатель в объект, чей деструктор сделает delete автоматически.

Семейство умных указателей делится по политике владения. unique_ptr как единоличный владелец, который нельзя копировать, только перемещать, и потому он бесплатен по накладным расходам (это просто указатель с правильным деструктором). shared_ptr уже создает разделяемое владение через счётчик ссылок, который освобождает объект, когда последний владелец умирает. weak_ptr всего лишь наблюдатель, который видит объект, но не продлевает ему жизнь, и нужен для разрыва циклических ссылок, в которых два shared_ptr могут держать друг друга и оба не умрут никогда.

Подводный камень shared_ptr, как обычно, его цена. Счётчик ссылок атомарный, потому что shared_ptr обязан быть потокобезопасным по части подсчёта, и каждое копирование становится атомарным инкрементом, а каждое уничтожение, соответственно атомарным декрементом с проверкой. В хотпасах указатели могут копировать десятками тысяч в кадр, и это все выливается в заметные потери, поэтому в движках shared_ptr стараются передавать по ссылке и не плодить копии без нужды.

Идиома прошла долгий путь от злополучного std::auto_ptr из конца девяностых с его «копированием-перемещением», переродившись в Boost'е в начале двухтысячных в видеscoped_ptr, shared_ptr и weak_ptr , и именно эти реализации, отшлифованные годами применения, в C++11 уже вошли в стандарт. Ключевой вклад в дизайн внесли Грег Колвин, Беман Доус и Питер Димов, запомните этих людей, мы к ним еще вернемся.

Скрытый текст
struct AudioClip { /* ... */ };

class SoundBank {
    std::unordered_map<std::string, std::shared_ptr<AudioClip>> clips_;
public:
    std::shared_ptr<AudioClip> get(const std::string& name) {
        auto& slot = clips_[name];
        if (!slot) 
            slot = std::make_shared<AudioClip>(load_from_disk(name));
        return slot;   // вызывающий разделяет владение с банком
    }
};

// unique_ptr и единоличное владение, ноль накладных расходов
std::unique_ptr<ParticleSystem> ps = std::make_unique<ParticleSystem>(1024);

В играх отношение к умным указателям прохладно-прагматичное и, например,unique_ptr любят и используют свободно, потому что он бесплатен и выражает владение, а вот shared_ptr многие движки сознательно ограничивают или вовсе запрещают из-за атомарных счётчиков и из-за того, что разделяемое владение размывает понимание, кто на самом деле управляет временем жизни объекта, что в больших кодовых базах превращается в источник трудноуловимых утечек.

Вместо повсеместного shared_ptr движки часто используют хендлы с поколениями, интрузивные счётчики или пулы с явным временем жизни, но для редко уничтожаемых, синглтонов, разделяемых ресурсов, загруженных текстур или сетевых сессий shared_ptr остаётся уместным, и компромисс «немного атомарных операций ради простоты владения» здесь обычно оправдан.

Checked delete

Крошечная идиома, решающая проблему что можно легально написать delete p, когда p указывает на тип, объявленный через forward declaration, но не определённый в этой точке. Компилятор не видит полного определения, не знает, есть ли у типа деструктор, и просто удаляет память, не вызвав деструктор, что в лучшем случае вызовет предупреждение компилятора, которое легко пропустить, и в итоге получить утечку ресурсов.

Идиома лечит это, заставляя delete происходить только там, где тип полностью определён. Делается это через проверку, которая не скомпилируется для неполного типа. Берётся sizeof(T), который для неполного типа равен ошибке компиляции, и результат скармливается в фиктивный массив или статическое утверждение. Если тип неполный, то сборка падает с понятной ошибкой в точке удаления. Без checked delete unique_ptr на неполном типе мог бы тихо «удалить» объект, не вызвав деструктор, и вся идея RAII рассыпалась бы в самом неожиданном месте, поэтому стандартные deleter построены вокруг этой проверки.

Идиому ввёл Boost в начале двухтысячных через функции boost::checked_delete и boost::checked_array_delete, которые появились как защитный слой внутри их умных указателей, и оттуда же мысль перекочевала в требования стандарта к unique_ptr и shared_ptr, которые обязаны диагностировать удаление неполного типа в определённых сценариях.

Скрытый текст
template <class T>
inline void checked_delete(T* p) {
    // если T неполный, sizeof не скомпилируется
    using complete = char[sizeof(T) ? 1 : -1];
    (void) sizeof(complete);
    delete p;
}

Это особенно актуально на границах модулей и в Pimpl-обёртках, где заголовки специально держат типы неполными, чтобы сократить зависимости компиляции и ускорить сборку (а время сборки большого движка это отдельная боль, измеряемая часами). Именно там легче всего случайно удалить неполный тип, и именно там checked delete отлавливает ошибку на этапе компиляции.

На практике современный код почти не пишет checked delete руками и его за вас уже делают стандартные умные указатели, но понимать, почему unique_ptr на Pimpl-типе требует, чтобы деструктор класса был определён в .cpp, где тип полный, без знания этой идиомы невозможно.

Intrusive reference counting

Разделяемое владение, в котором счётчик ссылок живёт не в отдельном управляющем блоке рядом с объектом, как у shared_ptr, а прямо внутри самого объекта. Объект сам носит в себе поле-счётчик, а умный указатель лишь инкрементирует и декрементирует это встроенное поле. Когда счётчик падает до нуля, объект удаляет сам себя.

Главное преимущество перед shared_ptr компактность и эффективность. У shared_ptr есть отдельный управляющий блок, а значит две аллокации (объект и блок) при создании попадают в разные области памяти, и надо между ними прыгать, чтобы получить сам счетчик и отдельно данные.

У интрузивного счётчика всё в одном объекте, получаем одну аллокацию и фактически один указатель. Расплата за это будет вторжением в сам тип, отсюда и название «intrusive», теперь объект обязан заранее знать, что им будут владеть по счётчику, и носить счётчик в себе, а значит вы не можете обернуть в такой указатель чужой класс из сторонней библиотеки, который про счётчик ничего не знает. Это связывает дизайн типа с политикой владения, и для одних объектов это нормально, а для других будет неприемлемо.

Идиома очень старая и имя «Counted Body» дал ей Джеймс Коплин в книге Advanced C++ Programming Styles and Idioms (1992), где разбирал её вместе с родственной идиомой Handle/Body. То есть интрузивный счётчик старше самого shared_ptr почти на десятилетие, и долгие годы это был основной способ делать разделяемое владение в C++. Позже Boost оформил его в intrusive_ptr, а Microsoft построила вокруг той же идеи COM с его AddRef/Release.

Скрытый текст
class RefCounted {
    mutable std::atomic<int> refs_{0};
public:
    void add_ref() const { refs_.fetch_add(1, std::memory_order_relaxed); }
    void release() const {
        if (refs_.fetch_sub(1, std::memory_order_acq_rel) == 1) delete this;
    }
    virtual ~RefCounted() = default;
};

class Texture : public RefCounted { /* пиксели, формат, mip-уровни */ };

// intrusive_ptr вызывает add_ref/release, счётчик живёт в самом Texture
boost::intrusive_ptr<Texture> tex(load_texture("wall.png"));

В геймдеве интрузивные счетчики основная рабочая лошадка управления ресурсами, и ресурсы вроде текстур, мешей и шейдеров и так являются «тяжёлыми» объектами, которым не жалко носить четыре байта счётчика, зато экономия на аллокациях и кеш-промахах при их шаринге между сотнями материалов и сущностей вполне ощутима. Базовый класс с add_ref/release можно увидеть как типичный паттерн в рендер-движках.

Весь DirectX и COM, на котором стоит графический стек Windows это интрузивный объект с AddRef/Release, и обёртки вроде ComPtr из WRL это intrusive_ptr под другим именем. Unreal Engine со своими TRefCountPtr и FRefCountedObject делает ровно то же самое.

Copy-on-write

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

Идея соблазнительна, чтобы получить дешёвое копирование «по требованию», обещает экономию памяти на одинаковых данных, прозрачность для пользователя, но внутри прячется разделяемый буфер с счётчиком ссылок, а каждый изменяющий метод начинается с проверки «а я единственный владелец? если нет, то отделюсь».

Подводных камней у COW столько, что от неё во многих фреймворках отказались. Любая мутирующая операция платит за проверку уникальности, и в однопоточном коде мы получаем оверхед на пустом месте. А еще она коварно взаимодействует с итераторами и ссылками и невинная операция чтения, которая внутри триггерит отделение копии, может инвалидировать ссылку, выданную раньше. Именно из-за COW реализация std::string в старом GCC доставляла столько сюрпризов, и в C++11 стандарт фактически запретил COW-строки, ужесточив требования к инвалидации.

Идиома пришла из системного программирования, когда операционная система использует copy-on-write для страниц памяти при форке процесса. В C++ она расцвела в девяностых и нулевых как способ сделать копируемые классы дешёвыми, активно применялась в Qt (его контейнеры и QString до сих пор COW) и в той самой реализации libstdc++.

Скрытый текст
class CowBuffer {
    std::shared_ptr<std::vector<std::uint8_t>> data_;
    void detach() {                       // отделиться перед записью
        if (data_.use_count() > 1)
            data_ = std::make_shared<std::vector<std::uint8_t>>(*data_);
    }
  
public:
    std::uint8_t read(std::size_t i) const { return (*data_)[i]; }
    void write(std::size_t i, std::uint8_t v) {
        detach();                         // личная копия только сейчас
        (*data_)[i] = v;
    }
};

В играх к COW относятся прохладно и в хотпасе её почти не встретишь, сказывается непредсказуемость момента копирования, которая плохо сочетается с бюджетом кадра. Зато она вполне жива в инструментах и редакторах, вроде тех же строк в Qt-based редакторах уровней, разделяемых структур данных в системах undo/redo, или снапшотов состояния, которые дёшево клонируются и расходятся в копии только при правке.

Концептуально к COW близки и более современные приёмы вроде структур данных с разделяемой неизменяемой частью (persistent data structures), которые используют в системах отмены действий и в сетевой репликации, где состояние мира дёшево «форкается». Так что сама идея «делим, пока только читаем» в разработке жива, просто реализуют её обычно не классическим COW со счётчиком на каждом классе, а более точечно и осознанно.

Thread-safe Copy-on-write

Стоит к copy-on-write добавить многопоточность, как все её скрытые сложности выходят на свет и множатся. И, вроде бы, простой COW с обычным счётчиком ссылок в многопоточной среде приводит к гонке данных, когда два потока копируют объект, оба видят счётчик равным единице (или оба не видят чужого инкремента), оба решают, что они единоличные владельцы или наоборот делят буфер, и дальше либо двойное освобождение, либо запись поверх чужих данных.

Потокобезопасный COW требует, во-первых, атомарного счётчика ссылок, чтобы инкременты и декременты не теряли друг друга. Но одного этого мало, потому что остается проблема в окне между проверкой «я единственный владелец?» и собственно записью, а между этими двумя действиями другой поток может присоединиться к буферу или отсоединиться от него, и решение «копировать или нет», принятое без учета многопоточности успевает устареть за один такт. Закрывать это окно приходится либо блокировками, либо хитрыми атомарными протоколами с повторными попытками.

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

Болезненность темы хорошо задокументирована в серии статей Херба Саттера про shared_ptr и атомарность, а также в обсуждениях модели памяти. C++11 с его формальной моделью памяти и атомиками впервые дал инструменты, чтобы хотя бы рассуждать об этом строго, и одновременно подтвердил, что COW-структурам в стандарте больше не место.

Скрытый текст
class TsCowBuffer {
    std::shared_ptr<const std::vector<int>> data_;  // const: общие данные неизменяемы
    std::mutex mtx_;
public:
    int read(std::size_t i) const { return (*data_)[i]; }
    void write(std::size_t i, int v) {
        std::lock_guard<std::mutex> lk(mtx_);
        auto copy = std::make_shared<std::vector<int>>(*data_);  // всегда личная копия
        (*copy)[i] = v;
        std::atomic_store(&data_, std::shared_ptr<const std::vector<int>>(copy));
    }
};

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

Эта идея выродилась в поведение, когда вместо «копировать при записи» используют «никогда не менять опубликованное, а всегда публиковать новое». Двойная и тройная буферизация состояния между игровым и рендер-потоком, которую используют едва ли не все крупные движки, это близкий родственник потокобезопасного COW, переосмысленный так, чтобы сделать подмену указателя на неизменяемый снапшот вместо синхронизации на каждую запись.

Free Function Allocators

Идея свободных функций-аллокаторов сделать перегрузку глобальных или классовых operator new и operator delete , чтобы взять управление выделением памяти в свои руки. Язык даёт встроенный механизм и вы можете определить operator new на уровне класса, и тогда все аллокации объектов этого класса пойдут через вашу функцию, а не через стандартный mallocмеханизм, но можно перегрузить и глобально, перехватив вообще все аллокации в программе.

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

За все надо платить, и тут мы платим глобальностью и инвазивностью. Перегруженный глобальный operator new влияет вообще на всё, включая сторонние библиотеки, и отлаживать проблему в чужой памяти, которую вы незаметно перенаправили в свой аллокатор, занятие для мсье, который в кое-чем знает толк. Плюс есть тонкости с выравниванием, с парностью new/delete (нельзя освободить «не тем» аллокатором), с обработкой nothrow-версий и с формами new[]/delete[].

Механизм перегрузки operator new/delete существует в C++ с самых ранних версий и описан ещё в The C++ Programming Language Страуструпа. А полностью его использование для пулов и арен оформилось в девяностых и активно разбиралось у Скотта Майерса в Effective C++ (отдельные пункты как перегружать new/delete) и стало одним из столпов того, как в C++ принято кастомизировать управление памятью без перехода на ручные malloc/free.

Скрытый текст
class Projectile {
    static PoolAllocator pool_;     // пул фиксированных блоков sizeof(Projectile)
public:
    static void* operator new(std::size_t) { return pool_.allocate(); }
    static void  operator delete(void* p)  { pool_.free(p); }
    // ... поля снаряда: позиция, скорость, урон, владелец ...
};

// new Projectile теперь берёт блок из пула, а не из общей кучи 
// быстро и без фрагментации, даже когда снарядов на экране тысячи

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

Перегрузка operator new это один из стандартных способов воткнуться в механизм выделения памяти прозрачно для остального кода, хотя многие большие движки идут дальше и вообще запрещают голый new, заставляя всё проходить через явные интерфейсы аллокаторов. Но базовая идея «не доверяй аллокацию общего назначения тому, что выделяется в горячем пути» остаётся ровно этой идиомой.

Pimpl (Handle Body, Compilation Firewall, Cheshire Cat)

«pointer to implementation», приём, при котором класс прячет все свои приватные члены за указателем на скрытую структуру реализации. В заголовке остаётся публичный интерфейс и единственное поле с указателем на неполный тип Impl, а всё настоящее наполнение (поля, приватные методы, зависимости) переезжает в .cpp-файл, где определяется эта структура.

Такая конспирация нужна ради двух вещей. Первая и главная, чтобы приватные поля больше не были видны в заголовке, то изменение внутренностей класса не требует перекомпиляции всех, кто этот заголовок включает. В большом проекте, где базовый заголовок тянется в тысячу единиц трансляции, добавление одного приватного поля без Pimpl означает пересборку всего, а с Pimpl — пересборку одного .cpp. Вторая причина это настоящая бинарная инкапсуляция и стабильность ABI, когда размер и раскладка класса снаружи не меняются, что важно для библиотек, которые не хотят ломать совместимость при каждом обновлении.

Платить приходится за все, и тут мы платим индирекцией и аллокациями. Каждый доступ к полю теперь идёт через указатель (лишний переход по памяти, потенциальный кеш-промах), а сам объект Impl живёт в куче и конструирование класса тянет за собой динамическую аллокацию. Для объекта, который создаётся редко и живёт долго, это незаметно, но для мелкого объекта фактически приговор, если мы хотим его использовать в рендере, например.

Идиома уходит корнями в Handle/Body Джеймса Коплина 1992 года, а звучные имена ей подарили другие. «Cheshire Cat» или Чеширский кот, когда от класса остаётся одна улыбка-интерфейс, а тело исчезает, приписывают Джону Каролану, а «Compilation Firewall» и собственно «Pimpl» популяризировал Херб Саттер в Exceptional C++ и серии Guru of the Week.

Скрытый текст
// physics_world.h заголовок ничего не знает о внутренностях
class PhysicsWorld {
public:
    PhysicsWorld();
    ~PhysicsWorld();                 // объявлен здесь, определён в .cpp
    void step(float dt);
private:
    struct Impl;                     // неполный тип
    std::unique_ptr<Impl> impl_;
};

// physics_world.cpp — здесь живёт вся реальность
struct PhysicsWorld::Impl {
    btDiscreteDynamicsWorld bullet;  // тяжёлый заголовок не утёк наружу
    std::vector<RigidBody> bodies;
};
PhysicsWorld::PhysicsWorld() : impl_(std::make_unique<Impl>()) {}
PhysicsWorld::~PhysicsWorld() = default;   // тут Impl полный checked delete доволен
void PhysicsWorld::step(float dt) { impl_->bullet.stepSimulation(dt); }

В играх Pimpl ценят в первую очередь за время сборки и изоляцию тяжёлых сторонних зависимостей. Классический сценарий применения будет обёртка над физическим движком, аудиолибой или сетевым стеком, когда мы не хотим, чтобы заголовки Bullet, FMOD или какого-нибудь монструозного SDK протекали в файлы движка. Там Pimpl запирает эти заголовки в одном .cpp, и остальной проект собирается, не зная об их существовании.

При этом в хотпасе рендера Pimpl осознанно избегают и никто не будет прятать Vector3 или компонент трансформа за указателем, потому что индирекция и аллокация убьют производительность. Поэтому Pimpl в играх это инструмент для «крупных» подсистем, API движка и редакторных классов, а не для мелких значений.

Fast Pimpl

Это Pimpl, у которого отобрали его главный недостаток, динамическую аллокацию. Вместо того чтобы держать Impl в куче через указатель, мы резервируем под него кусок памяти прямо внутри объекта (массив байтов нужного размера с нужным выравниванием) и конструируем Impl там же, через placement new. Снаружи класс по-прежнему скрывает внутренности, а внутри никакой кучи уже нет и объект живёт целиком на стеке или там, где его разместил владелец.

Выгода очевидна и вы сохраняете саму идею Pimpl, но убираете аллокацию и лишнюю индирекцию через кучу, а значит, и кеш-промах на доступе к реализации. Объект становится «плоским» в памяти, и по сути это попытка получить и инкапсуляцию, и производительность одновременно, не выбирая между ними.

За все приходится платить, и тут мы расплачиваемся размером и выравниванием. Теперь Impl приходится задавать в заголовке руками, числом, хотя сам Impl в заголовке невидим и это достаточно хрупко. Добавили поле в Impl, превысили зарезервированный размер и в лучшем случае получаете ошибку компиляции от статической проверки, в худшем (если проверки нет) затирание памяти. Поэтому Fast Pimpl всегда сопровождают static_assert, который сверяет реальный sizeof(Impl) с зарезервированным в .cpp, чтобы поймать рассинхрон на сборке.

Идиому, опять же, детально разобрал Херб Саттер в Exceptional C++ под названием «The Fast Pimpl Idiom», показав и наивную версию с фиксированным буфером, и тонкости с выравниванием. В современном C++ её делают чище через std::aligned_storage (а с C++23 через alignas и явные байтовые буферы), но суть с девяностых не изменилась: реализация прячется, но не убегает в кучу.

Скрытый текст
// fast_pimpl.h
class SoundEmitter {
public:
    SoundEmitter();
    ~SoundEmitter();
    void play();
private:
    struct Impl;
    static constexpr std::size_t kSize  = 64;   // подобрано под sizeof(Impl)
    static constexpr std::size_t kAlign = 16;
    alignas(kAlign) std::byte storage_[kSize];
    Impl* impl() { return reinterpret_cast<Impl*>(storage_); }
};

// fast_pimpl.cpp
struct SoundEmitter::Impl { /* поля микшера, голоса, фейды */ };
static_assert(sizeof(SoundEmitter::Impl) <= 64,  "увеличь kSize");
static_assert(alignof(SoundEmitter::Impl) <= 16, "увеличь kAlign");
SoundEmitter::SoundEmitter()  { new (storage_) Impl(); }
SoundEmitter::~SoundEmitter() { impl()->~Impl(); }

В играх Fast Pimpl всплывает там, где хочется и спрятать внутренности (ради сборки и чистоты API), и при этом не платить за аллокацию, потому что объект создаётся часто или живёт в плотном массиве. Типичные кандидаты это обёртки над платформенно-зависимыми хендлами (сокет, файловый дескриптор, таймер высокого разрешения), где Impl крошечный и фиксированного размера, а сам объект может создаваться пачками.

Стоит признать, что в больших движках Fast Pimpl встречается даже реже чем Pimpl, потому что ручная синхронизация размера это вечный источник раздражения, а профиль использования (хотпас + желание спрятать внутренности) встречается редко.

Interface Class

Класс, состоящий из одних чисто виртуальных методов и не несущий никакого состояния и реализации, т.е. чистый контракт «что объект умеет делать», без единой реализации. В C++ нет ключевого слова interface, поэтому интерфейс выражается абстрактным классом со всеми = 0 методами и обязательным виртуальным деструктором, а конкретные классы наследуются от него и реализуют контракт.

Смысл идиомы разорвать связь между тем, кто пользуется объектом, и тем, как объект устроен. Код, работающий с IRenderer*, ничего не знает про DirectX или Vulkan за этим интерфейсом и не пересобирается при смене реализации, а зависит только от контракта. Это и есть инверсия зависимостей, когда верхние уровни зависят от абстракций, а не от конкретики, что даёт подменяемость реализаций, моки для тестов и чистые границы между модулями.

За все приходится платить, и тут цена в динамической диспетчеризации. Каждый вызов через интерфейс будет виртуальным вызовом и переходом по vtable, лишняя индирекция, помеха инлайнингу и предсказателю переходов. Для «крупнозернистых» вызовов (раз в кадр дёрнуть рендер-бэкенд) это копейки, а для вызова в цикле по миллиону объектов будет катастрофой, поэтому интерфейсы хороши только на швах между подсистемами.

Концепция чистого интерфейса стара как само объектно-ориентированное программирование, но в C++ её каноническую форму («Interface Class») зафиксировал опять Херб Саттер, в том числе в Exceptional C++ Style, где разбирал, почему интерфейсы стоит делать именно так и почему деструктор обязан быть виртуальным. Языки вроде Java и C# позже сделали интерфейсы синтаксической сущностью, а C++ так и остался с идиомой на чистых виртуалах.

Скрытый текст
class IRenderer {
public:
    virtual ~IRenderer() = default;            // обязательно виртуальный
    virtual void begin_frame() = 0;
    virtual void draw(const Mesh&, const Material&) = 0;
    virtual void end_frame() = 0;
};

class VulkanRenderer : public IRenderer { /* ... реализация на Vulkan ... */ };
class D3D12Renderer  : public IRenderer { /* ... реализация на D3D12 ... */ };

// Игра знает только контракт, не зная бэкенда:
void render_world(IRenderer& r, const World& w) {
    r.begin_frame();
    for (auto& obj : w.visible) r.draw(obj.mesh, obj.material);
    r.end_frame();
}

Как я уже и сказал интерфейсные классы живут на крупных архитектурных швах в виде абстракций графического API, платформенном слое (файловая система, ввод, окно), аудиобэкенде, плагинах редактора. Т.е. везде, где нужно подменять реализацию под платформу или скрывать её за стабильной границей, здесь интерфейс это естественный выбор, и накладные расходы виртуального вызова пренебрежимы на фоне самой операции.

А вот в ядре симуляции отношение к интерфейсам очень осторожное. Старые объектные движки делали базовый Entity с виртуальным update() и кучей наследников, и это работало, пока сущностей было мало, но миллион виртуальных update упираются в кеш-промахи, идирекцию по vtable и в невозможность инлайнинга. Отсюда дрейф индустрии в сторону ECS и data-oriented design, где вместо интерфейса с виртуальными методами данные лежат плотными массивами, а логика проходит по ним без диспетчеризации.

Concrete Data Type

Это, по сути, антипод предыдущей идиомы и напоминание, что не всё на свете обязано быть полиморфным. Конкретный тип данных спроектирован как полноценное «значение» и он не наследуется, не имеет виртуальных функций, копируется и перемещается как обычная величина, живёт на стеке или внутри других объектов и ведёт себя предсказуемо, как встроенный тип. Vector3, Color, Quaternion, Rect вот это вот всё.

Мы сознательно отказались от полиморфизма и наследования там, где они не нужны, и получили за это всё, что C++ умеет давать значениям. Т.е. размещение на стеке без аллокаций, отсутствие vtable и виртуальных вызовов, полную прозрачность для инлайнинга, плотную упаковку в массивах и контейнерах. Такой тип компилятор видит насквозь и оптимизирует агрессивно, потому что нет ни одной точки, где поведение определяется в рантайме.

За все приходится платить, и тут мы расплачиваемся дисциплиной дизайна и такой конкретный тип должен быть «закрытым» в смысле семантики значения. Обычно у него нет виртуального деструктора (он не предназначен для наследования и удаления через базовый указатель), его инварианты простые, а интерфейс полный и самодостаточный. Попытка позже «донаследовать» от такого типа почти всегда будет ошибкой, и многие движки помечают такие классы final, чтобы зафиксировать намерение.

Термин и сама дисциплина идут от Бьёрна Страуструпа, который в The C++ Programming Language противопоставлял «конкретные типы» (concrete types) «абстрактным типам» (abstract types) как два разных способа использовать классы. Одни нужны, чтобы строить новые значения языка, а другие, чтобы строить иерархии поведения. Это противопоставление стало одной из фундаментальных развилок проектирования в C++, и понимание, на какую сторону встать в каждом конкретном случае, отличает зрелый код от каши из ненужных виртуалов.

Скрытый текст
struct Vec3 {                       // чистое значение: ни vtable, ни наследования
    float x = 0, y = 0, z = 0;
    Vec3 operator+(Vec3 o) const { return {x + o.x, y + o.y, z + o.z}; }
    Vec3 operator*(float s) const { return {x * s, y * s, z * s}; }
    float dot(Vec3 o) const { return x * o.x + y * o.y + z * o.z; }
};

// Плотный массив значений: ноль индирекции, идеально для кеша и SIMD
std::vector<Vec3> positions(100000);
for (auto& p : positions) p = p + velocity * dt;   // компилятор инлайнит и векторизует

Такие типы данных это буквально фундамент всей математики и низкоуровневых структур: векторы, матрицы, кватернионы, цвета, ограничивающие объёмы, ключи анимации. Все они спроектированы как значения именно потому, что их создают, копируют и перемалывают миллионами, и любая виртуальность или аллокация здесь была бы немыслимой роскошью. Эти типы обязаны быть прозрачными для компилятора, чтобы он мог их инлайнить и векторизовать.

Если посмотреть шире, то это будет сердцем data-oriented design, философии, которая в современном геймдеве потеснила наивный ООП. Когда сущность игры представлена не объектом с виртуальными методами, а набором конкретных типов-значений, разложенных по плотным массивам, процессор читает их потоком, а SIMD-инструкции обрабатывают по несколько штук за такт. Concrete Data Type именно та идиома, которая делает такой подход возможным, и именно её недооценка в эпоху «всё есть объект» стоила играм немалой доли производительности.

Final Class

Это класс, от которого запрещено наследоваться. До C++11 такого запрета в языке не было, и его приходилось эмулировать хитрыми трюками, но с C++11 появилось ключевое слово final, и идиома превратилась в одну строчку. Помечая класс final, вы говорите и компилятору, и читателю, что эта точка иерархии конечная, дальше расширять нельзя.

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

Девиртуализация в этом случае уже не теоретическая возможность, теперь виртуальный вызов в хотпасе не мешает инлайнингу и позволяет компилятору схлопнуть его в обычный вызов и раскрыть тело функции прямо в месте вызова.

За все приходится платить, и тут мы расплачиваемся намертво закрытой расширяемостью, и если позже выяснится, что наследоваться всё-таки надо (для мок-тестов например или для платформенного варианта), то придётся возвращаться и снимать ограничение.

До C++11 идиому изображали через приватный виртуальный базовый класс с дружбой и прочую тёмную магию (известный трюк описывал, в частности, Бьёрн Страуструп в обсуждениях, а вариации гуляли по C++-форумам), но всё это было настолько уродливо, что почти не применялось. Поэтому настоящая жизнь у идиомы началась только с C++11, когда final (вместе с override) стал частью языка как ключевое слово.

Скрытый текст
// Лист иерархии: дальше наследоваться нельзя, и компилятор это использует
class StaticMeshComponent final : public Component {
public:
    void update(float dt) override;   // override + final-класс => девиртуализация
};

// Вызов через StaticMeshComponent* компилятор может превратить в прямой и заинлайнить
void tick(StaticMeshComponent& c, float dt) { c.update(dt); }

final обычно ставят на «листовые» классы компонентов и систем не только ради чистоты, но и осознанно ради девиртуализации. Unreal Engine, например, рекомендует помечать final классы, которые не предполагается наследовать, именно из соображений производительности, и это даёт компилятору шанс убрать виртуальные вызовы там, где иерархия фактически закончилась.

Это один из тех редких случаев, когда добавление одного ключевого слова напрямую конвертируется во время кадра и профайлер на тяжёлой сцене вполне способен показать разницу между виртуальным и девиртуализированным update на десятках тысяч компонентов. Поэтому в современных движках final уже не стилистическая придирка ревьюера, а часть перформанс-культуры, наряду с noexcept и продуманной раскладкой данных.

Include Guard Macro

Самая базовая и самая повсеместная идиома препроцессора, без которой не собирается ни один сколько-нибудь крупный проект на плюсах. Проблема, которую она решает, элементарна: один и тот же заголовок почти всегда попадает в единицу трансляции несколько раз через цепочки #include, и если в нём есть определения (классов, структур), повторное включение даст ошибку «переопределение». Include guard гарантирует, что содержимое заголовка обработается ровно один раз.

Механика тривиальна: в начале заголовка проверяется, не определён ли уникальный макрос-страж, и если нет — он определяется, и дальше идёт тело; при повторном включении макрос уже определён, и препроцессор пропускает всё до закрывающего #endif. Имя стража должно быть уникальным на весь проект, обычно его строят из пути к файлу, чтобы случайно не столкнуться с чужим.

Подводных камней почти нет, кроме одного: коллизия имён стражей. Если два разных заголовка случайно используют один и тот же макрос (например, банальный UTILS_H), второй из них будет молча пропущен целиком, и вы получите загадочные ошибки «неизвестный тип» там, где тип явно определён. Поэтому крупные проекты либо генерируют стражи из полного пути, либо переходят на #pragma once.

Идиома стара как сам препроцессор C, то есть восходит к семидесятым годам и языку C Денниса Ритчи, и в C++ перешла по наследству без изменений. Альтернатива #pragma once появилась как нестандартное, но фактически повсеместно поддерживаемое расширение компиляторов; она короче и невосприимчива к коллизиям имён, но формально вне стандарта и в редких экзотических случаях (хитрые символические ссылки, сетевые файловые системы) может ошибиться, не распознав один и тот же файл.

Скрытый текст
// transform.h
#ifndef GAME_CORE_TRANSFORM_H   // имя из пути, чтобы не было коллизий
#define GAME_CORE_TRANSFORM_H

struct Transform { Vec3 position; Quat rotation; Vec3 scale; };

#endif  // GAME_CORE_TRANSFORM_H

// Многие вместо этого пишут просто:
// #pragma once

Если счёт заголовкам идёт на тысячи, то include guard'ы (и их разновидность #pragma once) это не столько про корректность, сколько про здравомыслие сборки. Большинство проектов по умолчанию используют #pragma once ради краткости, но в кодовых базах, которые должны компилироваться экзотическими или старыми компиляторами, до сих пор встречается классический #ifndef-страж как более переносимый.

Страж не мешает препроцессору открыть и прочитать файл повторно, он лишь не даёт его телу попасть в трансляцию, поэтому в дополнение к стражам движки применяют разные include-what-you-use, форвард-декларации и Pimpl, чтобы сократить число включений, потому что самый быстрый #include это тот, которого нет.

Inline Guard Macro

Более узкая родственница include guard, нацеленная на правило одного определения (ODR) для функций. Если вы определяете обычную (не inline, не шаблонную) функцию в заголовке, и этот заголовок включается в несколько единиц трансляции, линковщик увидит несколько одинаковых символов и упадёт с ошибкой «multiple definition». Inline guard это набор приёмов, чтобы определения в заголовке не нарушали ODR.

Исторически идиома сводилась к тому, чтобы оборачивать определения функций в заголовке либо ключевым словом inline (которое как раз и разрешает множественные идентичные определения в разных единицах трансляции), либо в условную компиляцию через макрос, который «включает» реальное определение только в одной выбранной единице трансляции, а в остальных оставляет лишь объявление. Второй вариант и есть собственно «inline guard macro»: макрос-переключатель между «здесь определение» и «здесь только объявление».

Вопреки наивному пониманию, inline в современном языке означает не «обязательно встроить вызов» (это лишь намёк оптимизатору, который он волен игнорировать), а именно «этому символу разрешено иметь несколько идентичных определений в программе». Путаница между этими двумя смыслами слова является классическим источником недопонимания, и именно ODR-смысл делает inline инструментом для header-only кода.

Корни идиомы все в той же эпохе раздельной компиляции C и в эволюции значения inline от C к C++. С приходом header-only библиотек (Boost, а позже бесчисленные однохедерные либы) и особенно с C++17, который дал inline-переменные, потребность в ручных макросах-переключателях резко упала. Теперь почти всё, что нужно положить в заголовок, можно честно пометить inline и забыть про ODR-проблемы.

Скрытый текст
// math_utils.h — header-only, безопасно включать откуда угодно
inline float fast_inv_sqrt(float x) {   // inline => множественные определения OK
    // ... реализация ...
    return x;
}

// C++17: и глобальная константа теперь безопасна в заголовке
inline constexpr float kPi = 3.14159265f;

inline guard в его старом макро-виде почти вымер, но его дух жив в повальной любви к header-only и inline-функциям для математики и мелких утилит. Векторнуя математику, мелкие хелперы, constexpr-таблицы кладут в заголовки с inline, чтобы компилятор видел тело в каждой единице трансляции и мог инлайнить, не нарушая ODR. Скорость важнее, чем экономия на дублировании, и inline тут больше про инлайнинг через границы файлов.

Где идиома всё ещё проявляется явно, так это это «амальгамированные» (single-header) сборки библиотек, которые геймдев любит за простоту интеграции. Одна-единственная .h-библиотека, где определения активируются макросом #define IMGUI_IMPLEMENTATION ровно в одном .cpp будет прямым потомком inline guard macro с тем же приёмом «определение здесь, в остальных местах только объявления», просто доведённым до уровня целой библиотеки. Посмотреть как именно так устроено можно в знаменитых stb-библиотеки Шона Барретта.

Export Guard Macro

Это уже идиома про границы динамических библиотек и как один и тот же заголовок ухитряется работать и для того, кто библиотеку собирает (и должен пометить символы как экспортируемые), и для того, кто библиотеку использует (и должен пометить те же символы как импортируемые). На Windows это выражается атрибутами declspec(dllexport) и declspec(dllimport), на других платформах — атрибутами видимости вроде attribute((visibility("default"))).

Решается это через макрос, который раскрывается по-разному в зависимости от того, собирается ли библиотека или потребляется. Внутри сборки библиотеки определён специальный макрос-флаг, и тогда «экспортный» макрос разворачивается в dllexport; снаружи флага нет, и тот же макрос разворачивается в dllimportи пользователю не надо ничего знать про эту механику, чтобы включить заголовок.

Подводных камней тут целая россыпь, и все платформенно-зависимые. Экспорт C++-классов через границы DLL завязан на совпадение компиляторов, версий рантайма и опций сборки с обеих сторон, потому что искажение имён (name mangling), раскладка классов и модель исключений должны совпадать. Передавать STL-типы или кидать исключения через границу DLL это классический способ получить загадочные падения, поэтому стабильные плагинные границы делают на чистом C-интерфейсе или на интерфейсных классах с COM-подобными правилами.

Идиома возникла вместе с разделяемыми библиотеками: на Windows с DLL и __declspec, появившимся в компиляторах Microsoft, на Unix с разделяемыми объектами и контролем видимости символов в GCC (атрибут visibility стал заметен примерно с GCC 4.0). Кроссплатформенные проекты с тех пор обязаны заворачивать все эти различия в один макрос ради переносимости.

Скрытый текст
// engine_api.h
#if defined(_WIN32)
  #if defined(ENGINE_BUILD_DLL)
    #define ENGINE_API __declspec(dllexport)   // собираем библиотеку
  #else
    #define ENGINE_API __declspec(dllimport)   // используем библиотеку
  #endif
#else
  #define ENGINE_API __attribute__((visibility("default")))
#endif

class ENGINE_API Engine {
public:
    void run();
};

В разработке игр export guard'ы вездесущи в любом проекте, который раскладывается на несколько модулей или поддерживает плагины: ядро движка, редактор, игровые модули, сторонние расширения часто делают отдельными библиотеками с размеченными границами экспорта. Unreal Engine, например, генерирует подобные макросы (ENGINE_API, CORE_API и сотни других, по одному на модуль) автоматически своей системой сборки, и каждый публичный класс модуля помечается соответствующим макросом.

Проект, который собирается под Windows, консоли и десктопные Unix-системы, обязан прятать все различия экспорта/импорта/видимости за такими макросами, иначе один и тот же заголовок просто не соберётся на всех платформах сразу, и такие гварды становятся платой за кроссплатформенную модульность.

Curiously Recurring Template Pattern (CRTP)

Многие мои знакомые разработчики считают такие шаблоны нарушением причинно-следственной связи, когда класс наследуется от шаблона, параметризованного им же самим, то есть class Derived : public Base<Derived>. Производный класс ещё не определён до конца, а уже передаёт сам себя в качестве шаблонного аргумента своему базовому классу, что уже звучит как черная магия, но компилятор это спокойно обрабатывает, потому что к моменту инстанцирования методов базового класса производный уже полностью известен.

Это даёт возможность сделать статический полиморфизм, то есть полиморфизм без виртуальных функций, когда базовый класс может вызывать методы производного, кастуясь к нему через static_cast<Derived*>(this). А поскольку точный тип известен на этапе компиляции, то и вызов разрешается напрямую, без vtable. Это та же диспетчеризация «база зовёт реализацию наследника», что и у виртуальных функций, но цена её "ноль" в рантайме, потому что вся работа сделана компилятором.

За все приходится платить, и расплачиваемся мы тут сложностью. CRTP даёт полиморфизм только там, где тип известен статически и вы не можете сложить разные Derived в один контейнер указателей на общую базу и итерировать по ним полиморфно, потому что Base<Cat> и Base<Dog> теперь вообще разные и несвязанные типы. CRTP меняет рантайм-гибкость на скорость, и применим лишь когда конкретный тип известен в точке вызов плюс ошибки в нём дают чудовищные шаблонные сообщения компилятора.

Странное имя «curiously recurring» придумал Джеймс Коплин в 1995 году в колонке в C++ Report, заметив, что этот «любопытно повторяющийся» паттерн всплывает в коде снова и снова независимо у разных людей. Сама техника при этом старше еще лет на пять и её использовали для разных целей, но со временем CRTP стал одним из столпов высокопроизводительного C++ и лёг в основу множества библиотек, от Eigen до фреймворков сериализации.

Скрытый текст
template <class Derived>
struct Shape {
    float area() const {                       // база зовёт метод наследника
        return static_cast<const Derived*>(this)->area_impl();
    }
};

struct Circle : Shape<Circle> {
    float r;
    float area_impl() const { return 3.14159f * r * r; }   // инлайнится, без vtable
};

template <class S>
float total_area(const std::vector<S>& shapes) {
    float sum = 0;
    for (auto& s : shapes) sum += s.area();   // прямой вызов, компилятор всё видит
    return sum;
}

CRTP любимый инструмент для оптимизации хотпасов и системы, которым нужна общая инфраструктура с настраиваемым поведением, но без рантайм-диспетчеризации, строят на нем. Базовый шаблон даёт общий каркас (итерацию, регистрацию, общий API), а наследник подставляет конкретную логику. Типичные применения это математические библиотеки с выражениями над векторами и матрицами (тот же Eigen целиком построен на CRTP), системы компонентов и разнообразные «mixin»-надстройки, добавляющие операторы сравнения или арифметику на основе одного-двух методов наследника.

Везде, где хочется переиспользования кода без штрафа за виртуальность, CRTP оказывается ответом, и понимание этой идиомы практически обязательное условие для чтения современного производительного C++ кода.

Barton-Nackman trick

Трюк Бартона-Накмана является продолженим предыдущей идиомы как способ определить свободную функцию (чаще всего оператор, например operator==) прямо внутри определения шаблонного класса черезfriend-функцию. Хитрость в том, что такая дружественная функция, определённая внутри шаблона, автоматически создаётся заново для каждой инстанциации шаблона и находится исключительно через ADL, не засоряя глобальное пространство имён и не участвуя в обычном разрешении перегрузок, пока её не позовут с правильными аргументами.

Изначально трюк решал конкретную историческую проблему, когда компиляторы C++ ещё не умели нормально работать с шаблонными функциями и их перегрузкой. Механизм был сырой, и определить, скажем, operator== как отдельный шаблон так, чтобы он корректно находился и инстанцировался, было то ли невозможно, то ли чревато ошибками инстанции отдельных шаблонов. Поэтому определение оператора как friend внутри класса обходило эти ограничения и оператор появлялся как обычная нешаблонная функция для каждой конкретной инстанциации.

Сегодня изначальная мотивация (компенсировать кривость ранних компиляторов с перегрузкой шаблонов) полностью устарела, но сам приём «friend-функция внутри шаблона» остался полезным по другой причине. Теперь это чистый способ дать тип-специфичные нечленские операторы, которые видны только через ADL и не вмешиваются в разрешение перегрузок для несвязанных типов.

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

Имя трюку дали по статье Джона Бартона начала девяностых, где он применял его в контексте научных вычислений и систем единиц измерения. Как ни странно, именно в коде Бартона впервые явно засветился и тот самый «любопытно повторяющийся» паттерн наследования, который позже Коплин окрестит CRTP, так что две эти идиомы близкие исторические родственники.

Скрытый текст
template <class T>
struct Comparable {
    // friend внутри шаблона: своя для каждого T, находится только через ADL
    friend bool operator==(const T& a, const T& b) { return a.equals(b); }
    friend bool operator!=(const T& a, const T& b) { return !(a == b); }
};

struct Vec2 : Comparable<Vec2> {
    float x, y;
    bool equals(const Vec2& o) const { return x == o.x && y == o.y; }
};
// operator== для Vec2 сгенерирован автоматически, в глобальном namespace его «нет»

В разработке игр трюк Бартона-Накмана сейчас практически везде ушел, потому что старых компиляторов давно нет, а вместе с ними ушли и проблемы, но его современное переосмысление в виде «hidden friends», дружественных операторов внутри шаблонных mixin-ов вполне живёт в библиотеках математики, физических единиц и утилитах, где нужно массово раздавать типам операторы сравнения и арифметики, не загрязняя глобальное пространство имён и не замедляя компиляцию лишними кандидатами при разрешении перегрузок.

Современные библиотеки физических величин (вроде тех, что считают метры, секунды и не дают сложить одно с другим) активно используют hidden friends в духе Бартона-Накмана, чтобы на уровне типов не перепутать, скажем, мировые координаты с экранными или секунды с тиками. Так что идиома, родившаяся как костыль под слабые компиляторы, переродилась в инструмент чистоты пространств имён и скорости сборки, по мне так неплохая судьба для трюка тридцатилетней давности.

Empty Base Optimization (EBO)

Пустая база уже не столько идиома, сколько гарантия, что пустой класс (без нефункциональных членов) не будет иметь размер. Обычно даже пустой класс должен иметь хотя бы один байт, чтобы у двух разных объектов были разные адреса, но когда такой пустой класс выступает базовым классом, компилятору разрешено не выделять под него ни одного байта и он может «схлопнуть» пустую базу в нулевой размер внутри производного объекта.

Зачем это эксплуатировать? Для начала, в C++ полно пустых, но полезных типов, вроде политик поведения, тегов, функторов без состояния, аллокаторов по умолчанию или компараторы. Если такой пустой тип хранить как поле, он съест минимум байт (а с учётом выравнивания нередко больше), и в структуре это выльется в перерасход памяти. А если унаследоваться от него вместо хранения полем, то EBO позволит ему не стоить вообще ничего.

За все приходится платить, и тут мы расплачиваемся наследованием ради экономии байта, и отдельными оптимизациями в самом компиляторе только под это поведение, что противоречит интуиции «наследование = is-a отношение», и злоупотребление им делает код немного странным. Еще правило ломается при множественном наследовании от нескольких пустых баз одного типа (адреса всё-таки должны различаться) и требует осторожности, поэтому в C++20 ввели атрибут [[no_unique_address]], который даёт ту же экономию для полей-членов без необходимости наследоваться, и это куда явно выражает намерение.

Возможность EBO давно зафиксирована в стандарте, а её систематическое применение для библиотечных целей популяризировал Натан Майерс, предложив технику «base-from-member» и компрессированных пар. Именно на EBO держится boost::compressed_pair, и именно благодаря EBO стандартные контейнеры не платят лишними байтами за свои аллокаторы и компараторы по умолчанию, которые почти всегда пусты.

Скрытый текст
struct DefaultDeleter {};   // пустая политика, 0 полезных байт

// Без EBO: deleter занял бы место. С EBO как база все еще ноль байт.
template <class T, class Deleter = DefaultDeleter>
class UniquePtr : private Deleter {   // наследуемся от пустой политики
    T* ptr_;
public:
    // sizeof(UniquePtr) == sizeof(T*), Deleter не стоит ничего
};

// C++20 уже без наследования:
template <class T, class Deleter = DefaultDeleter>
class UniquePtr2 {
    [[no_unique_address]] Deleter deleter_;
    T* ptr_;
};

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

Non-copyable Mixin

Маленький базовый класс, единственная задача которого это запретить копирование того, кто от него наследуется. Вы делаете класс с удалённым (или, до C++11, приватным и не определённым) конструктором копирования и оператором присваивания, и любой, кто унаследуется от него, автоматически теряет способность копироваться, потому что компилятор не сможет сгенерировать копирующие операции производного класса, упёршись в недоступные операции базы.

Сделано это, потому что для многих объектов копирование не имеет смысла или прямо опасно и объект, владеющий уникальным ресурсом (мьютекс, сокет, GPU-хендл, файл), при копировании породил бы двух «владельцев» одного ресурса, что ведёт к двойному освобождению. А менеджеры и синглтоны копировать бессмысленно, поэтому запрет копирования на уровне типа превращает потенциальную рантайм-ошибку в ошибку компиляции, что всегда дешевле.

За все приходится платить, и исторической надо было платить невнятными сообщениями об ошибках и то, что друзья и члены класса всё-таки могли вызвать копирование, получив ошибку только на этапе линковки. C++11 это явно вылечил ключевым словом = delete, которое даёт читаемую ошибку компиляции, а объявление копирующих операций (даже удалённых) подавляет автогенерацию move-операций, так что non-copyable тип по умолчанию ещё и non-movable, если не объявить move явно.

Самая известная реализация этой идиомы будет boost::noncopyable, появившаяся в Boost очень давно и ставшая каноном до C++11. Идиому в её приватно-необъявленном виде описывал еще Скотт Майерс в Effective C++ как способ «запретить функции, которые компилятор генерирует сам», а после C++11 нужда в специальном базовом классе во многом отпала и проще написать = delete прямо в классе, но boost::noncopyable и его аналоги по-прежнему живут в коде как выразительный маркер намерения автора.

Скрытый текст
struct NonCopyable {
    NonCopyable() = default;
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable& operator=(const NonCopyable&) = delete;
};

class GpuBuffer : private NonCopyable {   // копировать GPU-ресурс нельзя
    GLuint id_;
    // move при необходимости объявляем явно — копирование запрещено
public:
    GpuBuffer(GpuBuffer&&) noexcept;
    GpuBuffer& operator=(GpuBuffer&&) noexcept;
};

В разработке игр это давнишний приём для всего, что владеет ресурсами или обязано существовать в единственном экземпляре. GPU-буферы, текстуры, командные очереди, файловые потоки, сетевые сессии, менеджеры подсистем: это всё типы, чьё случайное копирование почти всегда баг, и пометка их non-copyable ловит этот баг на этапе компиляции, а не в виде загадочного двойного glDeleteBuffers в рантайме.

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

Parameterized Base Class

Это уже про наследование от шаблонного параметра, когда класс получает свою базу не фиксированной, а в виде шаблонного аргумента, template <class Base> class Mixin : public Base. Тем самым иерархию наследования собирают из кубиков на этапе компиляции, накладывая один слой поведения на другой в любом нужном порядке, и каждый слой добавляет свою функциональность поверх того, что пришло снизу.

Это основа того, что называют mixin-ами через наследование, где каждый mixin это шаблон, параметризованный базой, добавляющий один аспект (логирование, сериализацию, подсчёт ссылок, потокобезопасность), а конечный тип собирается их цепочкой. Порядок и состав слоёв задаются в точке использования, что даёт комбинаторную гибкость и из десятка mixin-ов можно собрать сотни вариантов поведения, не написав ни одного из них руками целиком.

За все приходится платить, и тут ценой будут глубокие цепочки шаблонного наследования с длинными, нечитаемыми именами типов и чудовищными сообщениями об ошибках, а иногда нетривиальными конструкторами (какой слой конструируется? когда? как пробросить аргументы через всю цепочку до нужного слоя?). Плюс это статическая композиция и состав слоёв фиксируется на этапе компиляции, и поменять его в рантайме нельзя, в отличие от композиции через агрегацию объектов.

Идиому как фундамент «mixin-based programming» детально проработали в академических работах девяностых Смарагдакис и Бачор, а в практическом C++ её во всю мощь развернул Андрей Александреску в Modern C++ Design в далеком 2001, где параметризованное наследование это несущая конструкция для policy-based классов, которые собираются из политик именно через наследование от шаблонных параметров.

Скрытый текст
// Каждый слой параметризован своей базой и добавляет один аспект
template <class Base>
struct WithLogging : Base {
    void update(float dt) {
        log("update start");
        Base::update(dt);          // делегируем вниз по цепочке
        log("update end");
    }
};

template <class Base>
struct WithProfiling : Base {
    void update(float dt) {
        ScopedTimer t("update");
        Base::update(dt);
    }
};

struct CoreSystem { void update(float) { /* реальная работа */ } };

// Собираем тип из слоёв в нужном порядке:
using DebugSystem = WithLogging<WithProfiling<CoreSystem>>;

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

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

Metafunction

Это лицокнига (шутка) «функция», которая работает не со значениями в рантайме, а с типами и компайл-тайм-константами на этапе компиляции и реализуется как шаблон, чьи параметры это «аргументы», а вложенные члены (::type для типа-результата, ::value для константы-результата) будут «возвращаемым значением». Компилятор, инстанцируя шаблон, фактически вычисляет эту функцию, и результат становится частью программы ещё до того, как она начала исполняться.

Это основа всего шаблонного метапрограммирования и на метафункциях можно вычислять что угодно во время компиляции: от «является ли тип указателем» до факториала и сортировки списков типов. Соглашение об именах (::type и ::value) превращает разрозненные шаблоны в единый «язык» метапрограммирования, где метафункции можно композировать, передавать друг в друга и применять к спискам типов.

За все приходится платить, и тут мы платим всем: от синтаксиса и до времени компиляции. Метапрограммирование традиционно многословно (typename, template-disambiguators, вложенные ::type), сообщения об ошибках повергают в уныние даже клода, а тяжёлое метавычисление ощутимо замедляет компиляцию, потому что компилятор материализует множество промежуточных типов. C++11 (constexpr), C++14 и особенно C++17/20 (if constexpr, концепты, constexpr-вычисления) сильно упростили жизнь, позволив часто писать обычный код вместо шаблонной акробатики.

Открытие, что шаблоны C++ можно использовать как язык вычислений, приписывают Эрвину Унру, который в 1994 году написал программу, заставлявшую компилятор печатать простые числа в сообщениях об ошибках и случайно доказавшему Тьюринг-полноту шаблонов. Дальше это систематизировал Тодд Велдхейзен (в контексте своих работ и научных вычислений), а в каноническую дисциплину оформили Александреску в Modern C++ Design и Абрахамс с Гуртовым в C++ Template Metaprogramming.

Скрытый текст
// Метафункция-предикат: "является ли T указателем"
template <class T> struct is_pointer_t      { 
  static constexpr bool value = false;
};
template <class T> struct is_pointer_t<T*>  { 
  static constexpr bool value = true;  };

// Метафункция, вычисляющая тип результата
template <class T> struct remove_ref      { using type = T; };
template <class T> struct remove_ref<T&>  { using type = T; };

static_assert(is_pointer_t<int*>::value);
using Clean = remove_ref<int&>::type;   // == int, вычислено компилятором

Метафункции это фундамент современных систем сериализации, рефлексии и обобщённых контейнеров. И если движок умеет автоматически сохранять и грузить любую структуру, под этим почти наверняка лежат метафункции, которые на этапе компиляции выясняют, тривиально ли копируется тип (тогда его можно сериализовать одним memcpy), есть ли у него пользовательская функция сериализации, является ли он контейнером, который надо обходить поэлементно.

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

Traits

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

Это решает фундаментальную проблему обобщённого кода, когда алгоритму нужно было знать что-то о типе, с которым он работает, но требовать от типа конкретных вложенных членов значит исключить из обобщённого кода всё, что этих членов не имеет (например, сырые указатели, которые «как итераторы», но не классы и членов не несут). Трейты дают нужный уровень косвенности и алгоритм сам спрашивает тип через трейт, и трейт можно доопределить снаружи для любого типа задним числом.

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

Идиому ввёл и дал ей имя Натан Майерс в статье 1995 года «Traits: a new and useful template technique», на примере того, как char и wchar_t обслуживаются одними и теми же шаблонами строк и потоков через char_traits. Дальше трейты пронизали всю стандартную библиотеку черезiterator_traits и сделали возможными обобщённые алгоритмы STL, работающие одинаково и с указателями, и с классами-итераторами, и в итоге стали одной из самых употребимых техник в C++.

Скрытый текст
// Трейт, описывающий свойства типа итератора
template <class It>
struct iterator_traits {
    using value_type = typename It::value_type;
    using difference_type = typename It::difference_type;
};

// Специализация для сырых указателей у которыех нет вложенных typedef'ов
template <class T>
struct iterator_traits<T*> {
    using value_type = T;
    using difference_type = std::ptrdiff_t;
};

// Алгоритм спрашивает трейт, а не сам тип, и потому работает с обоими:
template <class It>
typename iterator_traits<It>::value_type front_value(It it) { return *it; }

Трейты рабочий инструмент обобщённого любого низкоуровневого кода. Системы сериализации спрашивают у трейтов, как обращаться с типом, а математические библиотеки через трейты узнают «скалярный тип» вектора (float, double, half) и его размерность, чтобы один и тот же код работал для всех. Контейнеры через трейты выясняют, можно ли тип перемещать тривиально, и выбирают между memcpy и поэлементным перемещением.

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

Tag Dispatching

Если трейты дают возможность сделать "приведение типов", то теги - это способ выбрать одну из нескольких реализаций функции на этапе компиляции, опираясь на свойства типа, но без всякого if. Идея в том, чтобы превратить компайл-тайм-свойство (обычно полученное из трейта) в тип-«тег» как крошечную пустую структуру, и передать её последним аргументом в перегруженную функцию. Компилятор выберет нужную перегрузку по типу тега, и это решение примется на этапе компиляции, без рантайм-проверок.

Прелесть приёма в том, что выбор бесплатен в рантайме (пустой тег не несёт данных и оптимизируется в EBO), но при этом разные ветки могут содержать код, который для «не своих» типов вообще не скомпилировался бы. Перегрузка для случайных итераторов может делать арифметику с указателями, а перегрузка для однонаправленных уже нет, и они мирно сосуществуют, потому что компилятор инстанцирует только подходящую.

За все приходится платить, и тут мы платим излишней логикой, когда для каждого тега нужна отдельная функция-перегрузка, что намного многословнее одного if. С приходом if constexpr в C++17 многие случаи tag dispatching стало можно записать одной функцией с ветвлением по constexpr-условию, и это часто читаемее, хотя tag dispatching остаётся незаменим, когда веток много или когда нужно расширять набор перегрузок извне.

Канонический пример и фактически источник идиомы это реализация std::advance и std::distance в STL, спроектированной Степановым и там по iterator_category из трейтов выбирается оптимальная реализация: для random-access итераторов advance это будет одно сложение, для остальных будет цикл. Сам термин «tag dispatch» закрепился в литературе по STL и Boost.

Скрытый текст
struct random_access_tag {};
struct forward_tag {};

template <class It>
void advance_impl(It& it, int n, random_access_tag) { it += n; }   // O(1)

template <class It>
void advance_impl(It& it, int n, forward_tag) {                     // O(n)
    while (n-- > 0) ++it;
}

template <class It>
void advance(It& it, int n) {
    advance_impl(it, n, typename iterator_traits<It>::category{});  // тег решает
}

tag dispatching работает там же, где трейты, т.е. в обобщённых контейнерах, алгоритмах и сериализации, выбирая оптимальный путь по свойству типа без рантайм логики. Один тег для тривиально-копируемых типов уходит в быстрый memcpy, другой в поэлементный обход, и выбор делается компилятором по трейту типа. То же с математикой, где тег по размерности вектора или по наличию SIMD-поддержки уходит в специализированную реализацию.

Диспатчинг это уже часть более широкой философии «решай на этапе компиляции всё, что можно решить», которая в производительном коде ценится выше красоты кода и архитектурных паттернов, потому что вынесенное из рантайма в компайл-тайм, это убранная из хотпаса ветвь логики, которую не надо предсказывать процессору. Современный код всё чаще делает это через if constexpr и концепты, но под капотом многих библиотек, на которых стоит движок, по-прежнему работает классический tag dispatching.

Int-To-Type (Integer-to-Type Map)

Маленький, но важный гордый птиц трюк, превращающий константу времени компиляции в отдельный тип. Шаблон вида template <int N> struct Int2Type {}; для каждого значения N порождает свой уникальный тип вида Int2Type<0> и Int2Type<1>, которые уже разные типы, а не просто разные значения одного типа. Это позволяет диспетчеризовать перегрузки по числовой константе так же, как tag dispatching диспетчеризует по тегам-свойствам.

Если вспомнить, что перегрузка функций в C++ работает по типам, а не по значениям, то получается, что нельзя написать две перегрузки, различающиеся значением int-аргумента, но можно по различающиеся типами Int2Type<0> и Int2Type<1>. Так компайл-тайм-число (флаг, размерность, версия алгоритма) превращается в инструмент выбора реализации на этапе компиляции, причём ветки, неприменимые к данному числу, даже не компилируются.

За все приходится платить, и тут мы платим за старость идиомы. Она расцвела в эпоху, когда не было if constexpr, не было частичной специализации шаблонов функций и не было удобных compile-time условий, а Int2Type был обходным манёвром, чтобы хоть как-то ветвиться по числу на этапе компиляции. Сегодня почти всё, ради чего он применялся, прямее выражается через std::integral_constant, if constexpr или специализацию шаблонов.

Идиому ввёл и дал ей имя Андрей Александреску в Modern C++ Design в 2001, где Int2Type (вместе с Type2Type) был одним из базовых кирпичиков его библиотеки Loki. Он использовал её, в частности, чтобы выбирать реализацию в зависимости от того, является ли тип полиморфным, или чтобы разворачивать алгоритмы по компайл-тайм-флагу. Стандарт позже подарил эквивалент в видеstd::integral_constant, на котором держатся std::true_type и std::false_type.

Скрытый текст
template <int N> struct Int2Type {};

// Разные реализации для разных компайл-тайм-флагов оптимизации
template <class T> void process(T* data, int n, Int2Type<0>) { /* скалярный путь */ }
template <class T> void process(T* data, int n, Int2Type<1>) { /* SIMD-путь       */ }

template <int SimdLevel, class T>
void process(T* data, int n) {
    process(data, n, Int2Type<SimdLevel>{});   // число выбирает реализацию
}

В разработке прямое применение Int2Type сегодня встретишь нечасто, как я сказал выше, его вытеснили более новые средства языка, но идея «компайл-тайм-число выбирает реализацию» абсолютно жива и пронизывает весь хотпас рендера. Развёртка циклов на фиксированное число итераций, выбор пути по уровню SIMD (SSE/AVX/NEON), специализация шейдерных мутаций, размерность математических объектов. Всё это диспетчеризация по компайл-тайм-числу, просто записанная современным синтаксисом.

Так что Int2Type стоит знать в первую очередь как исторический ключ к чтению старого кода и как наглядную иллюстрацию принципа, который никуда не делся. Если вы видите в современном коде std::integral_constant<int, N> или std::true_type/std::false_type, передаваемые в перегрузку, это прямые потомки Int2Type, делающие то же самое.

Type Selection

Это уже компайл-тайм-аналог тернарного оператора, только выбирающий не значение, а тип. Метафункция (канонически Select<bool, T, U> или стандартная std::conditional<bool, T, U>) по булевой константе времени компиляции возвращает либо тип T, либо тип U, что даёт возможность писать обобщённый код, который в зависимости от условия использует разные типы, и решается это, естественно, на этапе компиляции.

Реализуется через специализацию шаблона по булеву параметру, когда основной шаблон для true хранит в ::type первый тип, а частичная специализация для false второй. Компилятор, инстанцируя нужную версию, подставляет соответствующий тип, и дальше остальной код работает с выбранным типом, как будто он был написан явно.

Это нужно, чтобы обобщённый код подстраивал внутренние типы под параметры. Например, контейнер может выбирать тип итератора в зависимости от того, константный он или нет, или математический шаблон может выбирать тип аккумулятора, чтобы не терять точность, или обёртка может хранить значение по значению для маленьких типов и по ссылке/указателю для больших.

За все приходится платить, и тут мы платим избыточностью логики. Обе ветки Select должны быть корректными типами, даже невыбранная, потому std::conditional инстанцирует ВСЕ аргументы, так что подсунуть туда тип, который для данного случая невалиден, не выйдет без дополнительных ухищрений.

И снова эта идиома пришла из арсенала Александреску в Modern C++ Design, где Select был базовым инструментом его метапрограммирования. Сейчас стандарт закрепил её как std::conditionalstd::conditional_t) в <type_traits> C++11, после чего она стала повседневным инструментом, лежащим в основе бесчисленных шаблонных классов, которым нужно выбрать внутренний тип по условию.

Скрытый текст
template <bool B, class T, class U> struct Select      { using type = T; };
template <class T, class U>          struct Select<false, T, U> { 
  using type = U; 
};

// Маленькие типы храним по значению, большие — по const-ссылке
template <class T>
struct StorageFor {
    using type = typename Select<(sizeof(T) <= sizeof(void*)), T, const T&>::type;
};

// Аккумулятор пошире, чтобы не переполнялся при суммировании
template <class T>
using accumulator_t = std::conditional_t<std::is_integral_v<T>, 
                                         std::int64_t, double>;

Встречается в обобщённой математике, контейнерах и системах, которые подстраивают раскладку данных под параметры. Шаблон вектора может выбирать между скалярным и SIMD-представлением по размерности или между inline-хранением маленьких объектов и хранением в куче больших, что и есть основа small object optimization, к которой мы ещё придём.

SFINAE (Substitution Failure Is Not An Error)

Это правило языка, на котором держится половина шаблонной магии C++, и звучит оно так: если при подстановке шаблонных аргументов в кандидата на перегрузку получается некорректный тип или выражение, это не ошибка компиляции, а всего лишь повод выкинуть этого кандидата из рассмотрения. Компилятор не падает в истерке, выдавая простыню нечитаемого лога, а вычёркивает неподходящую перегрузку из списка и продолжает искать подходящую среди остальных.

Это превращает «провал подстановки» из ошибки в инструмент намеренного конструирования перегрузок, так чтобы для одних типов подстановка удавалась, а для других проваливалась, и тем самым включать или выключать перегрузки в зависимости от свойств типа.

На базе этого механизма построены все enable_if, детекторы членов, и до появления концептов вообще все способы сказать «эта функция существует только для типов, удовлетворяющих такому-то условию».

За все приходится платить, и тут мы расплачиваемся понимаемостью кода. SFINAE заслужено поселился в репертуаре чёрных магов метакнижников, и заслуженно. Корректно работает только «непосредственный контекст» подстановки, а если ошибка происходит в глубине тела функции, то компилятор впадает в истерику и выдает нечитаемые сообщения об ошибках. Решилось это только концептами, которые по сути, стали человеко-читаемой обёрткой над тем же отбором перегрузок, но делающим большую часть SFINAE-трюков ненужными.

Сам термин и расшифровку «Substitution Failure Is Not An Error» ввёл Дэвид Вандевурд, а механизм формализовался по мере взросления шаблонов в девяностых, а в широкий С++ его принесли Вандевурд и Йосаттис в C++ Templates: The Complete Guide и долгие годы SFINAE был единственным способом делать «условные» шаблоны, а владение черной магией считалось признаком серьёзного шаблонного программиста.

Скрытый текст
// Перегрузка "включается" только для интегральных типов;
// для прочих подстановка enable_if проваливается — и это не ошибка
template <class T>
std::enable_if_t<std::is_integral_v<T>, T> half(T x) { return x / 2; }

template <class T>
std::enable_if_t<std::is_floating_point_v<T>, T> half(T x) { return x * 0.5; }

// half(10) выберет первую, half(3.0f) — вторую; неподходящие просто отсеялись

Так сложилось, что SFINAE идет рука об руку с игровыми движками, хоть его и почти не пишут руками сегодня, но оно лежит под капотом всего, что адаптирует поведение к свойствам типа. Системы сериализации, рефлексии, обобщённых математических библиотек, контейнеров. В современных движках, перешедших на C++17/20, SFINAE всё чаще заменяют на if constexpr и концепты, которые делают то же самое читаемо и с человеческими сообщениями об ошибках. Но понимать SFINAE по-прежнему необходимо, потому что на нём написана огромная масса существующего кода, включая стандартную библиотеку и Boost, и ближашие лет десять они никуда не денутся.

enable-if

Это уже проявления SFINAE, которе стало практическим инструментом разработки. Сама по себе это крошечная метафункция: enable_if<Condition, T> имеет вложенный ::type, равный T, если Condition истинно, и не имеет вложенного ::type вообще, если ложно. Вот это «не имеет ::type» и есть провал подстановки, который по правилу SFINAE отключает перегрузку.

Подставляя enable_if в сигнатуру шаблона, вы фактически вешаете на перегрузку условие «активна только если» и условий можно навесить несколько, разведя по разным перегрузкам так, чтобы для любого типа была активна ровно одна, тем самым реализовать выбор реализации по произвольным свойствам типа на этапе компиляции.

За все приходится платить, и тут ценой будет синтаксическая громоздкость. Код с тяжёлым enable_if тяжело читать и ещё тяжелее отлаживать, поэтому C++20 предложил концепты и сокращённый синтаксис requires как прямую, читаемую замену, оставив enable_if уделом совместимости со старыми стандартами.

enable_if родился в Boost (boost::enable_if) в нулевых, основным родителями были Яакко Ярви, Джереми Сик, и подавался как переиспользуемая обёртка над SFINAE-трюками, которые до того каждый изобретал заново. В C++11 он вошёл в стандарт как std::enable_ifstd::enable_if_t добавили в C++14 для краткости) и на десятилетие стал основным способом писать условные шаблоны, пока его не потеснили концепты.

Скрытый текст
// Активна только для типов, у которых есть метод .gpu_upload()
template <class T,
          class = std::enable_if_t<has_gpu_upload_v<T>>>
void upload(T& resource) { resource.gpu_upload(); }

// C++20 — то же самое, но читаемо:
template <class T>
    requires has_gpu_upload_v<T>
void upload2(T& resource) { resource.gpu_upload(); }

Как и остальные трюки enable_if встречается в обобщённом коде движков, написанном до повсеместного перехода на C++20: математика, контейнеры, сериализация, системы отражения типов. Позволяет одной перегрузкой обслужить, например, все арифметические типы, а другой все типы с пользовательским методом. Если ваш компилятор поддерживает C++20, предпочитайте концепты и requires вместо enable_if, потому что они дают на порядок более понятные ошибки, что в большой команде экономит реальные часы вашего времени, но enable_if уже никуда от нас не денется, и читать его уметь обязательно как "лингва франка" шаблонов доконцептной эпохи.

Member Detector

Брат предыдущего трюка, позволяющий на этапе компиляции узнать, есть ли у типа определённый метод с заданным именем, вложенный тип или поле. По сути это еще одна специализированная метафункция-предикат, отвечающая «да/нет» на вопрос «а есть ли у T метод serialize?» или «а определён ли у T вложенный тип iterator?», и ответ доступен как constexpr bool .

Классическая реализация это синтаксическая акробатика на SFINAE с трюком «sizeof двух разных типов», когда делаются две перегрузки вспомогательной функции и одна принимает что-то, что валидно только при наличии искомого члена (через decltype от обращения к нему), а другая «всеядная» с многоточием, но ловит всё остальное и потому выбирается, только когда первая отвалилась по SFINAE, ага, опять он, мы еще долго будем к нему возвращаться.

Возвращаемые типы у них разного размера, и по sizeof результата определяется, какая перегрузка выбралась, а значит есть ли член. За все приходится платить, тут мы платим за то, что не используем, вот прям "слово в слово", и это, пожалуй, самая уродливая из всех SFINAE-идиом, с её фиктивными типами yes/no, многоточиями и decltype-выражениями, которые надо составлять, иначе детектор всегда говорит «нет».

C++ долго не имел нормального способа это делать, и каждый писал свой детектор с новыми куртизанками и преферансом, пока положение на исправил std::experimental::is_detected, а затем концепты C++20, в которых проверка наличия члена пишется одной строкой requires.

Идиома кристаллизовалась в эпоху Boost и ранних библиотек метапрограммирования (характерные макросы вроде BOOST_MPL_HAS_XXX появились именно для генерации таких детекторов), а её каноничную современную форму с is_detected предложил Уолтер Браун в одном из пропозалов к стандарту, обобщив весь этот зоопарк ручных куртизановк в один переиспользуемый механизм.

Скрытый текст
// Детектор метода serialize(Archive&) через SFINAE + decltype
template <class T, class = void>
struct has_serialize : std::false_type {};

template <class T>
struct has_serialize<T, std::void_t<decltype(std::declval<T&>().serialize(std::declval<Archive&>()))>>
    : std::true_type {};

// C++20 — одной строкой:
template <class T>
concept Serializable = requires(T& t, Archive& a) { t.serialize(a); };

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

В итоге движок получается возможность вызвать у компонента метод on_attach, если тот его определил, и просто ничего не делать, если нет. Современные движки могут выражать это концептами, но идея «обнаружить возможность типа на этапе компиляции и сгенерировать соответствующий код» остаётся ровно member detector, и без неё обобщённые системы движка были бы либо негибкими, либо утыканными рантайм-проверками.

Policy-based Design

Если вы внимательно прочитали три предыдущих секции, то сможете легко понять этот подход. Теперь поведение класса не зашивается намертво, а собирается из взаимозаменяемых «политик-enable-if», переданных шаблонными параметрами. Каждая политика отвечает за один ортогональный аспект (как выделять память, как считать ссылки, как проверять границы, потокобезопасно ли это), и хост-класс наследует или хранит эти политики, комбинируя их в готовый тип. Меняя политику, вы меняете один аспект поведения, не трогая остальные и не платя за рантайм-гибкость.

Отличие от наследования с виртуальными функциями в режиме выполнения. Политика, переданная типом, инлайнится, её методы вызываются напрямую, пустые политики схлопываются через EBO и получается комбинаторная гибкость стратегий без единого виртуального вызова.

За все приходится платить, и тут цена это комбинаторика связей. Имена типов становятся монструозными (SmartPtr<Widget, RefCounted, NoChecking, SingleThreaded>), а сами политики должны быть тщательно спроектированы как ортогональные, иначе они начинают неявно зависеть друг от друга, и вся эта стройность рассыпается. Плюс гибкость фиксируется во время компиляции и выбрать политику в рантайме уже нельзя.

Это центральная идея книги Андрея Александреску Modern C++ Design 2001 года, и именно он ввёл термин «policy» и «policy-based design», показав на примере умного указателя и других компонентов библиотеки Loki, как из политик собирать классы. Подход оказал огромное влияние на стандартную библиотеку, аллокаторы и контейнеры, char_traits в строках, deleter в unique_ptr, компараторы. Всё выросло из работ Александреску.

Скрытый текст
// Две ортогональные политики: потокобезопасность и проверка
struct SingleThreaded { void lock() {} void unlock() {} };
struct MultiThreaded  { std::mutex m; void lock(){m.lock();} void unlock(){m.unlock();} };

struct NoChecking   { static void check(void*) {} };
struct AssertChecked { static void check(void* p) { assert(p); } };

template <class T, class Threading = SingleThreaded, class Checking = NoChecking>
class SmartPtr : private Threading, private Checking {  
    // EBO: пустые политики бесплатны
    T* p_;
public:
    T* operator->() { Checking::check(p_); return p_; }
};

using FastPtr = SmartPtr<Widget>;                              // ничего лишнего
using SafePtr = SmartPtr<Widget, MultiThreaded, AssertChecked>; // всё включено

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

Policy Clone (Metafunction wrapper)

Узкая вспомогательная идиома из александреску ворлд, решающая конкретную проблему «переноса» политики с одного типа на другой. Когда у вас есть, скажем, умный указатель SmartPtr<Foo, SomePolicy> и нужно получить SmartPtr<Bar, тойжеполитики>, то политика не всегда переносится напрямую, потому что многие политики сами шаблонны и параметризованы типом хоста. Здесь нужен механизм «клонировать политику, перенастроив её на новый тип».

Решается это через новую метафункцию-обёртку (отсюда второе имя, «metafunction wrapper»), когда политика предоставляет вложенный шаблон-«ребиндер» вида template <class U> struct rebind { using other = Policy<U>; };, который умеет породить версию той же политики для другого типа. Хост-класс, когда ему нужно сконвертировать себя в версию для другого типа, обращается к этому ребиндеру и получает корректно перенастроенную политику.

За всё... ну вы поняли. Клонирование политик довольно эзотерическая деталь, важная в основном авторам стандартной библиотек, и большинство прикладных программистов о ней никогда не узнают. Она нужна там, где политики сами зависят от типа хоста и должны уметь «переезжать» вместе с ним при конверсиях, и за пределами таких сценариев выглядит избыточной церемонией.

Самый известный пример того же механизма в стандарте — rebind у аллокаторов: allocator<T>::rebind<U>::other даёт allocator<U>, и так контейнеры получают аллокаторы для своих внутренних узлов (списку нужен аллокатор не T, а Node<T>).

Скрытый текст
template <class T>
struct PoolPolicy {
    template <class U> struct rebind { using other = PoolPolicy<U>; }; 
    // клон для U
    T* allocate(std::size_t n) { /* ... */ }
};

// Контейнеру нужен аллокатор не для T, а для своего узла Node<T>:
template <class T, class Alloc = PoolPolicy<T>>
class List {
    struct Node { T value; Node* next; };
    using NodeAlloc = typename Alloc::template rebind<Node>::other;
    // тот же пул, но для Node
    NodeAlloc node_alloc_;
};

В играх policy clone в чистом виде почти не встречается, по той же причине что и в обычной разработке, но он работает под капотом любого кастомного контейнера движка, который параметризуется аллокатором. Движки пишут свои контейнеры вместо стандартных ради контроля над памятью, и если такой контейнер уважает интерфейс аллокаторов, ему приходится поддерживать rebind, чтобы выделять память не под пользовательский тип, а под внутренние узлы, иначе список или дерево не сможет завести аллокатор для своих служебных структур.

Так что практическая ценность этой идиомы для программиста, просто понимать, зачем в аллокаторах живёт rebind и почему кастомный аллокатор обязан его предоставлять. Чтобы дружить со стандартными и движковыми контейнерами.

Это одна из тех вещей, которые незаметны, пока вы не пишете свой аллокатор, и тогда внезапно оказывается, что без policy clone контейнеры его не принимают.

Type Erasure

Это способ получить полиморфизм без общего базового класса и спрятать конкретный тип объекта за единым внешним интерфейсом, чтобы снаружи все объекты выглядели одинаково. Классический пример из стандарта будет std::function: в неё можно положить и лямбду, и указатель на функцию, и функтор, и они не имеют общего предка, но std::function<int(int)> обращается со всеми одинаково.

Работает это через комбинацию шаблонов и виртуальных функций, спрятанную внутри обёртки. Обёртка хранит указатель на маленький внутренний интерфейс с виртуальными методами, а конкретная реализация этого интерфейса шаблонная, параметризованная фактическим типом, и она перенаправляет вызовы реальному объекту.

За гибкость рантайм-полиморфизма приходится платить виртуальным вызовов и обычно аллокацией в куче под спрятанный объект (хотя маленькие объекты часто кладут inline через small object optimization). То есть это медленнее статического полиморфизма CRTP и шаблонов, и в хотпасе type erasure применяют осознанно.

Зато он развязывает руки, там где не нужен общий базовый класс, чтобы типы сделать значениями, чтобы их можно хранить в обычных контейнерах.

Идиома вызревала постепенно со времен boost::any (Кевлин Хенни, начало нулевых) и boost::function (Дуглас Грегор), которые были первыми массовыми примерами, термин «type erasure» закрепился в C++-сообществе примерно тогда же. В стандарт это вошло как std::function, std::any (C++17) и отчасти std::shared_ptr с его стиранием типа удалителя.

Скрытый текст
// Стираем тип всего, что умеет render(): общего базового класса не требуется
class AnyDrawable {
    struct Concept { 
      virtual ~Concept() = default; 
      virtual void render() const = 0; 
    };
  
    template <class T> struct Model : Concept {
        T obj;
        Model(T o) : obj(std::move(o)) {}
        void render() const override { obj.render(); }   // утиная типизация
    };
    std::unique_ptr<Concept> self_;
public:
    template <class T> AnyDrawable(T o) : 
         self_(std::make_unique<Model<T>>(std::move(o))) {}
    void render() const { self_->render(); }
};

// Sprite, ParticleSystem, DebugText — не связаны наследованием, но лежат вместе:
std::vector<AnyDrawable> scene;

type erasure ценят за развязку зависимостей, иstd::function используют для коллбэков, обработчиков событий, отложенных задач в job-системе, а стёртые по типу хендлеры команд наподобие std::any для свойств в редакторе и системах данных. Но в хотпасе его избегают по понятным причинам, из-за виртуальных вызовов плюс возможной аллокации на каждый объект. Поэтому в движках type erasure живёт в слое инфраструктуры (события, задачи, редактор, скриптовые мосты), а ядро симуляции предпочитает статический полиморфизм и плотные массивы.

Type Generator (Templated Typedef)

Это шаблон, единственная задача которого это собирать и отдавать тип. Вы передаёте ему параметры, а он во вложенном ::type выдаёт сконструированный по ним тип, часто весьма сложный (вложенные контейнеры, инстанцированные шаблоны, цепочки политик). По сути это «фабрика типов», работающая на этапе компиляции, чтобы спрятать громоздкую конструкцию типа за коротким, осмысленным именем и параметризовать её.

Историческая мотивация была в том, что до C++11 не было шаблонных псевдонимов типов (template <...> using), и нельзя было написать «частично специализированный typedef». Если вам был нужен, например, «map со строковым ключом и любым значением», вы не могли объявить это как параметризуемый псевдоним напрямую и приходилось заворачивать в структуру с вложенным ::type и обращаться через typename Gen<V>::type. Type Generator был обходным путём вокруг этого пробела.

Платить за это приходится громоздкостью обращения (typename ...::type повсюду) и тем, что идиома в значительной степени устарела. C++11 ввёл alias templates (template <class V> using StringMap = std::map<std::string, V>;), которые делают ровно то же самое прямо и без вложенного ::type. Поэтому сегодня Type Generator в старом виде нужен лишь там, где результат вычисляется метафункцией (тогда ::type неизбежен), а простые случаи закрываются alias-шаблонами.

Это уже часть фольклора C++ девяностых и нулевых, зафиксированная в каталоге More C++ Idioms, её активно использовали Loki и Boost.MPL, где «типовые функции» возвращали типы через ::type сплошь и рядом.

Скрытый текст
// До C++11: генератор типа через структуру с ::type
template <class Value>
struct ComponentStorage {
    using type = std::unordered_map<EntityId, Value>;   
    // громоздкая конструкция
};
typename ComponentStorage<Transform>::type transforms;

// C++11 и позже — то же самое:
template <class Value>
using ComponentStorageT = std::unordered_map<EntityId, Value>;
ComponentStorageT<Transform> transforms2;

Идея «дать сложному типу короткое параметризуемое имя» все еще живет, просто записывается она теперь alias-шаблонами, а не структурами с ::type. Тот же ECS-движок объявляет template <class C> using Storage = ... для хранилища компонентов, а рендер заводит alias для типизированных хендлов, а математическая библиотека прячет за коротким именем длинную инстанциацию шаблона вектора с политиками выравнивания.

Старую длинную форму Type Generator с ::type в новом коде писать уже незачем, кроме случаев, когда тип реально вычисляется метафункцией. Но знать её надо, чтобы читать доконцептный и до-C++11 код движков и библиотек, где typename SomeGenerator<T>::type встречается на каждом шагу, когда у языка ещё не было нормального синтаксиса для параметризованных псевдонимов.

Thin Template

Это уже архитектурный приём борьбы с раздуванием кода (code bloat), которым печально знаменито шаблонное программирование. Проблема в том, что компилятор генерирует отдельную копию всего шаблонного кода для каждой комбинации аргументов: vector<int>, vector<float>, vector<Foo*>, которые будет полными копиями для всех методов, хотя по машинному коду они могут быть идентичны. А если в проекте у вас сотни таких объектов, то такой подходи раздувает бинарник и бьёт по кешу инструкций.

Идея thin template была вынести всю логику, не зависящую от конкретного типа, в общую нешаблонную (или менее параметризованную) базу, а в тонком шаблонном слое оставить только тайп-специфичные обёртки, которые приводят типы и делегируют в общую реализацию. Классический пример будетvector<T*> для всех типов указателей, когда машинный код работы с указателями одинаков независимо от того, на что они указывают, так что можно реализовать всё один раз для void* и тонко обернуть это типизированным фасадом.

Ценой будет приведение типов внутри (обычно через void* и reinterpret_cast), что требует аккуратности и может подорвать типобезопасность, если ошибиться. Плюс тонкий слой должен быть действительно тонким и инлайниться в ничто, иначе вы заплатите за делегирование вызовом. Ну и не всякую логику удаётся обезличить, поэтому, что реально зависит от типа (вызов конструкторов, деструкторов, копирование значений), вынести в общую базу нельзя.

Идиому описал Джон Лакос в Large-Scale C++ Software Design еще в 1996 году в контексте управления большими системм, и стандартная библиотека применяет её на практике в качестве реализаций std::vector, которые действительно специализируют хранение указателей через общую void*-базу, чтобы не плодить идентичный код для каждого типа указателя.

Скрытый текст
// Толстая логика
// один раз, нетипизированно
class VectorBase {
protected:
    void** data_; std::size_t size_, cap_;
    void push_back_ptr(void* p);   // вся механика роста/копирования указателей здесь
    void* at_ptr(std::size_t i) const { return data_[i]; }
};

// Тонкий типизированный фасад — только приведения, всё инлайнится
template <class T>
class PtrVector : private VectorBase {
public:
    void push_back(T* p) { push_back_ptr(p); }
    T* operator[](std::size_t i) { return static_cast<T*>(at_ptr(i)); }
};
// PtrVector<Enemy>, PtrVector<Item>, 
// ... делят один и тот же машинный код базы

Борьба с раздуванием шаблонов ведется не только в игровых проектах, но особенно это было заметно на консолях прошлых поколений с их очень скромным кешем инструкций и ограничениями на размер исполняемого файла. Лишние мегабайты почти идентичного шаблонного кода могли буквально тормозить исполнение, поэтому движки часто применяли thin template к контейнерам указателей и к обобщённым структурам, чья логика не зависит от типа.

В современном геймдеве thin template вручную уже не пишут, полагаясь на то, что компоновщик схлопнет идентичный код (COMDAT folding, /OPT:ICF у MSVC), а компилятор заинлайнит тонкие обёртки. Но на старых платформах, а это почти вся история консолей, с которой начиналась прошлая статья, осознанное применение thin template к самым растиражированным шаблонам окупалось с лихвой, и понимать механику раздувания шаблонов тоже надо.

Named Template Parameters

Попытка дать шаблонам с большим числом параметров что-то вроде именованных аргументов, чтобы не зависеть от позиции и не вписывать дефолтные параметры только ради того, чтобы добраться до последнего.

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

Идиома решает это, превращая порядок в безразличный. Параметры заворачиваются в специальные типы-обёртки (threading<MultiThreaded>, checking<AssertChecked>), которые можно передавать в любом порядке, а шаблон затем метапрограммированием разбирает этот набор, для каждого аспекта находит соответствующий обработчик и собирает итоговую конфигурацию. Снаружи это выглядит почти как именованные аргументы, когда вы указываете только то, что хотите изменить, и подписываете, что именно.

Платить за это приходися нетривиальной и тяжелой реализацией. За удобный синтаксис вызова приходится платить изрядным метапрограммированием внутри (разбор набора обёрток, поиск по типам, подстановка дефолтов), а это и сложность сопровождения, и время компиляции, и фирменные нечитаемые ошибки. Поэтому идиома всегда была уделом серьёзных библиотек, готовых вложиться в эргономику ради своих пользователей, а не повседневным приёмом.

Каноническая реализация появилась в boost::parameter (Дэниел Уоллин, Дэйв Абрахамс), а также named template parameters в Boost.Graph, где у графа действительно много настраиваемых аспектов и позиционный синтаксис был бы невыносим. В новых стандартах проблема большей частью ушла благодаря designated initializers (C++20) для конфигов-агрегатов и в целом тенденции передавать настройки структурой, а не россыпью шаблонных параметров.

Скрытый текст
// Обёртки-«имена» можно передавать в любом порядке
template <class P> struct threading { using type = P; };
template <class P> struct checking  { using type = P; };

// Хост разбирает набор, подставляя дефолты для неуказанного (схематично):
template <class... Options>
class Allocator {
    using Threading = typename find_option<threading, SingleThreaded, Options...>::type;
    using Checking  = typename find_option<checking,  NoChecking,     Options...>::type;
};

// Указываем только нужное, порядок не важен:
using A = Allocator<checking<AssertChecked>>; // threading по умолчанию
using B = Allocator<threading<MultiThreaded>, checking<AssertChecked>>;

named template parameters в полном boost-овском виде встречаются редко, потому что это тяжёлая артиллерия для библиотек с по-настоящему большой матрицей настроек. Гораздо чаще движки идут более прагматичным путём и собирают настройки в один тип-конфиг (структуру traits) и передают его одним шаблонным параметром, а внутри читают её поля. Это проще, компилируется быстрее и читается яснее, пусть и менее «магично».

Тем не менее знать идиому полезно, чтобы ориентироваться в библиотеках с богатой конфигурацией (графовых, геометрических, вычислительных), которые вы иногда подключаете, и чтобы понимать, какую проблему решают современные альтернативы вроде конфиг-структур и designated initializers. Суть везде одна, что когда настроек много, то привязка к позиции параметров становится болью, и нужно дать им имена тем или иным способом.

Coercion by Member Template

Это идиома, позволяющая шаблонному типу-обёртке поддерживать те же неявные преобразования, что и тип, который он оборачивает. Проблема в том, что SmartPtr<Derived> и SmartPtr<Base> с точки зрения системы типов, совершенно разные инстанциации одного шаблона, между которыми нет никакой связи, даже если Derived публично наследует Base. То есть сырой Derived* свободно конвертируется в Base*, а вот SmartPtr<Derived> в SmartPtr<Base> уже нет, и это раздражает.

Лечится это добавлением в обёртку шаблонного конструктора (и/или шаблонного оператора присваивания), параметризованного другим типом-аргументом: template <class U> SmartPtr(const SmartPtr<U>& other). Внутри он просто пытается присвоить внутренний U* своему T*, и если это присваивание легально (то есть U* конвертируется в T*), конструктор компилируется и работает, а если нет, то проваливается по SFINAE и не мешает, позволяя обёртке наследовать те преобразования, которые допустимы для обёрнутых указателей.

Тут нужно не дать этому шаблонному конструктору перехватывать то, что должно идти в обычный конструктор копирования (шаблонный конструктор никогда не считается копирующим, и их легко перепутать), и не открыть случайно недопустимые преобразования. Плюс надо продумать const-преобразования и преобразования вверх по иерархии так, чтобы они работали, а вниз не работали, повторяя семантику сырых указателей.

Этот подход стал стандартной часть реализации любого умного указателя и её детально разбирали и Александреску в Modern C++ Design, и авторы Boost.SmartPtr, и так работает std::shared_ptr<Derived> неявно конвертируясь в std::shared_ptr<Base>, а unique_ptr<Derived> move-присваивается в unique_ptr<Base>.

Скрытый текст
template <class T>
class RefPtr {
    T* p_ = nullptr;
public:
    RefPtr(T* p) : p_(p) { if (p_) p_->add_ref(); }

    // member template: разрешает RefPtr<Derived> -> RefPtr<Base>,
    // если Derived* конвертируется в Base* (иначе SFINAE отсекает)
    template <class U>
    RefPtr(const RefPtr<U>& o) : p_(o.get()) { if (p_) p_->add_ref(); }

    T* get() const { return p_; }
};

RefPtr<Texture> tex = new Texture();
RefPtr<Resource> res = tex;   // работает: Texture наследует Resource

В разработке эта идиома незаметна, но используется везде, где есть кастомные умные указатели и хендлы на ресурсы, образующие иерархию. Движок с собственным TRefPtr или ComPtr-подобным указателем обязан поддерживать преобразование «указатель на конкретный ресурс → указатель на базовый ресурс», иначе работать с иерархиями ресурсов было бы невыносимо и вы не смогли бы передать RefPtr<Texture> в функцию, принимающую RefPtr<Resource>.

Поскольку движки часто пишут свои умные указатели (ради интрузивного счётчика, особой потокобезопасности или интеграции с RHI), они вынуждены реализовывать coercion by member template руками, повторяя поведение стандартных умных указателей.

Shortening Long Template Names

Это скорее набор приёмов против раздражающих особенностей шаблонного C++, когда имена инстанцированных типов разрастаются до каких-то совсем диких размеров. std::map<std::string, std::vector<std::shared_ptr<const Entity>>, std::less<>, MyAllocator<...>> и это ещё скромный пример. Такие имена засоряют код, а когда всплывают в сообщениях об ошибках, то делают отладку нечитаемой.

Базовые приёмы укорачивания это делать псевдонимы типов (using/typedef) для часто используемых инстанциаций, alias-шаблоны для параметризованных семейств, и вынос длинных конструкций в локальные using-объявления внутри функций, чтобы в коде фигурировало короткое осмысленное имя (EntityMap, Handle), а длинная конструкция была определена один раз в одном месте.

Более продвинутые приёмы касаются именно сообщений об ошибках, когда длинный тип специально заворачивают в отдельный именованный класс (не псевдоним, а настоящий тип-наследник или обёртку), чтобы в диагностике компилятора и в отладчике фигурировало короткое имя вместо развёрнутой шаблонной простыни. Псевдоним типа прозрачен для компилятора и не помогает в ошибках (он все равно раскрывается до полного типа), а вот отдельный именованный тип помогает, но добавляет реальную сущность со своими тонкостями (конструкторы, преобразования).

Проблема укорачивания стара как сами шаблоны, и приёмы борьбы с ней вошли в программистский фольклор, и все это выросло вauto (C++11), который избавил от необходимости выписывать длинные типы в объявлениях переменных, и alias-шаблоны, которые дали параметризуемые короткие имена.

Скрытый текст
// Длинно и сложно при каждом упоминании
std::unordered_map<EntityId, std::vector<std::shared_ptr<Component>>> components;

// Короткий псевдоним определён один раз
using ComponentList = std::vector<std::shared_ptr<Component>>;
using ComponentMap  = std::unordered_map<EntityId, ComponentList>;
ComponentMap components2;

// auto убирает длинное имя там, где тип и так хорошо виден
for (auto& [id, list] : components2) { /* ... */ }

Укорачивание это чистая прагматика читаемости и удобства отладки, и почти все используют псевдонимы для всех составных типов или типизированные хендлы, контейнеры компонентов, мапы ресурсов. Это улучшает опыт отладки и в окне watch отладчика короткий EntityMap несравнимо полезнее, чем развёрнутая на три строки шаблонная конструкция.

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

Expression-template

Одна из самых эффектных идиом C++, превращающая арифметические выражения над объектами в типы, которые описывают вычисление, но не выполняют его сразу. Когда вы пишете a + b c для векторов, наивная перегрузка операторов создаст временный вектор для b c, потом ещё один для сложения, и каждый из них это будет аллокацией и отдельным проходом по памяти. Но Expression templates вместо этого строят на этапе компиляции дерево типов, описывающее «сложить a с произведением b и c», и реальное вычисление откладывается до момента присваивания.

Это позволяет устраненить временные объекты, а дерево выражения, собранное из типов, при присваивании разворачивается компилятором в один проход по элементам, где для каждого индекса сразу вычисляется a[i] + b[i] * c[i], без промежуточных массивов и лишних проходов по памяти. Для больших векторов и матриц это разница между несколькими проходами с аллокациями и одним проходом без единой временной аллокации дает огромный выигрыш.

За все приходится платить, и тут мы платим сложностью реализации и хрупкостью поддержки. Реализация expression templates очень нетривиальна, типы выражений чудовищны (Add<Vec, Mul<Vec, Vec>>), а сообщения об ошибках ужасны, впрочем как и любые сообщения об ошибках в шаблонах. Плюс отладка такого кода то еще удовольствие. Но с приходом хороших автовекторизаторов и компиляторов, которые сами неплохо устраняют временные объекты, выгода expression templates в простых случаях сильно упростилась, но для библиотек линейной алгебры она остаётся существенной.

Идиому изобрёл и описал Тодд Велдхейзен в 1995 году в статье «Expression Templates», параллельно похожее независимо предложил Дэвид Вандевурд. Велдхейзен построил на ней библиотеку Blitz++, целью которой было догнать Fortran по скорости численных расчётов на C++. Сегодня на expression templates стоят флагманские библиотеки линейной алгебры вроде Eigen, Blaze, Armadillo, и этим они обеспечивают себе репутацию "быстрых как рукописный код" решений.

Скрытый текст
// Узел дерева выражения вместо немедленного вычисления
template <class L, class R>
struct VecSum {
    const L& l; const R& r;
    float operator[](std::size_t i) const { 
      return l[i] + r[i]; 
    }  // лениво, поэлементно
    std::size_t size() const { return l.size(); }
};

template <class L, class R>
VecSum<L, R> operator+(const L& l, const R& r) { 
  return {l, r}; 
}   // строим дерево, не считаем

// Присваивание разворачивает всё дерево в ОДИН цикл без временных векторов:
Vec result = a + b + c;   // result[i] = a[i] + b[i] + c[i], один проход

В разработке игр expression templates применяют осторожно, несмотря на попрулярность самого подхода, в основном там, где живёт линейная алгебра, симуляции, физика и обработка больших массивов данных. Или когда движок использует библиотеку вроде Eigen для математики.

А вот в повседневной игровой математике (короткие векторы из трёх-четырёх компонент, перемножение матриц 4×4) expression templates обычно избыточны. Потому что выражения мелкие, временные объекты крошечные и живут в регистрах, а компилятор и так всё инлайнит и устраняет. Поэтому большинство движков для своей Vec3/Mat4-математики обходятся обычными перегрузками операторов, приберегая тяжёлую артиллерию expression templates для больших численных задач, где она действительно окупается.

The result_of technique

result_of это техника вычисления на этапе компиляции типа, который вернёт вызов некоторого вызываемого объекта с заданными типами аргументов. Звучит стремно, сам вижу, но это фундаментальная потребность обобщённого кода, когда шаблон, принимающий произвольный функтор F и аргументы, должен как-то объявить тип, который он получит, вызвав F с этими аргументами. Без механизма «спросить у типа функции, что она вернёт» обобщённые алгоритмы над вызываемыми объектами писать невозможно.

Историческая сложность была в том, что до C++11 не было decltype, и узнать тип результата вызова напрямую язык не позволял, поэтому приходилось требовать от функторов предоставлять вложенный result_type или специальный шаблон result<...>, по соглашению описывающий тип возврата, и result_of лазил за этой информацией. Это работало только для функторов, согласных следовать протоколу, и ломалось на обычных функциях и лямбдах, которые ничего такого не объявляли.

За все приходится платить, и тум ценой становится зависимость от протокола и общая хрупкость старого std::result_of, который к тому же имел неуклюжий синтаксис и неопределённое поведение для невызываемых комбинаций. C++11 дал decltype, который позволяет спросить тип результата напрямую у любого вызываемого, и на нём построили decltype(std::declval<F>()(std::declval<Args>()...)). C++17 ввёл std::invoke_result как чистую замену, а std::result_of объявил устаревшим и затем удалил.

Идиома и сам result_of пришли из Boost (boost::result_of), где остро стояла задача типизировать результат вызова произвольных функторов в функциональных утилитах. В стандарт это вошло как std::result_of (C++11), затем переродилось в std::invoke_result (C++17), вобрав в себя уроки и распространившись на указатели на члены и прочие хитрые вызываемые сущности через единый механизм.

Скрытый текст
// Обобщённая функция-обёртка: какой тип вернёт вызов F с Args?
template <class F, class... Args>
auto invoke_and_log(F&& f, Args&&... args)
    -> std::invoke_result_t<F, Args...>            
    // C++17: тип результата вызова
{
    log("calling");
    return std::forward<F>(f)(std::forward<Args>(args)...);
}

// До C++17 это писали через std::result_of<F(Args...)>::type

Exploding Return Type

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

Достигается это объектом-посредником с набором перегруженных операторов преобразования (operator T() для разных T). Функция возвращает этот посредник, а дальше, когда результат присваивают переменной конкретного типа или передают в типизированный аргумент, срабатывает соответствующий оператор преобразования, и посредник «становится» нужным типом, выполнив именно ту работу, которая для этого типа уместна. Одна функция, разные результаты в зависимости от того, чего от неё хотят.

За всё... ну вы поняли... Неявные преобразования вообще опасны, а множественные неявные преобразования у одного типа опасны вдвойне, потому что они легко срабатывают там, где не ждёшь, конфликтуют при разрешении перегрузок, ломают вывод типов и делают auto .... непредсказуемым? Поэтому идиома считается уместной в узких, хорошо контролируемых сценариях, и почти всегда есть менее коварная альтернатива.

Опять же это часть фольклора, рядом с родственными приёмами вроде прокси-объектов, которые преобразованиями разбирались у многих авторов, и по сути это применение прокси-объекта (Temporary Proxy) к задаче «вернуть разное в зависимости от приёмника».

Скрытый текст
// Посредник, который "становится" нужным типом в точке присваивания
struct DefaultValue {
    template <class T>
    operator T() const { return T{}; }     // для любого T вернёт T по умолчанию
};

DefaultValue make_default() { return {}; }

int    a = make_default();   // operator int()    -> 0
float  b = make_default();   // operator float()  -> 0.0f
Vec3   c = make_default();   // operator Vec3()   -> {0,0,0}

В игровых движках в чистом виде в С++ это запрещено, либо используется в виде «нулевых» значений, которые подстраиваются под целевой тип, или в прокси-результатах парсинга конфигов и скриптовых мостов, где значение из динамического источника должно «развернуться» в запрошенный статический тип. Скриптовые привязки и системы вариантных значений иногда используют похожие конверсии, чтобы значение из Lua или из property-сумки приходило как нужный C++ тип.

Return Type Resolver

Развитием предыдущего подхода стала техника, в которой функция или объект определяет, что именно вернуть, исходя из типа, в который требуется присвоить результат. Теперь можно перекладывать выбор перегрузки с аргументов на тип приёмника. В обычном C++ перегрузка выбирается по аргументам, а тип возвращаемого значения в выборе не участвует вовсе и нельзя иметь две функции, различающиеся только возвратом, но Return Type Resolver обходит это, возвращая объект-резолвер с шаблонным оператором преобразования, который и подстраивается под требуемый тип.

Механика та же, что у Exploding Return Type, но акцент на реализацию чегоо-то вроде «полиморфизма по возвращаемому типу», например, фабрику, которая создаёт объект нужного типа, определённого по тому, куда присваивают, или функцию вроде гипотетической default_value(), дающей подходящий ноль для любого типа. Резолвер захватывает контекст (если нужно) и в операторе преобразования делает тип-специфичную работу.

Рсплачиваться приходится непредсказуемостью с auto (вы сохраните резолвер, а не результат), и потенциальными неоднозначностями в отладке. Кроме того, эта техника плохо сочетается с шаблонным кодом, который и вовсе не знает заранее целевой тип, и требует, чтобы точка присваивания однозначно задавала тип, поэтому её применяют в узких, хорошо очерченных API, где выигрыш в выразительности перевешивает риски.

Классический реальный пример этого подхода в стандартной библиотеке будет std::nothrow-подобные приёмы и, более явно, поведение, когда nullptr-подобный объект (исторически — самописные null-объекты до nullptr) преобразуется в указатель любого типа. Собственно, появление nullptr в C++11 и есть во многом стандартизация одного частного случая return type resolver объекта, который «становится» нулевым указателем нужного типа.

Скрытый текст
// Резолвер выбирает, что вернуть, по требуемому типу приёмника
class Zero {
public:
    template <class T> operator T*() const { return nullptr; }     // нулевой указатель любого типа
    operator int()   const { return 0; }
    operator float() const { return 0.0f; }
};

Zero zero;
int*    p = zero;   // operator T*<int>  -> nullptr
Entity* e = zero;   // operator T*<Entity> -> nullptr
float   f = zero;   // operator float()  -> 0.0f

Как и предыдущий брат близнец, return type resolver в разработке игр фактически запрещен или используется для универсального «нуля/пустого значения». Но системы, читающие данные из нетипизированных источников (конфиги, сеть, скрипты), иногда используют резолверы, чтобы значение «материализовалось» как требуемый C++-тип, хотя чаще это всё же делают явными get<T>()-методами.

Практическая ценность идиомы для программиста скорее в понимании, что nullptr и подобные «контекстно-типизированные» сущности устроены как return resolver, и в умении распознать такой резолвер в чужом коде, и обходить его стороной. В новом коде же действует общее правило "явное лучше неявного", поэтому там, где return type resolver соблазняет своей "элегантностью", стоит предпочесть функцию видаmake<T>(), у которой целевой тип задан явно параметром, а не выведен из загадочного контекста присваивания.

Named Constructor

Еще одна техника, обходящая фундаментальное ограничение C++, когда все конструкторы класса называются одинаково по имени класса, и различаются только списком параметров. Если у вас есть несколько способов создать объект из одного и того же набора типов аргументов, перегрузкой конструкторов их не развести Color(float, float, float) не может означать одновременно и RGB, и HSV. Named Constructor решает это, делая конструкторы приватными, а наружу выставляя статические функции с осмысленными именами, каждая из которых создаёт объект по-своему.

Из плюсов таков подхода мы получаем лучшую читаемость и снятие неоднозначности. Color::from_rgb(1, 0, 0) и Color::from_hsv(0, 1, 1), не оставляя сомнений в намерениях автора, тогда как Color(1, 0, 0) заставляет лезть в документацию. Заодно named constructor может вернуть объект производного типа, спрятать сложную логику инициализации, провалидировать аргументы до создания и в принципе отвязать «как назвать создание» от «как называется тип».

Расплачиваться приходится тем, что объект обычно возвращается по значению (или умным указателем), и до C++17 это означало зависимость от copy/move-механик, чтобы избежать лишней копии. Еще такие приватные конструкторы мешают положить тип в контейнеры, требующие публичного конструктора, и эмплейс-конструированию, что иногда приходится обходить костылями.

Этот похдод был описан ещё в Марашаллом Клайном в девяностых под именем «Named Constructor», а в стандартной библиотеке вы можете увидеть его в функциях вроде std::make_pair/std::make_unique, хотя это уже скорее object generators.

Скрытый текст
class Color {
    float r_, g_, b_;
    Color(float r, float g, float b) : r_(r), g_(g), b_(b) {}   // приватный
public:
    static Color from_rgb(float r, float g, float b) { return {r, g, b}; }
    static Color from_hsv(float h, float s, float v) {
        /* ... конвертация HSV->RGB ... */
        return {r, g, b};
    }
    static Color from_hex(std::uint32_t hex) {
        return {((hex>>16)&0xFF)/255.f, ((hex>>8)&0xFF)/255.f, (hex&0xFF)/255.f};
    }
};

Color red    = Color::from_rgb(1, 0, 0);
Color teal   = Color::from_hex(0x008080);   // намерение читается сразу

В играх named constructor вездесущ именно потому, что там полно типов с несколькими осмысленными способами создания из одинаковых аргументов. Цвета (RGB/HSV/hex/температура), углы и повороты (из градусов/радиан/кватерниона/осей), векторы (декартовы/полярные), трансформации (из матрицы/из позиции-поворота-масштаба) и всё это естественно выражается набором именованных кторов, а кодQuat::from_axis_angle(up, angle), читается намного приятнее чем Quat(v1, v2).

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

Virtual Constructor

Фольклор плюсов, и строго говоря, "деревянный как стекло", потому конструктор в C++ виртуальным быть не может, и чтобы вызвать конструктор, нужно уже знать точный тип создаваемого объекта, а виртуальность как раз про то, чтобы не знать точный тип. Но эта техника позволяет обходить эту невозможность, эмулируя «создание объекта неизвестного на момент написания типа» через обычные виртуальные функции. Есть две её разновидности: виртуальное клонирование (clone) и виртуальное создание (create).

clone решает задачу «скопировать объект, зная только указатель на базовый класс», когда вы не можете написать new Derived(*basePtr), не зная, что это Derived, но можете объявить clone(), который каждый наследник переопределяет, возвращая копию себя своего точного типа. Вызов basePtr->clone() даст правильную копию правильного типа, хотя вызывающий знает только базу, аcreate аналогично создаёт новый объект того же типа без копирования.

За все приходится платить, и клонирование требует, чтобы каждый класс иерархии реализовал свлй clone , но это легко забыть сделать в новом объекте. Плюс это виртуальный вызов с аллокацией, то есть точно не для хотпасов. Подход популяризировал Клайн, а «прототип» из банды четырёх объяснил это на уровне паттерна проектирования, через создание объектов копированием прототипа через полиморфный clone. Т.е. концептуально это давнее знание, восходящее к самым ранним обсуждениям как обойти невиртуальность конструкторов в C++.

Скрытый текст
struct Enemy {
    virtual ~Enemy() = default;
    virtual std::unique_ptr<Enemy> clone() const = 0;   // виртуальное копирование
    virtual std::unique_ptr<Enemy> create() const = 0;  // виртуальное создание
};

struct Orc : Enemy {
    int rage;
    std::unique_ptr<Enemy> clone()  const override { 
      return std::make_unique<Orc>(*this); 
    }
    std::unique_ptr<Enemy> create() const override { 
      return std::make_unique<Orc>(); 
    }
};

// Знаем только Enemy*, но получаем правильную копию правильного типа:
std::unique_ptr<Enemy> spawn_copy(const Enemy& prototype) { 
  return prototype.clone(); 
}

В разработке игр виртуальное клонирование самая обычная рабочая лошадка систем вроде спавнера врагов и предметов, у которых есть эталонный экземпляр, копируемый при создании. Или систем сохранения/загрузки и редакторов, т.е. везде, где нужно «сделай ещё один такой же» или «скопируй вот это» при наличии лишь указателя на базу, clone будет естественным решением.

Стоит отметить, что современные движки, особенно дата-ориентированные, нередко обходятся уже без полиморфного клонирования, потому что сущность теперь это набор данных в компонентах, а не объект с виртуальными методами. Поэтому и «клонировать» её можно просто копированием компонентов без всякого виртуального clone.

virtual constructor ярче всего проявляется в более классических объектных архитектурах и в редакторном/тулзовом коде, где иерархии полиморфных объектов всё ещё уместны, а производительность при копировании не критична.

Computational Constructor

Это техника, как избежать лишних временных объектов, когда конструктор должен произвести вычисление над аргументами. Наивный подход будет посчитать результат в свободной функции и вернуть его, что порождает временный объект, который затем копируется или перемещается в место назначения, а сomputational constructor вместо этого вычисляет результат прямо «на месте», в теле конструктора объекта, инициализируя его поля результатом вычисления без промежуточного временного.

Идея в том, чтобы перенести само вычисление внутрь конструирования. Вместо Matrix result = multiply(a, b);, где multiply создаёт и возвращает временную матрицу, вы делаете конструктор Matrix(const Matrix& a, const Matrix& b), который вычисляет произведение прямо в свои поля. Целевой объект строится сразу с правильным содержимым, и лишней временной матрицы не рождается, что особенно хорошо для крупных объектов, копирование которых дорого.

Эволюция языка во многом обесценила эту идиому, добавив в языка сначала copy elision и RVO, которые научили компилятор устранять временные переменные при возврате по значению в большинстве случаев, а затем move-семантика C++11 сделала «лишнюю» копию дешёвым перемещением. C++17 вообще убрал временный объект при return и в итоге современный Matrix multiply(...) с возвратом по значению часто генерирует такой же код, как computational constructor, но читается лучше.

Техника относится к до-move-семантикам, и её целью была та же борьба с временными объектами, что двигала и expression templates, и она часто обсуждалась в связке с оптимизацией возврата значений у книгах Майерса и в литературе по высокопроизводительному C++ девяностых-нулевых.

Скрытый текст
class Matrix4 {
    float m_[16];
public:
    // Computational constructor: произведение вычисляется прямо в поля this,
    // без временной матрицы-результата
    Matrix4(const Matrix4& a, const Matrix4& b) {
        for (int r = 0; r < 4; ++r)
            for (int c = 0; c < 4; ++c) {
                float s = 0;
                for (int k = 0; k < 4; ++k) s += a.m_[r*4+k] * b.m_[k*4+c];
                m_[r*4+c] = s;
            }
    }
};

Matrix4 mvp(model_view, projection);   // строится сразу как произведение

Борьба с лишними временными в математике всегда актуальна, но computational constructor в чистом виде сегодня применяют редко, потому что для мелких типов (матрица 4×4, вектор) временные переменные уже живут в регистрах и устраняются компилятором, а для крупных предпочитают move-семантику и явные in-place операции вроде multiply_into(result, a, b), которые ещё прозрачнее показывают где происходит запись, поэтому читаемость Matrix mvp = model_view * projection; обычно перевешивает.

Тем не менее сам принцип «вычисляй сразу в место назначения, не плоди промежуточные объекты» все еще остается краеугольным каменюкой некоторых дедов перформанс-ориентированных программеров. Computational constructor стоит знать как раннюю форму move-семантик и copy-elision идей как напоминание, что в эпоху до RVO и move программистам приходилось воевать с временными объектами вручную, изобретая для этого специальные конструкторы.

Construct On First Use

Construct On First Use лечит болезнь C++ «фиаско порядка статической инициализации» (static initialization order fiasco), потому что порядок инициализации глобальных и статических объектов из разных единиц трансляции стандартом не определён. Если один глобальный объект в своём конструкторе обращается к другому глобальному объекту из другого .cpp-файла, нет гарантии, что тот уже сконструирован и он вполне может быть ещё нулём, и вы получаете обращение к недостроенному объекту ещё до входа в main.

Решается это, прятанием глобального объекта за функцией, которая создаёт его при первом обращении. Объект объявляется как локальная static-переменная внутри функции-аксессора, а локальные статики, в отличие от глобальных, инициализируются гарантированно при первом проходе через их объявление, а не в неопределённый момент до main. Любой, кому нужен объект, зовёт instance(), и при самом первом вызове объект конструируется, после чего возвращается всегда один и тот же. Порядок инициализации становится определяемым порядком обращений, а не капризом компоновщика.

За все приходится... ну вы поняли... Теперь мы получаеся два разных варианта объекта с разной семантикой времени жизни, потому что версия с локальным статиком по значению (static Foo f; return f;) уничтожает объект в обратном порядке при выходе, что может вернуть фиаско уже на этапе разрушения, а версия с new (static Foo* f = new Foo; return *f;) никогда не уничтожает объект и является намеренной «утечкой», которая безопасна, потому что память всё равно вернёт ОС, но не зовёт деструктор, что плохо, если деструктор должен что-то сделать (сбросить файл, закрыть сокет). Плюс с C++11 локальные статики ещё и потокобезопасны при инициализации, что добавляет мьютексную проверку на каждом обращении.

Эта техника ещё одна классика из статей Клайна, где была описана как лекарство от static init order fiasco, а гарантия потокобезопасной инициализации локальных статиков («magic statics») появилась в C++11 и сделала её ещё надёжнее, убрав необходимость в ручных блокировках вокруг ленивой инициализации.

Скрытый текст
// Вместо глобального Logger logger; (с риском фиаско порядка инициализации):
Logger& logger() {
    static Logger instance;   
    // создаётся при первом вызове, потокобезопасно (C++11)
    return instance;
}

// Любой код, в т.ч. из конструкторов других глобалов, безопасно зовёт
void boot() { logger().info("engine starting"); }

В играх это стандартный способ оформлять «глобальные» подсистемы и синглтоны, которым нужно гарантированно существовать к моменту первого использования: логгер, менеджер памяти, реестр типов для рефлексии, глобальные таблицы. Также используется для систем, которые регистрируют сами себя при статической инициализации (через глобальные объекты-регистраторы), и которым нужно обращаться к центральному реестру, а реестр обязан быть готов к этому моменту, что и обеспечивает construct on first use.

Но в больших движках к глобальному состоянию в целом относятся с подозрением, предпочитая явную инициализацию подсистем в контролируемом порядке при старте движка, поэтому Construct on first use хорош как страховка от глобалов, если они всё-таки есть.

Nifty Counter

Nifty Counter решает ту же проблему с порядком инициализации, но для случая, когда объект обязан быть настоящим глобальным с гарантированным деструктором, а не ленивым локальным статиком. Классический пример это потоки std::cout, std::cin, std::cerr, которые должны быть готовы к использованию в конструкторе любого глобального объекта пользователя и корректно закрыться при завершении программы. Construct on first use тут проблему не решить, а обычный глобал страдает от неопределённого порядка.

Поэтому в заголовке, который включает каждый клиент, объявляется статический объект-счётчик (по одному на каждую единицу трансляции), включившую заголовок, а конструктор этого счётчика при самом первом вызове (когда счётчик с нуля идёт в единицу) инициализирует настоящий глобальный объект, размещая его в заранее зарезервированном буфере через placement new.

Последующие счётчики видят ненулевое значение и уже ничего не делают, а при завершении программы деструкторы счётчиков отрабатывают в обратном порядке, и тот из них, кто обнуляет счётчик, вызывает деструктор настоящего объекта, так объект гарантированно создаётся до первого использования и уничтожается после последнего.

Платить за это приходится громоздкой ручной системой управления сырым буфером, placement new и явным вызовом деструктора, плюс счётчик в каждой единице трансляции. Отлаживать такие проблемы порядка инициализации/разрушения, как вы понимаете, будет очень неприятным занятием, поэтому в прикладном коде её почти никогда не пишут руками и она остаётся уделом стандартной библиотеки и редких системных компонентов.

Еще её называют как «Schwarz Counter», в честь Джерри Шварца, который придумал этот приём при реализации потоков ввода-вывода <iostream> в ранние дни C++ в AT&T, чтобы решить именно проблему инициализации cout и компании. Это, пожалуй, самый старый из приёмов 80-х, который сохранился до наших дней и буквально вшит в заголовок <iostream>, который включает пожалуй каждая первая программа на C++.

Скрытый текст
// stream_setup.h — включается каждым клиентом
class StreamInitializer {
    static int count_;
public:
    StreamInitializer()  { if (count_++ == 0) init_streams();  }   
    // первый — создаёт
    ~StreamInitializer() { if (--count_ == 0) cleanup_streams(); } 
    // последний — рушит
};
static StreamInitializer stream_init_;   // свой в каждой единице трансляции

// Именно так под капотом инициализируется std::cout до первого использования

По понятным причинами nifty counter в чистом виде в играх почти не встречается, это очень специфический инструмент, а движки обычно управляют временем жизни подсистем явно, контролируемой последовательностью в коде старта, а не магией статической инициализации. Но вы используете его опосредованно каждый раз, когда пишете в std::cout из инструмента или дебажного кода: за тем, что поток готов к работе, стоит именно счётчик Шварца в заголовке <iostream>.

Концептуально полезно знать nifty counter как иллюстрацию того, насколько глубокой может быть проблема порядка инициализации и какой ценой её решали для базовых компонентов языка. В движках же общий вывод обычно противоположный и вместо того чтобы городить хрупкую магию инициализации глобалов, лучше не иметь глобалов вообще, а инициализировать всё явно и в известном порядке, и тогда ни construct on first use, ни счётчик Шварца просто не понадобятся.

Runtime Static Initialization Order

Две секции до этого описывали как обойти, но не решали саму проблему. Если construct on first use разрывает зависимость ленивым созданием, а nifty counter обслуживает один центральный объект, то более общий механизм будет не полагаться на порядок статической инициализации вообще, а ввести явную рантайм-фазу, в которой подсистемы инициализируются в порядке, который вы контролируете.

Глобальные объекты при статической инициализации лишь регистрируют себя (добавляют себя в список, в реестр), не делая ничего, что зависит от других, а реальная инициализация со всеми зависимостями происходит позже, в явно вызванной функции, когда вы уже управляете порядком. Регистрация глобалов безопасна, потому что добавление в список не зависит от состояния других объектов.

Расплачиваться за это приходится отдельной архитектурой приложения, когда нужно последовательно проводить границу между «зарегистрировался при статической инициализации» и «проинициализировался в рантайме», и не дать соблазну сделать в конструкторе глобала что-то, зависящее от других глобалов. Зато такой подход даёт полный контроль и хорошо масштабируется на десятки взаимозависимых подсистем.

Эта техника стала собирательным знанием и развитием идей Шварца, Мейерса и Клайна. По сути это признание того, что язык не даёт хорошего встроенного решения для упорядоченной инициализации глобального состояния с зависимостями, и что правильный ответ почти всегда будет вынести инициализацию из «до main» в контролируемый рантайм.

Скрытый текст
// Глобалы лишь РЕГИСТРИРУЮТ себя при статической инициализации (это безопасно):
struct Subsystem { 
  virtual void init() = 0; 
  virtual int priority() const = 0; 
};

std::vector<Subsystem*>& registry() { 
  static std::vector<Subsystem*> r; return r; 
}
void register_subsystem(Subsystem* s) { 
  registry().push_back(s); 
}

// Реальная инициализация в рантайме, в порядке, который МЫ задаём:
void init_all() {
    auto& subs = registry();
    std::sort(subs.begin(), subs.end(),
              [](auto* a, auto* b){
                  return a->priority() < b->priority(); 
              });
  
    for (auto* s : subs) 
      s->init();   // зависимости разрешены порядком приоритетов
}

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

Именно поэтому в движках так редко встретишь «тяжёлые» глобальные объекты с логикой в конструкторах, потому что индустрия на своём горьком опыте выучила, что полагаться на порядок статической инициализации значит обречь себя на загадочные падения, зависящие от порядка линковки и потому невоспроизводимые.

Base-from-Member

Base-from-Member решает проблему порядка инициализации внутри одного объекта, что делать, когда базовому классу в его конструктор нужно передать что-то, что само является членом производного класса. Беда в том, что базовые классы инициализируются строго раньше членов производного по правилу языка, а значит, к моменту вызова конструктора базы член производного ещё не существует, и передать его базе нельзя, потому что вы передадите ссылку на ещё не созданный объект.

Классический сценарий когда базовый класс хочет в конструкторе ссылку на поток или буфер, а сам этот поток логически принадлежит производному классу как его член. Прямо это не выразить Derived() : Base(member_), member_(...) и обращение к member_ до его конструирования будет неопределённым поведением.

Эта техника обходит это, вводя дополнительный промежуточный базовый класс, который держит нужный член и инициализируется раньше основной базы (потому что объявлен раньше в списке наследования), и тогда основная база может законно получить ссылку на член, живущий в этом промежуточном базовом классе.

Платить приходится неочевидным вспомогательным базовым классом ради чисто технической причины, что запутывает иерархию и требует комментария «зачем тут это». Плюс она применима только в специфичной ситуации «базе нужен член, которого еще нет» и часто проще пересмотреть дизайн класса и не делать объект и базой, и владельцем нужного ей ресурса одновременно, но когда такой возможности нет (например, при наследовании от чужого класса вроде стандартных потоков), base-from-member оказывается единственным чистым выходом.

Придумали это поведение в Boost как boost::base_from_member и каноническое решение было для потоков, где производный класс от std::ostream, которому нужно передать в конструктор ostream указатель на буфер, являющийся членом этого же производного класса.

Скрытый текст
// Промежуточная база, держащая член, который понадобится основной базе
struct BufferHolder {
    StreamBuffer buffer;
    BufferHolder() : buffer() {}
};

// BufferHolder в списке раньше Stream => его buffer уже жив,
// когда конструируется Stream, и ссылку на него можно передать законно
class LoggingStream : private BufferHolder, public Stream {
public:
    LoggingStream() : BufferHolder(), Stream(&buffer) {}  
    // buffer уже сконструирован
};

В играх base-from-member редкий гость, я не видел его применения за все годы в разработке, а нужен для кастомных std::ostream , которых почти нет, для логирования в файл/консоль/сеть с собственным буфером. То есть это инструмент для границ с чужим кодом, навязывающим неудобный порядок инициализации.

Паттерн «базе нужен мой член» считается признаком того, что обязанности в иерарахии распределены неудачно и надо её пересмотреть, но знать идиому стоит и когда вы натыкаетесь на необходимость передать базе свой будущий член, надо понимать, что это почти всегда путь в неопределённое поведение, ну или надеяться на фазу луны и что всё обойдётся.

Calling Virtuals During Initialization

Это набор обходных приёмов вокруг одной из самых коварных ловушек C++, вызова виртуальной функции из конструктора (или деструктора), когда тот не делает того, что ожидается. Во время конструирования базового класса объект ещё «является» только базой, а производная часть не сконструирована, поэтому виртуальный вызов из конструктора базы уйдёт в реализацию базы, а не производного класса, даже если вы создаёте объект производного типа. В деструкторе, понятно, зеркально и производная часть уже разрушена, и вызов уходит в базу.

Логика языка тут, если вызвать переопределение в производном классе, чьи поля ещё не инициализированы (или уже уничтожены), значило бы работать с мусором. Поэтому стандарт намеренно «опускает» динамический тип объекта до текущего конструируемого класса, но для программиста, пришедшего из Java или C#, где виртуальный вызов из конструктора идёт в самый производный класс (со своими, гораздо худшими проблемами), это сюрприз.

Есть несколько обходных путей для случаев, когда полиморфное поведение при создании действительно нужно. Самый "правильный", хотя любая "правильность" тут скорее недоловленный UB, это двухфазная инициализация, когда конструктор создаёт «пустой» объект, а отдельный (не виртуальный, вызывающий виртуальные) метод init() запускается уже после полного конструирования, когда объект имеет правильный динамический тип.

Второй вариант будет передавать нужное поведение параметром или фабрикой, а не полагаться на виртуальный вызов из конструктора. Но в любом случае двухфазная инициализация ломает RAII (объект существует, но ещё не готов) и требует дисциплины «не забыть вызвать init», что само по себе источник новых багов.

Проблема и её обходы были детально разобраны у Скотта Майерса в Effective C++ (отдельный пункт «никогда не вызывайте виртуальные функции при конструировании или разрушении») и это одно из тех мест, где правила C++ строго логичны, но контринтуитивны, и знание идиомы экономит часы, проведенные в отладчике.

Скрытый текст
struct Widget {
    Widget() { /* НЕ звать здесь virtual draw() — уйдёт в Widget::draw */ }
    virtual void draw() { /* база */ }
    void init() { draw(); }   // безопасно: объект уже полностью сконструирован
};
struct Button : Widget { void draw() override { /* кнопка */ } };

// Двухфазная инициализация, чтобы получить полиморфизм при "создании":
auto b = std::make_unique<Button>();
b->init();   // вот теперь draw() уйдёт в Button::draw

В разработке игр эта ловушка исторически живет в иерархиях игровых объектов, UI-виджетов и компонентов, где соблазн «при создании сразу настроить себя полиморфно» очень велик. Многие движки именно поэтому вводят явный жизненный цикл объекта с методами вроде BeginPlay в Unreal или OnEnable/Start в Unity-подобных архитектурах, где это и есть явная двухфазная инициализация, отделяющая «сконструирован» от «готов к игре», чтобы виртуальные вызовы происходили уже над полностью построенным объектом правильного типа.

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

Понимать, почему так сделано, прямое следствие того, что виртуальные вызовы в конструкторе не полиморфны, и попытка обойти это «в лоб» приводит к багам, которые проявляются только для определённых типов в иерархии.

Construction Tracker

Construction Tracker решает неприятную проблему, когда в списке инициализации конструктора несколько членов конструируются с возможностью бросить исключение, и нужно точно знать, какой именно из них упал, чтобы корректно отреагировать или хотя бы внятно сообщить. Дело в том, что если конструктор члена бросает исключение, уже сконструированные члены будут корректно разрушены (язык это гарантирует), но сам конструктор не получает простого способа узнать, на каком из членов всё сломалось, а это иногда нужно для диагностики или для правильного уничтожения объекта.

Тогда вводится «трекер», счётчик или индикатор фазы, который продвигается по мере успешного конструирования каждого члена. Поскольку члены инициализируются по порядку, значение трекера в момент исключения указывает, до какого члена дошли, то есть какой именно конструировался, когда бросил. Трекер обычно сам является членом, инициализируемым первым, а продвигают его через вспомогательные выражения в списке инициализации (например, функции, которые конструируют значение и заодно инкрементируют трекер).

Платить приходится "шумом" в конструкторе и сам трекер тоже является источником путаницы, поэтому в большинстве случаев правильнее обработать возможные исключения внутри самих конструкторов членов или вынести рискованную инициализацию из списка в тело конструктора, где её проще обернуть в try/catch с понятным контекстом.

Все это описано было еще в 90-е и тесно связано с function-try-block (try-блок вокруг всего конструктора, включая список инициализации) и механизмом C++, который позволяет ловить исключения из списка инициализации, но не позволяет «починить» объект, а лишь даёт шанс на диагностику перед повторным выбросом.

Скрытый текст
class Pipeline {
    int phase_ = 0;
    Stage a_, b_, c_;
    template <class T> static T track(T&& v, int& phase) { 
      ++phase; return std::forward<T>(v); 
    }
public:
    Pipeline(Config cfg)
    try
        : phase_(0),
          a_(track(make_stage_a(cfg), phase_)),   // phase_ -> 1
          b_(track(make_stage_b(cfg), phase_)),   // phase_ -> 2
          c_(track(make_stage_c(cfg), phase_))    // phase_ -> 3
    {}
    catch (const std::exception& e) {
        log("pipeline failed at stage %d: %s", phase_, e.what());  
        // знаем, где упало
        throw;
    }
};

construction tracker скорее экзотика, и в большинстве кода вы его не встретите, потому что игровые объекты редко имеют длинные списки рискованных членов, а инициализацию подсистем, способную провалиться, обычно делают явной двухфазной с понятной обработкой ошибок на каждом шаге, а не прячут в список инициализации.

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

Attach by Initialization

Еще один механизм, когда объект «прикрепляет» себя к некоторой внешней системе или реестру прямо в процессе своей инициализации, обычно через глобальный или статический объект, чей конструктор и выполняет регистрацию. Смысл этого всего - добиться, чтобы что-то произошло автоматически, без явного вызова из main или из кода инициализации, когда сам факт существования объекта (или подключения файла, его определяющего) запускает регистрацию.

Это оборотная, «полезная» сторона той самой статической инициализации, которая в других идиомах была источником бед. Здесь мы намеренно используем то, что глобальные объекты конструируются до main, чтобы выполнить регистрацию и глобальный объект-регистратор в своём конструкторе добавляет фабрику типа в реестр, подписывает обработчик на событие, регистрирует тест, плагин или команду. Программисту достаточно объявить такой объект (часто через макрос), и «прикрепление» случится само.

Платить за это приходится снова порядоком статической инициализации со всеми его рисками, и регистратор должен обращаться только к тому, что гарантированно готово (обычно к реестру, оформленному через construct on first use, чтобы он точно существовал). Плюс линковщик любит выбрасывать объектные файлы, на которые нет явных ссылок, а у саморегистрирующегося объекта таких ссылок может не быть, и он молча не попадёт в сборку вместе со своей регистрацией.

Механизм поселился в фреймворках тестирования, фабриках и плагинных системах задолго до того, как его каталогизировали под этим именем.

Скрытый текст
// Макрос, объявляющий саморегистрирующийся глобальный объект:
#define REGISTER_COMPONENT(Type) \
    static bool s_reg_##Type = (ComponentRegistry::get().add(#Type, []{ return new Type; }), true)

// В файле компонента достаточно одной строки и он сам себя регистрирует:
class HealthComponent : public Component { /* ... */ };
REGISTER_COMPONENT(HealthComponent);   
// прикрепление при статической инициализации

Как я сказал выше это крайне популярный приём для систем, которым нужна расширяемость без центрального списка «всех типов», вроде фабрики компонентов и сущностей, где каждый компонент сам регистрирует себя, или регистрация типов для рефлексии и сериализации, или саморегистрирующиеся консольные команды, cvars, отладочные виджеты, или плагины редактора. Это позволяет добавить новый тип, просто написав его файл с одной строкой регистрации, не редактируя никакой общий реестр, что очень ценно в больших командах.

Но у этой красоты есть та самая тёмная сторона, на которой обжигались многие, когда линковщик выкидывает «неиспользуемые» объектные файлы, и саморегистрация исчезает из релизной сборки при агрессивной оптимизации. Поэтому движки, активно использующие этот приём, обязаны принимать меры против выпиливания саморегистраторов компоновщиком и отладка пропавшей регистрации в релизе, которая прекрасно работала в дебаге, входит в число классических загадочных багов, на которые программисты тратят незабываемые вечера.

Идея мощная и живая, но требует понимать, что происходит между компилятором и линковщиком, иначе она подведет в самый ответственный момент.

Non-Virtual Interface (NVI)

Non-Virtual Interface переворачивает привычную раскладку «публичное = виртуальное» с ног на голову, и ВСЕ публичные методы базового класса делаются невиртуальными, а виртуальными только приватные (или защищённые) методы, которые вызываются этими публичными. Снаружи класс предоставляет стабильный невиртуальный интерфейс, а точки кастомизации для наследников спрятаны внутри. Наследник переопределяет приватные виртуальные «крючки», но не может изменить публичный контракт.

Это нужно, чтобы невиртуальный публичный метод сделать единственной точкой входа, в которой база полностью контролирует «обвязку» вокруг кастомизируемого поведения, например, проверку предусловий и постусловий, логирование, замер времени, захват блокировки, обработку ошибок. База гарантирует, что вся эта обвязка выполнится всегда, что бы ни переопределил наследник, потому что наследник не может обойти публичный метод, и он лишь подставляет своё поведение в специально отведённую виртуальную «дырку» посередине. Это применение принципа «шаблонный метод» из банды четырёх, доведённое до максимального правила.

Платить за это приходится еще одним уровнем косвенности (публичный невиртуальный зовёт приватный виртуальный) и непривычность для тех, кто ждёт, что переопределяют именно публичные методы. Плюс наследник иногда хочет переопределить и обвязку, а NVI это намеренно запрещает, что в редких случаях оказывается слишком жёстким, зато выгода в единой точке контроля и невозможности для наследника «забыть» обязательную обвязку, обычно перевешивает.

Идиому сформулировал и активно пропагандировал Херб Саттер как «предпочитайте делать виртуальные функции приватными» и показал, что публичный и виртуальный это два разных свойства, которые незачем всегда совмещать. После 2000-х это стало одним из самых влиятельных переосмыслений в языке и породило отдельный стиль программирования и как «правильно» проектировать полиморфные интерфейсы в C++.

Скрытый текст
class Renderer {
public:
    void render(const Scene& s) {        // невиртуальный: контролирует обвязку
        ScopedTimer t("render");         // эту обвязку наследник не обойдёт
        validate(s);
        do_render(s);                    // вот сюда наследник подставляет своё
        present();
    }
private:
    virtual void do_render(const Scene&) = 0;   // точка кастомизации, приватная
    void validate(const Scene&);
    void present();
};

class VulkanRenderer : public Renderer {
    void do_render(const Scene& s) override { /* только сам рендер, без обвязки */ }
};

В играх NVI отлично ложится на системы с жёстким жизненным циклом и обязательной обвязкой вокруг шагов, когда базовый класс системы или компонента в невиртуальном update() замеряет время для профайлера, проверяет состояние, ведёт статистику, а наследник реализует лишь приватный do_update(). Это гарантирует, что ни один наследник не «потеряет» профилирование или проверки, как бы небрежно его ни написали, и вы все это можете увидеть в каждом "DoUpdate" в Unity/Unreal/Godot движке.

Тот же подход естественно применяется к сериализации и публичный save оборачивает виртуальный serialize в запись заголовка и версии, к обработке событий, к сетевой репликации. Везде, где есть «обязательное вокруг» и «настраиваемое внутри», NVI даёт чистое разделение и защищает инварианты на уровне дизайна.

Thread-Safe Interface

Это тот же NVI, но специализированный под многопоточность, и решающий конкретную беду, что вложенные вызовы методов одного объекта приводят к попытке повторно захватить уже удерживаемый мьютекс, то есть к взаимоблокировке (если мьютекс нерекурсивный) или к скрытой неэффективности (если рекурсивный). Если каждый публичный метод захватывает мьютекс, а один публичный метод зовёт другой публичный метод того же объекта, второй захват того же мьютекса будет дедлоком на ровном месте.

Теперь мы раскладываем методы на два слоя и публичные методы у нас невиртуальные, и они отвечают за захват мьютекса (и только за это плюс делегирование), а вся реальная работа происходит в приватных методах, которые мьютекс не трогают и предполагают, что блокировка уже взята вызывающим. Тогда публичный метод захватывает мьютекс и зовёт приватную реализацию, а если одной приватной реализации нужно вызвать логику другой, она зовёт приватную версию напрямую, без повторного захвата. Так гарантируется, что мьютекс захватывается ровно один раз на входе извне и никогда изнутри.

Платить за это приходится дисциплина использования, и приватные методы нельзя вызывать снаружи без захвата, а публичные нельзя вызывать из приватных (иначе вернётся двойной захват). Эту инвариантность приходится поддерживать вручную, а нарушить её легко, особенно при рефакторинге, плюс остаётся вся общая сложность блокировок и этот подход защищает только от одного конкретного класса дедлоков (рекурсивный захват), но не от блокировок между разными объектами и не от прочих радостей многопоточности.

Все это описал и назвал Херб Саттер в статьях про многопоточность и в развитие своей же NVI, которая прямо вытекает из принципа «публичное невиртуальное оборачивает приватное», где видно, что NVI работает, и что на него удобно навешивать перекрёстные обязанности вроде блокировки, логирования или транзакционности, потому что есть единственная контролируемая точка входа.

Скрытый текст
class ResourceCache {
    std::mutex mtx_;
    std::unordered_map<Id, Resource> map_;

    void insert_locked(Id id, Resource r) { map_[id] = std::move(r); }  // без захвата!
    Resource* find_locked(Id id) { auto it = map_.find(id); return it != map_.end() ? &it->second : nullptr; }
public:
    void insert(Id id, Resource r) {                 // публичный: захватывает
        std::lock_guard<std::mutex> lk(mtx_);
        insert_locked(id, std::move(r));
    }
    Resource get_or_load(Id id) {                    // зовёт *_locked, не публичные
        std::lock_guard<std::mutex> lk(mtx_);
        if (auto* r = find_locked(id)) return *r;
        Resource r = load(id);
        insert_locked(id, r);                        // без повторного захвата
        return r;
    }
};

thread-safe interface обычно полезен для разделяемых между потоками сервисов, вроде кэшей ресурсов, к которым обращаются и загрузочные потоки, и игровые. Или пулов задач, или систем логирования и телеметрии, пишущих из многих потоков. Везде, где у объекта несколько публичных операций, которые могут звать друг друга, и при этом он защищён собственным мьютексом, разделение на «публичное с замком» и «приватное без замка» спасает от случайных рекурсивных дедлоков.

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

В этой парадигме объектов, защищённых мьютексом, становится меньше, и потребность в thread-safe interface снижается, но там, где разделяемый защищённый объект всё-таки нужен, эта идиома давно проверенный способ не выстрелить себе в ногу дедлоком, и знать её нужно, если вы пишете многопоточный код движка.

Acyclic Visitor Pattern (cложно)

Для начала нужно сначала вспомнить обычный паттерна «посетитель», который позволяет добавлять новые операции к иерархии классов, не меняя сами классы, вынося операцию в объект-посетитель с методом visit . Проблема в том, что базовый интерфейс посетителя обязан знать обо всех конкретных типах иерархии (по visit на каждый), и это создаёт жёсткий цикл зависимостей, когда добавили новый тип в иерархию и обязаны добавить visit во всех посетителей и в их общий базовый интерфейс.

Acyclic Visitor позволяет разорвать этот цикл и вместо одного «толстого» интерфейса посетителя, знающего все типы, делается пустой базовый интерфейс-маркер посетителя и отдельные маленькие интерфейсы «умею посещать вот этот конкретный тип». Метод accept элемента пытается через dynamic_cast выяснить, реализует ли пришедший посетитель интерфейс «посещения меня», и если да то зовёт его, если нет, то просто игнорирует. Так посетителю не обязательно знать про все типы, а новый тип не ломает существующих посетителей и они просто не реализуют интерфейс для него и пропускают.

Платить приходится dynamic_cast на каждом accept, а это рантайм-проверка плюс «пропуск» необработанных типов может маскировать ошибки (забыли обработать тип или не заметили). То есть acyclic visitor дает развязку зависимостей ценой производительности, и часто это осознанный размен в редко исполняемом коде.

Механизм описал Роберт Мартин (тот самый «дядюшка Боб») в конце девяностых в статье «Acyclic Visitor», именно как решение проблемы циклических зависимостей и хрупкости расширения в классическом GoF-визиторе. Это попытка сохранить силу паттерна «посетитель» (добавление операций без правки классов), убрав его главный недостаток с необходимостью трогать всех посетителей при добавлении типа.

Скрытый текст
struct Visitor { virtual ~Visitor() = default; };           
// пустой маркер

template <class T> struct VisitorFor { 
  virtual void visit(T&) = 0; 
};  // по интерфейсу на тип

struct Node { 
  virtual void accept(Visitor&) = 0; 
};

struct Mesh : Node {
    void accept(Visitor& v) override {
        if (auto* mv = dynamic_cast<VisitorFor<Mesh>*>(&v)) 
          mv->visit(*this);  // умеет — зовём
          // не умеет пропускаем, и Mesh не ломает старых посетителей
    }
};

// Посетитель реализует только те типы, что ему интересны:
struct BoundsCollector : Visitor, VisitorFor<Mesh> {
    void visit(Mesh& m) override { /* собрать AABB меша */ }
};

В играх acyclic visitor живет в инструментах и редакторах, где обработка разнородных деревьев (сцены, графы ассетов, AST скриптов, узлы UI) и расширяемость важнее, чем перф, а набор операций над иерархией постоянно растёт и пишется разными людьми. Возможность добавить новый тип узла, не переписывая все существующие обработчики, и новый обработчик, не трогая узлы, очень ценна в долгоживущих тулзах редактора.

В рантайме движка визиторы (особенно acyclic, с их dynamic_cast) применяют редко, потому что и динамик каста может не быть или он дорогой, поэтому предпочитают data-oriented обход плотных массивов без диспетчеризации по типам. Но acyclic visitor стоит держать в уме как инструмент для оффлайн- и редакторного кода с богатыми расширяемыми иерархиями, а не как способ обходить игровой мир в кадре.

Capability Query

Механизм «спросить у объекта, умеет ли он что-то» в рантайме, прежде чем просить его это сделать. Теперь вместо того, чтобы заставлять каждый объект иерархии реализовывать все мыслимые интерфейсы (с кучей пустых заглушек), вы даёте объекту лишь те способности, которые ему подходят, а вызывающий код во время выполнения запрашивает: «а ты, случаем, не IDamageable?» и если да, то получает соответствующий интерфейс и работает с ним, а если нет, то просто не трогает эту способность.

Технически это обычный dynamic_cast к интересующему интерфейсу, и если объект реализует этот интерфейс, каст вернёт валидный указатель, если нет то просто nullptr, но внутри это рантайм-проверка «реализует ли объект данный контракт». Альтернативой будет явный метод query_interface(id), возвращающий указатель по идентификатору интерфейса (как в COM), или проверка через систему флагов/способностей. Идея во всех вариантах одна, потому что способности необязательны и опрашиваются динамически.

Расплачиваеться за это снова приходится рантаймом(dynamic_cast или эквивалент) и философский вопрос как часто надо «опрашивать способности» нередко признак плохого дизайна. Если вы постоянно спрашиваете объекты «а ты умеешь то? а это?», возможно, способности стоит вынести в отдельные компоненты, которые либо есть, либо нет, а не прятать в иерархию наследования, где их приходится выпытывать динамик кастами.

Механизми описал Скотт Майерс под именем capability query в статьях о том, как и когда уместен dynamic_cast. Проросло это всё в COM от Microsoft с его QueryInterface, который и есть промышленный capability query и любой COM-объект можно спросить, поддерживает ли он данный интерфейс, и получить его или отказ. А сейчас это все живет по всей COM-модели, на которой стоит, в частности, DirectX.

Скрытый текст
struct Entity { virtual ~Entity() = default; };
struct IDamageable { virtual void take_damage(int) = 0; };
struct IInteractable { virtual void interact() = 0; };

struct Barrel : Entity, IDamageable { void take_damage(int) override { /* взрыв */ } };

void shoot(Entity& target, int dmg) {
    if (auto* d = dynamic_cast<IDamageable*>(&target))   // умеет получать урон?
        d->take_damage(dmg);                              // да — наносим
    // нет — стена, декорация: просто игнорируем
}

В играх capability query часто единственный способ выразить «не все объекты умеют всё» и что одни сущности можно повредить, другие - поджечь, третьи - поднять, а с четвёртыми - поговорить, и большинство объектов поддерживает лишь некоторые из этих способностей. Запрос интерфейса перед действием позволяет писать обобщённый код («выстрел проверяет, можно ли цель повредить»), не требуя от каждого камня и стены реализовывать take_damage пустой заглушкой.

Но именно эта задача в современной разработке уже решается не наследованием с dynamic_cast, а композицией из компонентов и «можно повредить» будет наличием у сущности компонента здоровья, и тогда проверка превращается в дешёвый запрос компонента, а не в рантайм-каст по иерархии.

Поэтому capability query в форме dynamic_cast ярче всего живёт в более классических объектных движках и на границах с COM-подобными API (тот же DirectX), а дата-ориентированные ECS-движки достигают того же эффекта через присутствие или отсутствие компонентов, что и быстрее, и гибче. Сам же принцип «спроси, прежде чем требовать» остаётся одним из фундаментальных способов работать с разнородными сущностями.

Covariant Return Types

Это возможность языка, позволяющая переопределённой виртуальной функции в производном классе возвращать более конкретный тип, чем та же функция в базовом. Если базовый clone() возвращает Base*, то clone() в Derived может возвращать Derived* и это считается корректным переопределением, а не другой функцией. Возвращаемый тип «ковариантен» если он меняется в ту же сторону, что и иерархия, сужаясь у наследников.

Это нужно для типобезопасности на стороне вызывающего и без ковариантности виртуальный clone() всегда возвращал бы Base*, и вызвав его на объекте, про который вы точно знаете, что это Derived, вы получили бы Base* и были бы вынуждены делать каст обратно к Derived*. С ковариантным возвратом derivedPtr->clone() сразу даёт Derived* и никаких кастов вам не нужно, что убирает целый класс ненужных приведений и делает виртуальные фабрики и clone приятными в использовании.

Платит тут комлилятор, а для нас это «бесплатная» и безопасная возможность языка, единственное ограничение в том, что ковариантность работает для указателей и ссылок, но не для возврата по значению (а значит вернуть «более конкретный» тип через виртуальный вызов нельзя, потому что размер другой). Поэтому ковариантный возврат естественно сочетается с возвратом указателей, что в случае clone обычно и нужно, а лёгкая шероховатость возникает при сочетании с умными указателями, потому что unique_ptr<Derived> не ковариантен к unique_ptr<Base> на уровне языка, и для них ковариантность приходится эмулировать вручную.

Ковариантные возвращаемые типы добавили в C++ не сразу и ранние компиляторы их не поддерживали, а возможность закрепилась в языке примерно к середине девяностых, став частью стандарта C++98.

Скрытый текст
struct Shape {
    virtual ~Shape() = default;
    virtual Shape* clone() const = 0;
};

struct Circle : Shape {
    Circle* clone() const override { return new Circle(*this); }  // ковариантно: Circle*, не Shape*
    float radius() const { return r_; }
private:
    float r_;
};

Circle c;
Circle* copy = c.clone();   // сразу Circle*, без каста — copy->radius() работает

Это удобная мелочь при разработке игр, которая делает иерархии полиморфных объектов с клонированием и фабриками приятнее в использовании. Везде, где есть виртуальный clone, duplicate, create_instance (системы прототипов, редакторное дублирование, undo-копии), ковариантность позволяет работать с результатом как с конкретным типом, где конкретный тип известен, не засоряя код приведениями.

Особой «игровой специфики» у этой возможности нет, но поскольку клонирование и виртуальные фабрики в играх встречаются регулярно (особенно в инструментах и более классических объектных архитектурах), ковариантный возврат просто одна из тех деталей, знание которой отличает чистый полиморфный код от усыпанного лишними static_cast. А ограничение «только для указателей/ссылок» полезно понимать, чтобы не удивляться, почему трюк не работает с unique_ptr напрямую.

Virtual Friend Function

Идея, разрешающая противоречие что функции-друзья (friend) не наследуются и не могут быть виртуальными. Иногда хочется, чтобы дружественная свободная функция вела себя полиморфно и самый частый случай это оператор вывода operator<< для иерархии классов, которые должен быть свободной функцией (потому что левый операнд всегда поток, а не ваш объект), но при этом печатать объект в соответствии с его реальным типом, то есть полиморфно.

Чтобы это сделать, дружественная свободная функция делается невиртуальной и единственной (обычно в базовом классе), а всю работу она делегирует виртуальному методу класса. То есть operator<< это нешаблонный, ненаследуемый друг, который внутри зовёт obj.print(stream), а print уже обычный виртуальный метод, переопределяемый в наследниках. Снаружи выглядит как полиморфная свободная функция, но внутри будет обычная виртуальная диспетчеризация, спрятанная за тонкой свободной обёрткой.

По сути это костыль, обходной приём, а не настоящая «виртуальная дружба» (таковой в языке нет и врядли будет), и тут надо не запутаться, когда виртуальность даёт метод, а свободная функция лишь переадресует в него. Также есть тонкости с тем, в каком классе объявлять дружественную функцию, чтобы её находил ADL, и с тем, чтобы не наплодить по такой функции в каждом наследнике (она нужна всего одна, в базе). Зато в результате получаем естественный синтаксис stream << obj с полиморфным поведением.

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

Скрытый текст
class Shape {
public:
    virtual ~Shape() = default;
    // свободный друг — один на иерархию, делегирует виртуальному print
    friend std::ostream& operator<<(std::ostream& os, const Shape& s) {
        s.print(os);            // полиморфизм здесь
        return os;
    }
protected:
    virtual void print(std::ostream& os) const = 0;
};

struct Circle : Shape { 
  void print(std::ostream& os) const override { 
    os << "Circle"; 
  } 
};

struct Square : Shape { 
  void print(std::ostream& os) const override { 
    os << "Square"; 
  } 
};

Shape* s = new Circle;
std::cout << *s;   // печатает "Circle" полиморфно через свободный operator<<

В разработке игр приём всплывает в отладочном и инструментальном коде, где нужно полиморфно выводить или сериализовать объекты иерархии через свободные функции и операторы, например сделать дамп состояния сущностей в лог, или текстово сериализовать полиморфные узлы сцены. Т.е. везде, где хочется писать log << *entity и получать вывод по реальному типу, virtual friend function будет стандартное решение.

В хотпасе это, разумеется, не используется, потому что и потоковый вывод и виртуальные вызовы там ни к чему, так что идиома живёт в диагностике, тулзах и текстовых форматах. Понимание, что «виртуальной дружбы» в языке нет, а есть лишь свободная обёртка над виртуальным методом, избавляет от попыток объявить virtual friend напрямую и недоумения, почему компилятор это отвергает.

Fake Vtable

Это ручная реализация того, что компилятор обычно делает за вас при при создании таблицы виртуальных методов. Вместо того чтобы полагаться на встроенный механизм виртуальности, вы сами заводите структуру с указателями на функции (это и есть «таблица») и сами вызываете через неё нужную функцию по указателю. По сути это полиморфизм, собранный руками из указателей на функции, минуя языковую логику виртуальных классов.

Часто это нужно, когда требуется контроль над раскладкой и вы точно знаете, где лежит таблица, сколько весит объект, как он сериализуется, и можете менять «тип» объекта в рантайме, подменив указатель на таблицу (чего с настоящим vtable не сделать).

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

Это сложно, дорого, чревато ошибками и теряет всякую помощь со стороны компилятора. Поэтому fake vtable всегда инструмент крайней необходимости, и в 99% случаев настоящие виртуальные функции и проще, и быстрее, и безопаснее.

Концептуально это известно давно, но понимание её, а заодно и понимание того, что компилятор генерирует для обычных виртуальных классов будет неплохим гайдом по разработке. Ручные таблицы указателей на функции, это древниший приём системного программирования на C (так устроены, все драйверы и плагинные API в ядре Linux), пришедший в C++ для случаев, где встроенная виртуальность по каким-то причинам не подходит.

Скрытый текст
struct AllocatorVTable {                 // "таблица" вручную
    void* (*allocate)(void* self, std::size_t);
    void  (*free)(void* self, void*);
};

struct Allocator {
    const AllocatorVTable* vtable;        
    // указатель на таблицу как настоящий vptr
    
    void* state;
    void* allocate(std::size_t n) { return vtable->allocate(state, n); } 
    // ручная диспетчеризация
};

// "Тип" аллокатора можно подменить в рантайме, перенаправив vtable
// чего с настоящими виртуальными функциями не сделать

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

Еще частое местое обитание это рендер, где хотят полиморфизм, но с возможностью «горячей» подмены поведения (например, переключить реализацию системы на лету) или с раскладкой, дружественной к кешу и сериализации. Но это редкость и почти всегда осознанный размен ясности на контроль, а для подавляющего большинства кода настоящие виртуальные функции остаются самым правильным выбором, и fake vtable стоит знать прежде всего как устроена виртуальность изнутри.

Algebraic Hierarchy

Способ организовать иерархию числовых (алгебраических) типов так, чтобы пользователь работал с единым «фасадным» типом, за которым скрыто несколько конкретных представлений. Классическим примером будет число, которое может быть целым, рациональным, вещественным или комплексным, но пользователь оперирует одним типом Number, а внутри он динамически держит то или иное конкретное представление и выбирает оптимальное в зависимости от значения и операций.

Снаружи Number ведёт себя как обычное значение (копируется, складывается, сравнивается), а внутри хранит указатель на полиморфную реализацию (целое, дробь, флоат) и делегирует ей операции. При операциях между разными представлениями происходит продвижение к общему типу (сложили целое с дробью и получили дробь), как это делают математические системы, что объединяет идиомы handle/body, envelope/letter и обычный полиморфизм в специфическом применении к числовым башням.

Платить приходится за аллокации и виртуальные вызовами на арифметику, что на порядки медленнее машинных int и float, но для системы компьютерной алгебры или калькулятора произвольной точности это приемлемо, для хотпас кода уже будет проблемой. Плюс логика продвижения типов и сохранения значениевой семантики поверх полиморфной реализации часто нетривиальна в реализации.

Корни идеи восходят к классической работе Джеймса Коплина 1992 года, где он разбирал «envelope/letter» и числовые башни как пример, но это во многом академический и нишевый паттерн, иллюстрирующий, как построить «умный числовой тип» с динамическим представлением, сохранив для пользователя иллюзию обычного значения.

Скрытый текст
class Number {                                  // фасад-значение
    std::shared_ptr<const NumberImpl> impl_;    
    // полиморфное представление внутри
public:
    Number operator+(const Number& o) const { 
      return impl_->add(o.impl_); 
    }  // продвижение типов внутри
};

struct NumberImpl { 
  virtual Number add(std::shared_ptr<const NumberImpl>) const = 0; /* ... */ 
};

struct IntImpl : NumberImpl { 
  long long v; /* int+int=int, int+rational=rational ... */ 
};
struct RationalImpl : NumberImpl { 
  long long num, den; /* ... */ 
};

В играх algebraic hierarchy в чистом виде не применяется, и честно говоря игровая математика построена ровно на противоположном принципе, когда фиксированные конкретные типы (float, int, Vec3), специально еще и упрощают, и всё ради того, чтобы процессор и SIMD молотили числа на полной скорости, а «умное число» с аллокацией на каждую операцию это антипаттерн .

Где родственные идеи всё же всплывают, это опять же в инструментах и нерантаймовых системах, вроде редакторов выражений или скриптовых языказ и системах данных, где значение может быть «числом, строкой или вектором» (вариантные типы). Там динамическое представление за единым фасадом уместно, потому что производительность не критична, а гибкость важна. Но в ядре движка algebraic hierarchy это скорее пример того, как делать не надо, и понимание, почему так делать не надо будет полезно как иллюстрация дата-ориентированного мышления.

Polymorphic Exception

Это механизм корректной работы с исключениями в полиморфной иерархии классов исключений, когда бросить, поймать, при необходимости сохранить и перебросить исключение надо, не потеряв его реальный тип.

Базовое правило C++ ловить исключения по ссылке (catch (const std::exception&)), потому что ловля по значению срезает производный тип до базового (object slicing), теряя всю специфику конкретного исключения. Бросать же нужно временный объект, а копию для «вылета» создаёт сам механизм исключений.

Сложность возникает, когда исключение нужно не просто поймать и обработать на месте, а сохранить, передать в другой поток или перебросить позже, тогда встаёт задача «скопировать пойманное исключение, сохранив его динамический тип», что очень нетривиально, потому что вы поймали const std::exception&, но не знаете, какой именно это наследник, чтобы его скопировать и классическое решение ведет к виртуальным методам вродеraise() (бросить себя) и clone() (скопировать себя) в базовом классе исключения, чтобы исключение умело полиморфно воспроизводить и переносить само себя.

Платить тут придется ручной реализацией clone/raise в каждом классе исключений, что утомительно и легко ломается, а ошибки в проявляются в самый неподходящий момент, например уже при обработке другой ошибки выше по стеку.

C++11 в значительной мере решил задачу штатно, введя std::exception_ptr и функции std::current_exception() / std::rethrow_exception()и теперь любое исключение можно захватить в exception_ptr (с сохранением точного типа), передать куда угодно, в том числе в другой поток, и перебросить позже уже без всякого ручного clone.

Идея с виртуальными raise/clone была описана еще до C++11, а появление exception_ptr в C++11 во многом мотивированное как раз потребностью переносить исключения между потоками в асинхронном коде, что сделало ручную идиому в большинстве случаев ненужной, хотя понимание проблемы в целом и необходимости ловить по ссылке осталось.

Скрытый текст
// Современный способ перенести исключение, сохранив его точный тип:
std::exception_ptr captured;

void worker() {
    try { risky_load(); }
    catch (...) { captured = std::current_exception(); }   // захватили любой тип
}

void main_thread() {
    worker();
    if (captured) {
        try { std::rethrow_exception(captured); }          // перебросили здесь
        catch (const ResourceError& e) { /* точный тип сохранён */ }
        catch (const std::exception& e) { /* ловим по ссылке — без срезки */ }
    }
}

В играх отношение к исключениям, мягко говоря, никаое и значительная часть индустрии их вообще не использует, нередко собирая проекты с -fno-exceptions, чтобы не мешало некоторым оптимизациям. Поэтому ядро многих движков построено на кодах ошибок, std::optional/expected-подобных результатах и assert, а не на исключениях.

polymorphic exception всё же уместны в инструментах, редакторах, и загрузчиках ассетов и прочем нерантаймовом коде, где надёжная обработка ошибок важнее перфа, и где exception_ptr отлично переносит сбои между потоками асинхронной загрузки.

Polymorphic Value Types

Собственно про исключения мы поговорили, но такая же проблема актуальная и для обычных значения, и попытка полиморфное поведение (как у объектов в иерархии за указателем на базу) и значениевую семантику (как у int или Vec3 — копируется, кладётся в контейнер по значению, не требует ручного управления временем жизни) осталась. Обычно эти две вещи противопоставлены, потому что полиморфизм требует указателей и наследования, а значит ручного владения и риска слайсинга, а значения копируются легко, но не полиморфны.

Реализуется это обёрткой, которая внутри держит указатель на полиморфный объект иерархии, но ведёт себя как значение и её копирование делает глубокую копию через виртуальный clone, так что копия будет уже независимым объектом правильного динамического типа, без проблем со слайсингом и без разделения владения, а её уничтожение корректно удаляет хранимый объект. Снаружи вы получаете обычное значение, которе кладёте в std::vector, копируете, передаёте, а внутри сохраняется полиморфизм и точный тип.

Платить приходится за каждое копирование такого «значения», которе в 99% делает аллокацию и виртуальный clone, что дороже копирования обычного значения и неприемлемо в хотпасе или при интенсивном копировании. Плюс это требует, чтобы вся иерархия поддерживала clone, что частично убивает удобство и оплачивается ценой динамической памяти и виртуальных вызовов при каждой копии.

Идею активно продвигал Шон Перент в своих докладах о «value semantics and concept-based polymorphism», показывая, как обернуть полиморфизм в значения и избавить код от ручного управления указателями и от наследования в пользовательском API. В стандарт это вошло как std::polymorphic_value , но до сих пор критикуется, сторонниками наследования и евангелистами value-ориентированного дизайна.

Скрытый текст
// Обёртка ведёт себя как значение, но хранит полиморфный объект:
template <class Base>
class PolyValue {
    std::unique_ptr<Base> p_;
public:
    template <class D> PolyValue(D d) : p_(std::make_unique<D>(std::move(d))) {}
    PolyValue(const PolyValue& o) : p_(o.p_->clone()) {}
    // копия = глубокий clone, без срезки
    Base* operator->() { return p_.get(); }
};

// Можно класть полиморфные объекты в вектор ПО ЗНАЧЕНИЮ
std::vector<PolyValue<Shape>> shapes;
shapes.push_back(Circle{});
shapes.push_back(Square{});
auto copy = shapes;   // глубокая копия каждого, типы сохранены

В играх polymorphic value types в чистом виде встречаются редко из-за аллокации и, но идея «значениевой семантики для полиморфных штук» очень привлекательна для кода, где важна простота владения и безопасность, т.е. это опять редакторы, модели данных и системы undo/redo (где глубокое копирование состояния и есть суть отмены) или описания конфигураций и свойств.

И эта же идея, хорошо прижилась в инди играх, как часть того же дрейфа индустрии от «всё через наследование и указатели» к value-ориентированному и data-oriented дизайну и ECS паттернам, но это уже отдельный разговор.

Hierarchy Generation

Это отдельная техника, при которой целая иерархия классов порождается из списка типов автоматически, шаблонами, вместо того чтобы выписывать каждый класс руками. Вы даёте список типов движку (например, TypeList<int, float, std::string>) и «генератор», который рекурсивно строит по нему иерархию, т.е. класс, наследующий от обработчиков каждого типа из списка, или цепочку классов, по одному уровню на тип.

Это часто нужно, чтобы избежать ручного дублирования при создании семейств родственных классов и на этом строились, в частности, обобщённые функторы, абстрактные фабрики и вариантоподобные хранилища, где нужно «по одному чему-то на каждый тип из набора».

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

С приходом variadic templates (C++11) ручные TypeList и рекурсивные генераторы во многом устарели, и теперь у нас есть пакет параметров, которые можно разворачивать в иерархии и структуры данных напрямую, без громоздкой логики списков типов.

Идиому в её каноническом виде ввёл и начал проповедовать Александреску в начале нулевых вместе со всей инфраструктурой TypeList и генераторов иерархий библиотеки Loki, и пожалуй это была одна из самых впечатляющих демонстраций мощи шаблонного метапрограммирования того времени. А вылилось это в современные эквиваленты с variadic templates, std::tuple (который и есть, по сути, тот самый hierarchy generation, порождённsq по пакету типов) и std::variant.

Скрытый текст
// Линейная иерархия по уровню наследования на каждый тип из пакета
template <class... Ts> struct Handlers;
template <> struct Handlers<> {};                       // база рекурсии
template <class T, class... Rest>
struct Handlers<T, Rest...> : Handlers<Rest...> {
    virtual void handle(const T&) = 0;                  // обработчик для T
    using Handlers<Rest...>::handle;                    // плюс всё, что ниже
};

// Обработчик событий, умеющий по типу на каждое из событий, сгенерирован автоматически:
struct EventHandler : Handlers<KeyEvent, MouseEvent, ResizeEvent> {
    void handle(const KeyEvent&)    override { /* ... */ }
    void handle(const MouseEvent&)  override { /* ... */ }
    void handle(const ResizeEvent&) override { /* ... */ }
};

В играх вся эта древность давно переписана наstd::tuple и std::variant и variadic-шаблоны. Как отдельный вид это пробралось в ECS-движки, которые используют пакеты типов компонентов, чтобы генерировать хранилища и системы по списку компонентов, и отдельно это живет в сериализации, которая разворачивает поля структуры по списку их типов. Всё это уже реализации и наследники этой идеи генерации иерархий, просто на более новом и удобном синтаксисе.

Прямое же написание рекурсивных генераторов иерархий из TypeList в новом коде почти не оправдано, потому что variadic templates делают то же самое проще и компилируются быстрее. Про hierarchy generation полезно знать в первую очередь как про фундамент работы variadic шаблонов и этот принцип жив и здоров, он лишь сменил инструментарий с тяжёлых списков типов на лёгкие пакеты параметров.

Function Object (Functor)

Это объект, который притворяется функцией, потому что у него перегружен operator(), но от обычной функции его отличает возможность хранить состояние. В общем виде функтор - это класс с полями (состоянием) и оператором вызова, и каждый его экземпляр может быть настроен по-своему и помнить что-то между вызовами или захватить параметры при создании, т.е. по сути это «функция, у которой есть память».

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

Прямой явной цены здесь почти нет, и хотя функторы часто многословнее, чем хотелось бы (нужно объявить класс), что до C++11 было их главной болью, и чтобы передать в sort нестандартный компаратор, приходилось заводить отдельный класс где-то поодаль от места использования. Но лямбды C++11 решили ровно эту проблему на уровне компиляторов, обернув это в синтаксический сахар, который компилятор разворачивает в безымянный функтор с захваченными переменными в качестве полей. То есть каждая лямбда есть функтор, просто записанный кратко и на месте.

Концепция функторов в C++ оформилась вместе с STL Степанова и Ли в начале девяностых, где функторы (предикаты, компараторы, операции) были неотъемлемой частью обобщённых алгоритмов, и именно они дали STL репутацию «абстракции без накладных расходов». Стандартная библиотека всегда предпочитала функторы указателям на функции в шаблонных алгоритмах, а лямбды C++11 закрепили функторы как повседневный инструмент.

Скрытый текст
// Функтор с состоянием: компаратор, помнящий точку отсчёта
struct DistanceFromCamera {
    Vec3 camera;
    bool operator()(const Object& a, const Object& b) const {
        return dist2(a.pos, camera) < dist2(b.pos, camera);  
        // инлайнится в sort
    }
};

std::sort(objects.begin(), objects.end(), DistanceFromCamera{cam_pos});

// Лямбда тот же функтор, записанный кратко (компилятор генерирует класс сам):
std::sort(objects.begin(), objects.end(),
          [cam_pos](const Object& a, const Object& b){ 
            return dist2(a.pos,cam_pos) < dist2(b.pos,cam_pos); 
          });

Функторы и лямбды вездесущи, как компараторы для сортировки видимых объектов по расстоянию или по материалу, предикаты фильтрации сущностей, коллбэки событий, мелкие задачи для job-системы или операции, передаваемые в обобщённые проходы по данным.

Разница между лямбдой как функтором (тип известен статически, инлайнится, ноль накладных расходов) и лямбдой, завёрнутой в std::function (type erasure, виртуальный вызов, возможная аллокация), сказывается в хотпасе, когда std::function проигрывает по скорости. Разница не очень большая, но она есть и понимание этого механизма отличает производительный код от обычного.

Object Generator

Это вспомогательная функция, которая создаёт объект, выводя его шаблонные параметры из своих аргументов, чтобы вам не приходилось выписывать их руками. До C++17 шаблонные классы не умели выводить аргументы из конструктора и чтобы создать std::pair<int, std::string>, нужно было либо выписать все типы (std::pair<int, std::string>{1, "a"}), либо позвать std::make_pair(1, "a"), которая выведет типы за вас, вот этот самый make_pair и есть object generator, как функция-фабрика, существующая только ради вывода типов.

Смысл тут, переложить вывод типов на механизм вывода аргументов шаблонных функций, который работал всегда, в отличие от вывода для шаблонных классов, которого не было. Функция make_X(args...) смотрит на типы аргументов и выводит из них параметры шаблона X и возвращает сконструированный X<...>, что убирает дублирование (не нужно повторять типы, которые и так видны из аргументов) и делает код короче и устойчивее к изменениям типов.

Платать придется отдельными функциями на каждый шаблон (make_pair, make_tuple, make_shared, make_unique, bind...), и это явно был обходной приём вокруг отсутствующей возможности языка. В C++17 появился CTAD (class template argument deduction) вывод аргументов шаблона класса прямо из конструктора, после чего многие object generators стали не нужны и теперь можно писать std::pair{1, std::string{"a"}}, который выводит типы сам.

Но исторически большая часть генераторов пережила CTAD, потому что делает чуть более читаемый код и менее подвержены случайным ошибками.

Эта идея одна из фундаментальных в STL и Boost: std::make_pair (Степанов и компания), которая затем выросла в std::make_tuple, std::make_shared, boost::bind/std::bind , и осталась обобщённым приемом после C++17.

Скрытый текст
// Object generator и типы выводятся из аргументов, писать их руками не надо
template <class A, class B>
std::pair<A, B> make_pair(A a, B b) { return {std::move(a), std::move(b)}; }

auto p = make_pair(42, std::string{"hp"});   // вывел pair<int, string>

// С++17 CTAD во многих случаях убирает нужду в генераторе:
std::pair p2{42, std::string{"hp"}};         
// тоже pair<int, string>, без make_

object generators в виде make_unique/make_shared это повседневность для создания владеющих указателей, а make_pair/make_tuple нет-нет да встретятся в обобщённом коде, хотя CTAD многое из этого вытеснил. Движки нередко пишут и собственные генераторы для своих шаблонных типов в виде make_handle, make_span, или фабрики типизированных идентификаторов и обёрток.

Ценность make_shared/make_unique не только в выводе типов, сколько в объединии аллокации объекта и управляющего блока в одну (меньше промахов кеша, меньше работы аллокатору), а make_unique гарантирует отсутствие утечки при исключениях в сложных выражениях, так что эти генераторы стоит предпочитать голому new не только ради краткости.

Object Template

Object Template (не путать с шаблонами) это механизм про сериализуемые объекты-«шаблоны», описывающие, как создать и настроить объект, отделяя описание от самого экземпляра. Идея в том, что у вас есть данные-описание (template/blueprint/archetype), которые задают начальное состояние и состав объекта, и фабрика, которая по этому описанию порождает настоящие объекты. Само описание тоже данные, которые можно загрузить из файла, отредактировать в инструменте или размножить.

А Смысл механизма, отделить «как объект выглядит и из чего состоит» от «вот конкретный живой объект». Описание существует как редактируемые данные, не как код, и потому его можно менять без перекомпиляции, хранить в ассетах, версионировать, переиспользовать. Один «шаблон врага» порождает сотни конкретных врагов, но правка шаблона меняет всех будущих (а иногда и существующих) и это уже больше data-driven подход, когда поведение и состав объектов определяются данными, а не зашиты в классы.

Платить приходится механизмом отображения данных-описания на реальные типы и поля (по сути, форма рефлексии или регистрации типов), и нужно решить вопрос наследования самих шаблонов-описаний, когда один шаблон расширяет другой, что усложняет всю систему. И понимать что делать с уже созданными объектами при изменении их шаблона, но выгода перевешивать, потому что гибкость и возможность отдать создание контента дизайнерам без программиста - это лучшее что может сделать программист в движке.

В геймдеве шаблоны объектов являются одной из центральных архитектурных идей, известная под именами prefab, blueprint, archetype, data template, и её начало кроется в самом data-driven дизайне, который индустрия выработала, чтобы дизайнеры могли создавать и настраивать контент без программистов, и чтобы итерации не требовали пересборки кода.

Скрытый текст
// Описание-«чертёж» как данные (загружается из файла, редактируется в тулзе):
struct EntityTemplate {
    std::string name;
    float max_health = 100;
    std::vector<ComponentDesc> components;   // какие компоненты и с какими параметрами
};

// Фабрика порождает живые сущности по чертежу:
Entity spawn_from_template(const EntityTemplate& tmpl, World& world) {
    Entity e = world.create();
    for (const auto& c : tmpl.components)
        component_registry().build(c, e, world);   // отображение данных на реальные типы
    return e;
}

Все это выродилось в prefab'ы Unity, blueprint'ы Unreal, archetype'ы в ECS-движках, и враги, предметы, эффекты и даже уровни теперь описываются данными-шаблонами, которые дизайнеры создают и правят в редакторе, а движок инстанцирует в рантайме. Это позволяет создавать огромные объёмы контента без программиста и итерировать, не пересобирая игру.

Под этой data-driven надстройкой почти всегда лежит C++-инфраструктура рефлексии, регистрации типов и фабрик (часто построенная на attach-by-initialization для саморегистрации компонентов и на member detector/traits для сериализации полей). То есть object template как высокоуровневая идиома опирается на целый набор низкоуровневых C++-идеи выше по списку, без которых она была бы невозможна и это хороший пример того, как отдельные абстрактные шаблонные приёмы складываются в цельный фундамент, на котором стоит вполне осязаемая и важная для продакшена возможность, отдать создание контента в руки дизайнеров, а программистам дать время выпить чащечку другую кофе.

Iterator Pair

Это фундаментальный механизм STL, который он же сам и породил для алгоритмов. Диапазон элементов задаётся не контейнером и не указателем с длиной, а парой итераторов на начало и на «за-концом» (one-past-the-end). Все стандартные алгоритмы построены поверх (begin, end), и эта пара полностью описывает последовательность, над которой нужно работать, ничего не зная о том, в каком контейнере она лежит и лежит ли вообще.

Так удалось развязать алгоритмы и контейнеры, потому что std::sort не надо знать, сортирует ли он vector, кусок массива или диапазон внутри deque, и ему достаточно пары итераторов нужной категории.

На этой идее построена вся ортогональность STL «M контейнеров × N алгоритмов = M+N кода вместо M×N» и алгоритмы пишутся один раз для итераторов, а контейнеры предоставляют итераторы, давая возможность любому алгоритм работать с любым контейнером.

Платить приходится негласным соглашением, что пара итераторов корректна на уровне типа. Вы можете легко передать итераторы от разных контейнеров или перепутать порядок, получив неопределённое поведение без всякой диагностики.

Впридачу мы получили проблему с инвалидацией итераторов при изменении контейнера, как отдельный вечный источник багов. Решить эту проблему пытались с самого рождения языка, и вылилось это все в концепцию ranges, когда диапазон стал единым объектом (std::ranges::sort(v) вместо sort(v.begin(), v.end())), но который внутри по-прежнему пара итераторов, а снаружи вроде бы одна сущность, но которую уже труднее испортить и которую можно лениво композировать через views.

Собственно эта идея про итераторы и есть сердце STL Степанова и он сознательно строил всю библиотеку вокруг итераторов как обобщения указателей, и пара итераторов будет жить с нами, пока жива сама STL. Это, возможно, самое влиятельное проектное решение в истории, определившее, как выглядит и будет выглядеть обобщённый код на десятилетия вперёд.

Скрытый текст
// Алгоритм работает с парой итераторов, не зная про контейнер:
template <class It, class T>
It find(It begin, It end, const T& value) {
    for (; begin != end; ++begin)
        if (*begin == value) return begin;
    return end;                            // "не найдено" = итератор на за-концом
}

std::vector<int> hp = {100, 80, 0, 60};
auto dead = find(hp.begin(), hp.end(), 0);     // работает с vector
int arr[] = {5, 3, 8};
auto it = find(arr, arr + 3, 8);               // и с сырым массивом — тот же код

// C++20 ranges: диапазон как единый объект
std::ranges::sort(hp);

Отдельно стоит отметить, что итераторный интерфейс позволяет применять алгоритмы к под-диапазонам и к нестандартным «контейнерам» вроде кусков пула или представлений над чужой памятью, что в движках встречается постоянно. C++20 ranges и std::span (невладеющее представление диапазона как пары «указатель + длина») сделали это ещё удобнее и безопаснее, и современный движковый код всё активнее использует span, который особенно хорошо прижился как способ передавать «вид на массив» без привязки к конкретному контейнеру и без копирования.

Generic Container

Под этим понимают набор соглашений, которым следует контейнер, чтобы стать «хорошим гражданином» в мире STL, то есть чтобы с ним работали стандартные алгоритмы, range-based for, и чтобы он вёл себя предсказуемо для всех, кто привык к стандартным контейнерам. Этоа свод правил, какие вложенные типы предоставить (value_type, iterator, const_iterator, size_type), какие методы (begin/end, size, empty), и какую семантику копирования и обмена.

Это правила «хорошего тона для контейнера» и если ваш контейнер предоставляет begin()/end(), его уже можно использовать в range-based for и в стандартных алгоритмах. Если он добавляет правильные вложенные typedef'ы, с ним работают iterator_traits и обобщённый код, который их спрашивает. Если у него корректные swap и семантика значения, он дружит с контейнерами контейнеров и copy-and-swap. Следование этим соглашениям делает ваш тип взаимозаменяемым со стандартными и встраиваемым в существующую экосистему без специального кода.

Платить за это приходится кодом, которого надо написать очень много и ручками, исторически все правила и соглашения были неформальными (просто «делай как std::vector»), и легко что-то упустить, получив контейнер, который почти работает. Плюс полноценная реализация всех требований (включая аллокатор-осведомлённость, корректные категории итераторов, exception safety) это серьёзный объём кода, и только C++20 формализовал значительную часть этих неявных соглашений в виде концептов (std::ranges::range, std::input_iterator и т.д.).

Эти соглашения появлялись по мере развития STL и десятилетиями жили как её дух в виде «требований к контейнерам» из стандарта, и как практика «подражай стандартным контейнерам».

Скрытый текст
// Минимум, чтобы кастомный контейнер дружил с range-for и алгоритмами:
template <class T, std::size_t N>
class FixedVector {
    T data_[N];
    std::size_t size_ = 0;
public:
    using value_type = T;                 // вложенные typedef'ы для обобщённого кода
    using iterator = T*;
    using const_iterator = const T*;

    iterator begin() { return data_; }    // begin/end => range-for и алгоритмы работают
    iterator end()   { return data_ + size_; }
    std::size_t size() const { return size_; }
    bool empty() const { return size_ == 0; }
};

FixedVector<Enemy, 64> enemies;
for (auto& e : enemies) { /* работает */ }
std::sort(enemies.begin(), enemies.end());   // и алгоритмы тоже

Большинство популярных движковых библиотек контейнеров (EASTL от Electronic Arts как самый известный пример) сознательно повторяют интерфейс STL именно ради этой совместимости, отличаясь реализацией (аллокаторы, рост, отладочные проверки), но не интерфейсом. Это даёт лучшее из двух миров: контроль над памятью и производительностью своих контейнеров плюс совместимость со всей экосистемой обобщённого кода. Поэтому понимание «что делает контейнер контейнером» практический навык разработчика, которому важно не только академическое знание стандартной библиотеки.

Erase-Remove

Это поведение при удалении элементов из последовательного контейнера, родившаяся из неочевидного устройства алгоритма std::remove, когда std::removeremove_if) ничего не удаляет, потому что работает с парой итераторов и не имеет доступа к самому контейнеру, чтобы изменить его размер. Вместо этого remove переставляет элементы и сдвигает «нужные» элементы в начало, затирая ими «ненужные», а потом возвращает итератор на новый логический конец, а вот уже «хвост» после него остаётся с неопределённым (но валидным) содержимым.

Чтобы реально удалить данные теперь нужен второй шаг, и метод контейнера erase, которому передают диапазон от нового логического конца до старого физического, вроде v.erase(std::remove_if(v.begin(), v.end(), pred), v.end()). remove_if уплотняет нужные элементы и возвращает границу, а erase обрезает хвост. Отсюда и название.

Платить за этот механизм приходится двухшаговостью, на которой спотыкались поколения программистов, и просто вызвать один remove_if без erase это классическая ошибка, после которой «удалённые» элементы остаются в контейнере (размер не изменился), просто переехав в хвост.

Механизм неинтуитивен и требует помнить, зачем тут два вызова, и только C++20 наконец дал прямые функции вродуstd::erase и std::erase_if, принимающие контейнер и предикат и делающие всё за один вызов, т.е. после двадцати лет erase-remove язык признал, что людям нужна была просто «удали элементы по условию».

Это прямое следствие дизайна STL, когда алгоритмы намеренно отвязаны от контейнеров и потому физически не могут менять их размер. Майерс посвятил erase-remove отдельные пункты в Effective STL, объясняя и саму идиому, и почему remove устроен именно так, и эти страницы спасли немало людей от бага «удаление, которое не удаляет».

Скрытый текст
// Удалить всех мёртвых врагов из вектора:
enemies.erase(
    std::remove_if(enemies.begin(), enemies.end(),
                   [](const Enemy& e){ return e.hp <= 0; }),   // уплотняет живых, вернёт границу
    enemies.end());                                            // erase обрезает хвост мёртвых

// C++20 — то же самое одним вызовом:
std::erase_if(enemies, [](const Enemy& e){ return e.hp <= 0; });

Стоит, впрочем, отметить, что в хотпасе движки часто применяют более быстрый приём swap-and-pop, если порядок элементов не важен, когда удаляемый элемент меняют местами с последним и укорачивают вектор на один, получая удаление за константу вместо сдвига. Erase-remove же незаменим, когда порядок важен или когда удаляется сразу много элементов по условию, но в любом случае надо понимать, что std::remove сам по себе ничего не удаляет.

Clear-and-minimize

Этот механизм решает специфическую проблему, как не просто очистить контейнер от элементов, но и заставить его вернуть операционной системе занятую под них память, потому что vector::clear() уничтожает элементы, но не освобождает выделенную ёмкость (capacity) и вектор остаётся с той же зарезервированной памятью, готовый снова её заполнить без новых аллокаций. Обычно это и нужно, но иногда вы хотите именно вернуть память, например, когда контейнер раздулся до пика и больше таким большим не будет.

Классическое до C++11 решение было «swap с пустым временным контейнером» std::vector<T>().swap(v), когда создаётся пустой временный вектор (с нулевой ёмкостью), он обменивается содержимым с вашим, и теперь ваш вектор пуст и с нулевой ёмкостью, а временный забрал старый большой буфер и тут же умирает в конце выражения, освобождая его. Элегантный трюк, использующий то, что swap меняет и данные, и ёмкость, а деструктор временного делает грязную работу.

Но платить за это приходится неочевидностью самого приёма (для непосвящённого vector<T>().swap(v) выглядит как ненужная фигня), а не освобождение памяти. Часто сохранить ёмкость для переиспользования выгоднее, чем вернуть память и потом аллоцировать заново. C++11 наконец дал более читаемый, хоть и необязывающий, shrink_to_fit() , а для полной очистки с освобождением комбинацию clear() + shrink_to_fit(), что выражает намерение явно, а не через swap-трюк.

Идиома была описана у Скотта Майерса в Effective STL, но часто она пропускается, и многие забывают, что у вектора есть два независимых понятия: размер (сколько элементов) и ёмкость (сколько памяти выделено), и что стандартные операции по-разному на них влияют, а управление ёмкостью требует отдельных приёмов.

Скрытый текст
std::vector<Particle> particles;
particles.resize(1'000'000);   
// пиковая нагрузка: выделили память под миллион

// ... всплеск закончился, частицы больше не нужны в таком объёме ...

particles.clear();              
// размер 0, но память под миллион ВСЁ ЕЩЁ занята

std::vector<Particle>().swap(particles);   
// do C++11 освобождаем память по-настоящему

// или C++11:
particles.clear();
particles.shrink_to_fit();                  
// читаемее: очистить и вернуть память

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

Но в хотпасе действует противоположная стратегия, когда ёмкость как раз сохраняют, чтобы переиспользовать буферы между кадрами без повторных аллокаций. Вектор частиц или список видимых объектов очищают через clear() (сохраняя ёмкость) в начале каждого кадра и заполняют заново и это классический способ избежать аллокаций в кадре. Так что clear-and-minimize и «clear с сохранением ёмкости» это два инструмента для двух противоположных ситуаций.

Shrink-to-fit

Логично, что эта идея (а с C++11 и штатный метод), появилась из как частный случай секции выше, т.е. приведения ёмкости контейнера в соответствие с его реальным размером, когда надо убрать «лишнюю» зарезервированную память и оставить ровно столько, сколько нужно под текущие элементы. Это родственник clear-and-minimize, но более общий и он уменьшает ёмкость до размера не только для пустого, а для любого контейнера, у которого ёмкость заметно превышает число элементов.

Векторы имеют свойство расти с запасом (обычно удваивая ёмкость при переполнении, чтобы амортизировать стоимость роста), и после серии добавлений и удалений ёмкость может оказаться вдвое-втрое больше реально нужной. И если такой контейнер будет долго жить в этом состоянии, лишняя память пропадает зря, а Shrink-to-fit говорит «подгони ёмкость под размер», возвращая излишек.

Платить за это приходится отдельным вызовомshrink_to_fit() , но это не является обязательным и стандарт разрешает реализации проигнорировать его и ничего не уменьшить. На практике же, основные реализации его уважают, но полагаться на гарантированное освобождение нельзя. Кроме того, уменьшение ёмкости обычно реализуется через перевыделение и перемещение всех элементов в новый, точно подогнанный буфер, то есть это не бесплатная операция, а полный проход с аллокацией, и злоупотреблять им в хотпасе так себе идея. До C++11 того же эффекта добивались тем же swap-трюком, что и в clear-and-minimize, только меняясь с копией (std::vector<T>(v).swap(v)).

Скрытый текст
std::vector<RenderCommand> commands;
commands.reserve(10000);          // зарезервировали с большим запасом
build_commands(commands);         // а реально заполнили, скажем, 1200

// Кадр построен, список команд будет жить до конца кадра — вернём лишнее:
commands.shrink_to_fit();         // ёмкость ~подгоняется под 1200 (необязывающе!)

// доC++11 эквивалент:
std::vector<RenderCommand>(commands).swap(commands);

В играх shrink-to-fit применяют точечно и осознанно, обычно для долгоживущих контейнеров, которые раздулись, и чью лишнюю память выгоднее вернуть, чем держать. Например, контейнеры, заполняемые на загрузке уровня с запасом и затем «замораживаемые» на всё время игры на уровне, и после загрузки их разумно сжать, освободив память под игровой процесс.

А вот в ежекадровых, переиспользуемых контейнерах shrink-to-fit обычно противопоказан, и там лишняя ёмкость это удобная фича, которая избавляет от аллокаций при следующем заполнении, и сжимать такой контейнер значило бы платить перевыделением каждый кадр. Поэтому практическое правило в движках будет сжимай только то, что раздулось и больше расти не будет.

Safe bool

Эта решение хитрой проблему до C++11, как дать классу возможность использоваться в булевом контексте (if (obj), while (ptr)), не открыв при этом дверь для опасных неявных преобразований. Наивное решение было добавить operator bool(), который в целом работает, но имеет жуткие побочные эффекты в виде неявного конверта в bool, а bool в int, то есть начинают внезапно компилироваться бессмысленные выражения вроде obj << 1, obj + 5, или сравнение двух несвязанных умных указателей через приведение к bool, а объект «протекает» в арифметику и сравнения, где ему не место.

Safe bool обходил это, возвращая не bool, а указатель на член-функцию (или другой тип, конвертируемый в bool в условии, но не в int) и такой тип годится для проверки в if, потому что указатель сравнивается с нулём, но не участвует в арифметике и не конвертируется в число, что отсекает весь опасный класс выражений. Реализация в целом была громоздкой, нужен был приватный тип-член, возврат указателя на фиктивный метод, и всё это ради того, чтобы if (obj) работало, а int x = obj + 1 уже нет.

Платить приходилось многословностью и неочевидностью решения, плюс она всё равно не закрывала все дыры и только C++11 решил проблему и окончательно, введя explicit-операторы преобразования вродеexplicit operator bool() const. Ключевое слово explicit означает, что преобразование срабатывает в булевом контексте (условие if, while, &&, ||) автоматически, но не срабатывает неявно нигде. Словом это ровно то, чего safe bool добивался кошмарным обходным путём, теперь выражается одним словом.

Саму идею в её каноническом виде описал еще Бьёрн Карлссон и популяризировали авторы Boost (где она использовалась в boost::shared_ptr и других умных указателях до C++11), а детальный разбор был в статьях того периода. Появление explicit-операторов преобразования в C++11 было во многом затаскиванием их идей в стандаорт, когда комитет признал, что раз все городят эту идиому, языку нужен нормальный механизм.

Скрытый текст
// доC++11: safe bool через указатель на член (громоздко!)
class Handle {
    void (Handle::*safe_bool_)() const;
    void this_type_does_not_support_comparisons() const {}
public:
    operator decltype(safe_bool_)() const {
        return valid_ 
                 ? &Handle::this_type_does_not_support_comparisons 
                 : nullptr;
    }
    bool valid_ = false;
};

// C++11 — то же самое, одним словом:
class Handle2 {
    bool valid_ = false;
public:
    explicit operator bool() const { return valid_; }  
    // работает в if, но не в арифметике
};

Handle2 h;
if (h) { /* OK */ }
// int x = h + 1;   // ошибка компиляции — и слава богу

В современном движковом коде это просто explicit operator bool, и его стоит добавлять всем хендлам, опциональным обёрткам и result-типам, чтобы они естественно работали в if, оставаясь защищёнными от случайных арифметических глупостей. Safe bool интересен в основном как исторический урок, когда целая громоздкая идиома существовала только потому, что в языке не хватало одного ключевого слова, и её исчезновение уже хороший пример того, как эволюция языка затаскивает хорошие идеи внутрь.

Type Safe Enum

Раньше отдельная реализация, а с C++11 и языковая возможность создания перечислений, которые не страдают от дыр в типобезопасности классических C-style enum. У старых нескоупленных перечислений было три беды: их имена «вываливаются» в окружающую область видимости (два enum'а с членом Red конфликтуют), они неявно конвертируются в int (что позволяет складывать, сравнивать и путать значения разных перечислений), и их базовый тип неопределён, что мешает форвард-декларации и контролю размера.

Раньше это лечили, заворачивая перечисление в класс и тогда значения становились статическими константами или объектами класса-обёртки, а область видимости ограничивалась классом, а неявные преобразования в int запрещались. Получался «настоящий» типобезопасный enum, где значения нужно квалифицировать именем типа, а разные перечисления нельзя смешивать. Компилятор сам ловил попытки использовать значение одного enum'а там, где ждут другой.

Платить за это надо было многословностью и приходилось воспроизвести руками всё поведение enum (вплоть до использование в switch, как параметра шаблона, в качестве значения), что было трудно и громоздко.

C++11 решил проблему, введя enum class (scoped enumerations) и такие имена уже не загрязняют область видимости (Color::Red), нет неявного преобразования в int (нужен явный static_cast), и можно задать базовый тип (enum class Flags : std::uint8_t), контролируя размер и позволяя форвард-декларацию. Это ровно то, чего добивались обёртки раньше, но теперь это есть как часть языка.

enum class в C++11 было предложением в том числе и от Страуструпа, и стало каноническим решением, а где хочется арифметики/побитовых операций, для которых enum class требует либо явных кастов, либо перегрузки операторов, тут до сих пор пишут вспомогательные обёртки и макросы.

Скрытый текст
// C-style enum — дырявый:
enum Color { Red, Green, Blue };        // Red протекает в область видимости
enum Fruit { Apple, Banana, Red2 };     // конфликт имён, если назвать Red
int x = Red + Blue;                     // компилируется (а не должно бы)

// C++11 enum class — типобезопасный:
enum class Team : std::uint8_t { Red, Blue, Neutral };   // задан размер
Team t = Team::Red;                     // имя квалифицировано
// int y = t;                           // ошибка: нет неявного преобразования
int y = static_cast<int>(t);            // нужен явный каст — намерение видно

В играх типобезопасные перечисления давно уже повседневность и большое благо, потому что игровой код пестрит перечислениями, вроде состояний (idle/walk/attack/dead), команд, фракций, урона или коллизий. C-style enum в большой кодовой базе был постоянным источником багов, а перепутанные значения разных перечислений компилировались и маскировали ошибки, поэтомуenum class ловит это на этапе компиляции. Так что type safe enum в форме enum class стало «бесплатной» практикой, которая ничего не стоит в рантайме, но заметно сокращает класс ошибок, и применять её стоит по умолчанию везде, кроме случаев совместимости со старым C-API.

Attorney-Client

Эта идея решает проблему, присущую механизму friend, когда вы объявляете класс или функцию другом, то вы даёте ему доступ ко всем приватным членам сразу и нет способа сказать «дружи только с этими тремя методами, а с остальными не дружи», что нарушает инкапсуляцию. И другу часто нужен доступ к одной-двум деталям, а получает он ключи от всей квартиры.

Идея что «клиент» (ваш класс с приватными членами) дружит не напрямую с тем, кому нужен доступ, а с классом-«холдером» (attorney) или адвокатом. Адвокат друг клиента и потому видит всё приватное, но наружу выставляет лишь узкий, тщательно отобранный набор статических методов, открывающих ровно те детали, которые нужны конкретному знакомому. Знакомый теперь не ваш друг, а работает через адвоката и получает доступ только к тому, что адвокат решил предоставить. Так большая дружба становится только нужным знакомством.

Платить за это приходится дополнительным слоем шаблонного кода, ради только настройки доступа, который усложняет понимание и который нужен довольно редко. Часто потребность в attorney-client является сигналом, что вы сделали класс слишком большим и стоит пересмотреть его архитектуру (возможно, приватная деталь должна быть отдельной сущностью). Плюс это все еще не настоящая защита, и ключи от квартиры теперь у адвоката, и достаточно мотивированный код всё равно может обойти инкапсуляцию. Идея выражает намерение, но не гарантирует отсуствие ключей от квартиры вообще. Это нишевый, но изящный приём для библиотек, которым нужно открыть часть внутренностей конкретным сотрудничающим компонентам, не распахивая их всему миру.

Скрытый текст
class Renderer {
    void set_internal_state(int);     // приватная деталь
    friend class RendererAttorney;    // дружим только с адвокатом
};

// Адвокат открывает наружу РОВНО ОДИН метод, и только доверенному коду
class RendererAttorney {
    static void set_state(Renderer& r, int s) { r.set_internal_state(s); }
    friend class DebugOverlay;        // вот кому адвокат разрешает
};

// DebugOverlay получает доступ к
// одной детали, не видя остального приватного Renderer

В играх attorney-client встречается в специфических местах на границах подсистем движка, где, скажем, сериализатор или отладочный инспектор должен залезть в приватные кишки класса, но только в строго определённые, а не во все. Вместо того чтобы делать сериализатор всеобъемлющим другом каждого класса (распахивая всю инкапсуляцию ради сохранения пары полей), адвокат открывает ему ровно нужные поля.

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

Making New Friends (сложно)

Из предыдущей секции рождается понимание, что корректные объявлении дружественных функций для шаблонных классов, могут сделать, чтобы у каждой инстанциации шаблонного класса была своя дружественная функция, правильно с ним связанная. Когда вы объявляете friend-функцию внутри шаблона, то «одна функция-шаблон, может дружить со всеми инстанциациями», а другая «своя только для отдельной инстанциации».

Каноническая схема - это определить дружественную функцию прямо в теле шаблонного класса (это тот самый приём «hidden friend», родственный трюку Бартона-Накмана), тогда для каждой инстанциации шаблона генерируется своя нешаблонная дружественная функция, видимая только через ADL и корректно работающая именно с этой инстанциацией. Это «заводит нового друга» для каждого конкретного типа, отсюда и название, но можно объявить friend-шаблон, или friend конкретной инстанциации, что требует предварительных объявлений и тонкой возни с тем, чтобы типы совпали.

Расплачиваемся мы здесь комбинаторикой взаимодействия шаблонов, дружбы, ADL и правил поиска имён и сделать «не так» очень легко, можно получить функцию, которая не находится, или находится, но не та, или конфликтует при множественных инстанциациях. Современный консенсус всегда предпочитать hidden friends (определение в теле класса), потому что они и проще, и эффективнее при разрешении перегрузок (не засоряют глобальный набор кандидатов).

Идиома описана давно и детально разбирается в Вандевурдом в его работах, а её актуальность выросла в последние годы благодаря «возрождению» hidden friends как рекомендуемой практики в работах по оптимизации времени компиляции и качества ошибок, где показано, что hidden friends заметно разгружают компилятор по сравнению с операторами, объявленными в области видимости namespace.

Скрытый текст
template <class T>
class Vector3 {
    T x_, y_, z_;
  
public:
    // hidden friend и своя нешаблонная функция для каждого Vector3<T>,
    // находится только через ADL, корректно связана с этой инстанциацией
    friend Vector3 operator+(const Vector3& a, const Vector3& b) {
        return {a.x_ + b.x_, a.y_ + b.y_, a.z_ + b.z_};
    }
  
    friend T dot(const Vector3& a, const Vector3& b) {
        return a.x_*b.x_ + a.y_*b.y_ + a.z_*b.z_;
    }
};

Vector3<float> a, b;
auto c = a + b;     // находит "своего" друга через ADL

Используется это при написании шаблонной математики, которой в играх полно в виде шаблонных векторов, матриц и кватернионов, параметризованных скалярным типом (float, double, half, fixed-point). Всем им нужны операторы (+, -, *, dot, cross), и определять эти операторы как hidden friends внутри шаблона будет самым чистым и эффективным способом, дающий правильную связь оператора с каждой инстанциацией и не засоряющий глобальное пространство имён.

Практическая выгода hidden friends в большой шаблонной кодовой базе не столько корректность, скролько влияение на скорость компиляции и качество ошибок, теперь операторы, спрятанные в классах, не участвуют в разрешении перегрузок для несвязанных типов, и компилятору приходится рассматривать меньше кандидатов.

В движке с обширной шаблонной математикой это складывается в ощутимую разницу времени сборки, так что making new friends уже не только про «как сделать, чтобы компилировалось», но и про «как сделать, чтобы компилировалось быстро и с понятными ошибками».

Non-member Non-friend Function (сложно)

Из двух предыдущих секций, родился принцип проектирования, гласящий, что если функцию можно реализовать как свободную функцию, не являющуюся ни членом класса, ни его другом (то есть пользуясь только публичным интерфейсом), то именно так её и следует делать. Звучит контринтуитивно, потому что мы привыкли пихать всё в методы класса.

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

Следовательно, чем больше функциональности вынесено в такие свободные функции, тем меньше кода завязано на приватные детали, тем выше инкапсуляция. Парадоксально, но вынос функций из класса наружу делает класс более инкапсулированным, а не менее.

Распачиваться за это приходится уходом от «объектного» программирования в глобальные методы и требует сознательного усилия, что часто вызывает споры в команде. Плюс свободные функции нужно куда-то поместить (обычно в тот же namespace, что и класс, чтобы их находил ADL), и их становится много. А еще не всё можно вынести, но сама идея, что класс предоставляет минимальный полный набор примитивных операций как методы, а всё остальное (удобные комбинации, алгоритмы) как свободные функции поверх них, и поныне жива среди игровых разработчиков.

Принцип сформулировал и горячо отстаивал Скотт Майерс в Effective C++, и его позже поддержал Херб Саттер, под их влияением это попало в стандартную библиотеку, где алгоритмы это самые яркие примеры свободных функций, и даже многие операции над строками вынесены наружу. После С++11 это стало одним из направлений современного «interface-минималистского» дизайна классов в разработке.

Скрытый текст
class Vec3 {
    float x_, y_, z_;
public:
    float x() const { return x_; }       // минимальный публичный интерфейс
    float y() const { return y_; }
    float z() const { return z_; }
    void set(float x, float y, float z) { x_=x; y_=y; z_=z; }
};

// length, normalize, lerp свободные, через ПУБЛИЧНЫЙ интерфейс.
// Меняя приватную раскладку нельхя  эти функции сломать.
float length(const Vec3& v) { 
  return std::sqrt(v.x()*v.x() + v.y()*v.y() + v.z()*v.z()); 
}

Vec3 lerp(const Vec3& a, const Vec3& b, float t) {
    return {a.x()+(b.x()-a.x())*t, /* ... */};
}

Из стандартной библиотеки этот принцип "протек" во все современные математические и геометрические типы движков, у которых огромный набор операций. Делать length, normalize, lerp, reflect, project, angle_between методами Vec3 означало бы раздувать класс и привязывать все эти операции к его внутренностям, а вынос их в свободные функции оставляет Vec3 компактным, с минимальным набором базовых операций, а богатую библиотеку операций над ним строит снаружи, не трогая приватные данные.

Это даёт и практическую гибкость, когда новые операции над типом можно добавлять, не редактируя сам тип (важно, когда тип в чужой библиотеке), и группировать их по назначению, а не вываливать в один распухший класс. Поэтому компактные математические типы плюс обширные namespace'ы свободных функций над ними стали практическим способом держать ключевые типы движка маленькими, устойчивыми к изменениям и расширяемыми.

 Small Object Optimization (SOO/SSO)

Это приём, при котором маленькие объекты хранятся прямо внутри обёртки, во встроенном буфере, вместо выделения под них памяти в куче если объект достаточно мал, чтобы поместиться в зарезервированное внутри обёртки место, то аллокация не происходит вовсе. Если же он больше, то обёртка падает обратно на кучу.

Самый известный частный случай эту Small String Optimization (SSO) Александрску, когда короткие строки (обычно до 15–22 символов) живут прямо в объекте std::string, без обращения к аллокатору.

Мотивация была избавиться от аллокаций в куче, которые дороги и непредсказуемы, а строки чаще короткие, чем длинные. Второй кандидат этоstd::function, который чаще оборачивает маленькую лямбду, чем огромный функтор. Реализуется обычно через объединение (union) встроенного буфера и указателя плюс флаг/дискриминатор «я сейчас маленький или большой», и вся логика прячется за интерфейсом.

Расплачиваться приходится сложностью реализации и бОльшими размерами класса, который специально раздувают чтобы поместить побольше данных, и больший буфер значит реже аллокации, но хуже плотность в массивах. Логика «маленький/большой» с union'ами, placement new и ручной диспетчеризацией копирования/перемещения очень нетривиальна и легко содержит баги. Плюс перемещение объекта с активным SOO означает физическое копирование буфера (нельзя просто украсть указатель), что иногда дороже, чем для heap-варианта.

Это давнее практическое знание о реализации строк и контейнеров, и SSO применялся в реализациях std::string десятилетиями (libc++, libstdc++, MSVC STL каждая со своим порогом и раскладкой), аstd::function, std::any и многие type-erasure-обёртки используют SOO, чтобы не аллоцировать под мелкие вызываемые объекты.

Скрытый текст
// Схематично: маленькое значение живёт в буфере, большое в куче
template <class T, std::size_t N = 32>
class SmallBuffer {
    alignas(T) std::byte inline_[N];
    T* ptr_;

    bool on_heap_;
public:
    template <class... Args>
    SmallBuffer(Args&&... a) {
        if constexpr (sizeof(T) <= N) {                   
            // влезает кладём внутрь
            ptr_ = new (inline_) T(std::forward<Args>(a)...);
            on_heap_ = false;
        } else {       
            // не влезает, аллок в кучу
            ptr_ = new T(std::forward<Args>(a)...);
            on_heap_ = true;
        }
    }
    // ... деструктор, move, copy с учётом on_heap_ ...
};

В играх SOO традиционо считается однай из самых ценных оптимизаций, потому что движки одержимы избеганием аллокаций в кадре, а мелкие объекты повсюду. И к SSO идее приведены кастомные строковые типы движков, и контейнеры вроде inline_vector/fixed_vector (хранящие первые N элементов inline и уходящие в кучу только при переполнении), и даже мапы и списки, где это используется для коротких списков (несколько коллизий, пара дочерних узлов или горстка тегов), которые иначе плодили бы аллокации, а EASTL и подобные библиотеки предоставляют такие контейнеры из коробки.

Грамотно подобранный порог буфера превращает «аллокация на каждый чих» в «аллокация только для редких крупных случаев», и на профайле памяти это видно как резкое падение числа обращений к аллокатору. Поэтому понимание SOO и умение применять inline-контейнеры обычный практический навык в оптимизации, как один из самых прямых способов убрать аллокации из хотпаса.

Prohibiting Heap-based Objects

В играх часто разделяют, где некоторым объектам разрешено или запрещено жить, только в куче, только на стеке, или как угодно. Иногда дизайн требует, чтобы объекты определённого класса создавались исключительно через new (например, потому что управляются счётчиком ссылок, который вызывает delete this, а значит объект на стеке привёл бы к крашу при попытке себя удалить). А иногда наоборот, объект обязан жить только на стеке (например, scope guard, и вся суть в привязке к области видимости, и создание которого в куче было бы ошибкой использования).

Запретить создание в куче можно, сделав operator new приватным (или удалённым) — тогда new MyClass не скомпилируется, и объект можно создать только как локальную/членскую переменную. Обратным будет запретить создание на стеке, что делается приватным деструктором, и если деструктор недоступен извне, компилятор не может создать автоматический объект (ему негде вызвать деструктор при выходе из области), но создание через new работает, а удаление идёт через специальный публичный метод destroy(), который зовёт delete this изнутри, где деструктор доступен. Так объект "насильно" загоняется в кучу.

Платить за оба ограничения приходится хрупкость и дисциплиной использования, которые нарушают ожидания пользователя («почему я не могу создать это на стеке?!»), а приватный деструктор к тому же ломает наследование и членство по значению. Идиома почти всегда значит, что у класса необычная модель времени жизни, и её стоит сопровождать жирным комментарием.

Все это подробно разобрал Скотт Майерс в More Effective C++ (отдельный пункт «как ограничить количество объектов» и связанные приёмы про размещение в куче/на стеке) и это классический метод как взять под контроль модель создания объектов, тесно связанное с reference-counting (Counted Body) и с особыми требованиями времени жизни.

Скрытый текст
// Только куча: приватный деструктор не даёт создать на стеке
class RefCountedResource {
    ~RefCountedResource() = default;          
    // приватный => нельзя на стеке
    int refs_ = 1;
public:
    void release() { if (--refs_ == 0) delete this; }   
    // удаление изнутри, где dtor доступен
};
// RefCountedResource r;        // ошибка: деструктор недоступен
// auto* p = new RefCountedResource;  // OK

// Только стек: удалённый operator new не даёт создать в куче
class ScopedLock {
public:
    static void* operator new(std::size_t) = delete;    
    // нельзя через new
};

В разработке игр случай «только куча» естественно возникает для объектов с интрузивным счётчиком ссылок (ресурсы, RHI-объекты), которые управляют своим временем жизни через delete this и потому не должны жить на стеке. А «только стек» нужны для RAII-обёрток вроде scope guard'ов, профайлер-таймеров и блокировок, чья семантика жёстко привязана к области видимости, и создание которых в куче было бы логической ошибкой, отменяющей весь смысл RAII.

Но в современном движковом коде эти ограничения чаще выражают мягче и вместо приватного деструктора делают фабрику, возвращающую intrusive_ptr/shared_ptr (что и так направляет в кучу и заодно даёт безопасное владение), а вместо запрета new просто делают [[nodiscard]] на конструкторе scope guard'а и отслеживают на ревью.

Жёсткие версии идиомы оставляют для случаев, где компилятор действительно должен ловить ошибку, потому что цена неверного размещения скорее всего краш в рантайме. Знать их стоит, чтобы понимать необычные классы с приватными деструкторами и удалёнными new, и не пытаться использовать их «нормально», упираясь в загадочные ошибки компиляции, за которыми стоит намеренное ограничение модели времени жизни.

Storage Class Tracker

Редкая и довольно сложная идиома, позволяющая объекту в рантайме узнать, где он размещён: на стеке, в куче или в статической памяти. Это связано с предыдущей идиомой, но решает не «запретить», а «выяснить и среагировать», когда объект хочет вести себя по-разному в зависимости от своего класса хранения, или хотя бы убедиться, что его создали правильно.

Реализация завязана на сравнение адресов, чтобы определить примерные границы стека (например, по адресу локальной переменной в текущем фрейме) и кучи и сравнить с ними адрес this. Один из приёмов будет переопределить operator new, который устанавливает флаг «следующий объект создаётся в куче» прямо перед аллокацией, а конструктор затем проверяет этот флаг, и если он не был установлен, значит объект родился не через new , т.е. на стеке или статически, а сброс и установка флага вокруг аллокации позволяют конструктору отличить heap-объект от прочих.

Платить приходится неопределённым поведения и переносимостью, потому что сравнение адресов с границами стека/кучи опирается на предположения о раскладке памяти, которые стандарт не гарантирует, а трюк с флагом в operator new ломается при множественном наследовании, массивах и многопоточности. Это очень хрупкий приём, работающий «обычно, на этой платформе, при этих условиях», потому в продакшене его сторонятся, и чаще правильный ответ будет спроектировать класс так, чтобы он был известен по размещению (фабрики, политики владения).

Применяется скорее как любопытный приём, чем как работающий код и иллюстрирует, как далеко можно зайти в попытках узнать о собственном размещении, и заодно, почему этого обычно делать не стоит, потому что надёжного и переносимого способа в стандартном C++ нет.

Скрытый текст
// Хрупкий приём: флаг "создаюсь в куче", выставляемый operator new
class TrackedObject {
    static thread_local bool heap_flag_;
    bool on_heap_;
  
public:
    static void* operator new(std::size_t n) { 
      heap_flag_ = true; 
      return ::operator new(n); 
    }
  
    TrackedObject() : on_heap_(heap_flag_) { 
      heap_flag_ = false; 
    }  // конструктор считывает
    bool is_on_heap() const { return on_heap_; }
};
// Ненадёжно при массивах, исключениях, множественном наследовании

В играх storage class tracker не применяют в продакшене из-за его хрупкости, когда надо стабильно работать на множестве платформ и компиляторов, где объекты крайне чувствительны к платформенным различиям раскладки памяти. Любой приём, опирающийся на сравнение адресов со «стеком» и «кучей», на консоли может повести себя непредсказуемо, из-за активных методов защиты стека.

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

Так что storage class tracker стоит знать в основном как иллюстрацию границы между «теоретически возможно» и «не стоит делать», как сам факт, что объект может попытаться узнать свой класс хранения сравнением адресов.

Execute-Around Pointer

Это идея применения «умного указателя», который выполняет некоторый код до и после каждого обращения к обёрнутому объекту, прозрачно для вызывающего. Вы пишете ptr->method() как обычно, но между вашим вызовом и реальным методом встраивается обвязка и что-то происходит перед каждым вызовом метода и что-то уже после него. Это применение идеи «execute around» (выполнить вокруг) к отдельным вызовам метода.

Теперь когда вы пишете wrapper->foo(), компилятор сначала зовёт operator-> обёртки, который возвращает не сам объект, а ещё один временный прокси-объект и у этого прокси тоже есть operator->, возвращающий уже настоящий объект. Временный прокси конструируется перед вызовом foo (его конструктор и есть «до») и уничтожается после завершения полного выражения (его деструктор «после»). Так конструктор/деструктор временного прокси оборачивают каждый вызов метода кодом «до» и «после», и это работает для любого метода объекта без перечисления их всех.

Расплачиваться придется неочевидность (двойной operator-> и временные прокси, которые трудно понять с первого взгляда) и накладными расходами на каждый вызов метода, когда создаётся и уничтожается прокси-объект. Плюс «до/после» одинаковы для всех методов и нельзя по-разному обрабатывать разные методы, не усложняя схему. Семантика времени жизни прокси (он живёт до конца полного выражения) даёт тонкости, если вызовы цепляются. Это коварный приём, уместный в узких сценариях.

Механихм описал Кевлин Хенни в статье «Function-Object and Execute-Around Pointer» как частный случай более широкой темы execute-around (к которой относятся и RAII, и scope guard), применённый именно к перехвату вызовов методов через прокси и двойной operator->. Это классический пример того, как перегрузка операторов в C++ позволяет встроить поведение в синтаксис, привычный пользователю.

Скрытый текст
template <class T>
class LockingPtr {
    T* obj_;
    std::mutex& mtx_;
    struct Proxy {
        T* obj_; std::mutex& mtx_;
        Proxy(T* o, std::mutex& m) : obj_(o), mtx_(m) { 
          mtx_.lock(); 
        } // "до" вызова
        ~Proxy() { 
          mtx_.unlock();
        } // "после" вызова
        
        T* operator->() { return obj_; }
    };
public:
    LockingPtr(T* o, std::mutex& m) : obj_(o), mtx_(m) {}
    Proxy operator->() { return Proxy(obj_, mtx_); }   
    // временный прокси оборачивает вызов
};

LockingPtr<Inventory> inv(&inventory, inv_mutex);
inv->add_item(sword);   // автоматически lock -> add_item -> unlock

В играх execute-around pointer находит применения там, где хочется прозрачно навесить обвязку на каждое обращение к объекту, вроде автоматической блокировки/разблокировки при доступе к разделяемому объекту, или логирование, или профилирование каждого вызова метода у отлаживаемого объекта, проверка инвариантов до и после модификации, dirty-флаги (пометить объект изменённым после любого мутирующего обращения).

Но в хотпасе накладные расходы на прокси-объект на каждый вызов обычно неприемлемы, поэтому все это живёт в отладочном и инфраструктурном коде, где удобство и прозрачность важнее перфа.

Temporary Proxy

Это механизм, при котором operator[] (или другой оператор доступа) возвращает не сам элемент и не ссылку на него, а временный объект-посредник, который умеет различать чтение и запись и реагировать на них по-разному. Нужна она, когда обнаружить факт записи в элемент через обычную ссылку невозможно, потому что ссылка не знает, читают через неё или пишут. Прокси же, перехватив operator= (запись) и operator T() (чтение), может различить эти ситуации.

Классические применение это реализацияstd::vector<bool>, где элементы упакованы по битам и «ссылку на бит» вернуть нельзя (бит не адресуется), поэтому operator[] возвращает прокси, который при чтении достаёт бит, а при присваивании устанавливает его. Или другой пример будет copy-on-write строки, где прокси при чтении символа не триггерит отделение копии, а при записи триггерит. Прокси здесь это «перехватчик», вставленный между синтаксисом доступа и реальными данными.

Платить приходится "прокси протеканием" иauto x = container[i] сохранит прокси, а не значение (классическая ловушка vector<bool>, из-за которой его считают «сломанным контейнером»). Прокси не является настоящей ссылкой, и код, ожидающий T&, с ним не работает, потому что адрес элемента через прокси не взять. Эти несоответствия делают прокси-контейнеры коварными, и vector<bool> главный пример того, почему «умный operator[]» бывает скорее проклятием, чем хорошим решением.

Все это разбиралось у Майерса (в том числе в контексте operator[], различающего чтение и запись, в More Effective C++) иstd::vector<bool> с его прокси reference стандартизированом еще в C++98, и служит и каноническим примером как хотят сделать, и каноническим предостережением "что получилось" одновременно.

Скрытый текст
// Прокси различает чтение и запись бита в упакованном битсете
class BitArray {
    std::vector<std::uint64_t> words_;
    struct BitProxy {
        std::uint64_t& word; int bit;
        
        operator bool() const { 
          return (word >> bit) & 1; 
        }   // чтение
        
        BitProxy& operator=(bool v) {                                
            // запись
            if (v) word |=  (1ull << bit);
            else   word &= ~(1ull << bit);
            return *this;
        }
    };
  
public:
    BitProxy operator[](std::size_t i) { return {words_[i/64], int(i%64)}; }
};

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

Но относиться к прокси-контейнерам надо с осторожностью, потому что есть ловушка с auto, несовместимость с кодом, ждущим настоящих ссылок, и накладные расходы прокси-объектов делают их инструментом для специальных случаев, а не для контейнеров общего назначения.

Многие движки сознательно избегают std::vector<bool> именно из-за его прокси-природы, заводя явные битсеты с понятными методами set/test вместо обманчиво-прозрачного []. Так что про temporary proxy полезно знать, когда это оправдано, и чтобы распознавать, почему некоторые [] ведут себя не как обычные, и не попадаться в связанные с этим ловушки.

Address Of

Известный механизм получения настоящего адреса объекта даже тогда, когда у его класса перегружен operator&. Да, в C++ можно перегрузить унарный оператор взятия адреса, и некоторые классы это делают (обычно ради хитрых прокси, COM-подобных умных указателей или DSL), но беда в том, что после такой перегрузки выражение &obj возвращает уже не адрес объекта, а что-то, что решил вернуть перегруженный оператор, и обобщённый код, которому нужен реальный адрес (например, чтобы сконструировать объект через placement new или сохранить указатель), оказывается обманут.

Надо обходить перегруженный operator&, добывать адрес «в обход», поэтому объект приводится к ссылке на символьный тип (через серию reinterpret_cast или const_cast), у которого operator& гарантированно не перегружен, берётся адрес уже этой символьной ссылки, и результат приводится обратно к нужному типу указателя. Цепочка приведений выглядит пугающе, но идея простая и надо просто спуститься на уровень «сырых байтов», где взятие адреса точно даёт настоящий адрес, и подняться обратно.

Платить приходится за сомнительную возможность перегружать operator&, которую многие считают ошибкой дизайна языка (перегрузка взятия адреса почти всегда приносит больше вреда, чем пользы, ломая обобщённый код и интуицию). То есть это лечение симптома плохой практики, да и реализация хрупкая и многословная, и писать её руками не нужно.

Механизм родился в дискуссиях вокруг Boost, и её канонической реализации boost::addressof, позже вошедшей в стандарт как std::addressof (C++11). Именно её используют стандартные контейнеры и аллокаторы внутри, когда им нужен настоящий адрес элемента, чтобы корректно работать даже с пользовательскими типами, перегрузившими operator&. C++17 добавил constexpr-версию, а необходимость в идиоме целиком переложена на библиотеку.

Скрытый текст
// Класс с перегруженным operator& (например, прокси) обманывает наивный &obj:
struct Sneaky {
    int* operator&() { return nullptr; }   // &obj вернёт nullptr, а не адрес!
};

Sneaky s;
Sneaky* wrong = &s;                  // nullptr — сюрприз
Sneaky* right = std::addressof(s);   // настоящий адрес, в обход operator&

// Обобщённый код (контейнеры, аллокаторы) внутри всегда использует std::addressof,
// чтобы не быть обманутым перегруженным operator&

В играхstd::addressof напрямую пишут редко, но он незаметно работает внутри любого обобщённого кода, манипулирующего адресами объектов, например в кастомных контейнерах и аллокаторах, системах сериализации или пулах объектов с placement new. Везде, где код должен взять настоящий адрес произвольного пользовательского типа, чтобы разместить там объект или сохранить указатель, std::addressof будет страховкой от типов, которые зачем-то перегрузили operator&.

Address Of рабоает как метод-санитар, существующий, чтобы чинить последствия сомнительной возможности языка, и лучший способ с ней взаимодействовать будет не создавать ей работы.

nullptr

Это типизированный нулевой указатель, появившийся в C++11 и решивший застарелую болезнь представления «никуда не указывающего» указателя. До него для этого использовали либо литерал 0, либо макрос NULL (который в C++ обычно был просто 0 или 0L). Но проблема в том, что 0 это в первую очередь целое число, и его «нулевоуказательность» лишь особый случай, из-за чего возникала путаница между «число ноль» и «нулевой указатель», особенно болезненная при разрешении перегрузок.

Классический пример беды, когда есть две перегрузки, f(int) и f(char*)и вызов f(NULL) намеревался выбрать указательную версию, но NULL это 0, то есть int, поэтому выбиралась f(int) иногда без ошибки и с неверным поведением. nullptr имеет специальный тип std::nullptr_t, который конвертируется в любой указатель, но не в int, поэтому f(nullptr) однозначно выбирает указательную перегрузку. Заодно nullptr не ломает вывод шаблонных типов так, как 0, и читается яснее, прямо говоря «это нулевой указатель», а не «это, возможно, ноль, а возможно, указатель».

Редкий случай, когда за механизм не приходится платить, и единственная «опасность» в том, что старый код всё ещё пестрит NULL и 0 в роли указателей, и при модернизации их стоит заменять. До C++11 идиому пытались эмулировать самописными классами-«nullptr» (тот самый return type resolver с шаблонным operator T*), но это были полумеры. Сам nullptr это, по сути, стандартизованный и доведённый до ума такой класс.

Идиому-предшественницу (самописный nullptr) описывали Скотт Майерс и Херб Саттер ещё до C++11, а в язык nullptr вошёл по предложению Херба Саттера и Бьёрна Страуструпа именно для устранения проблем NULL/0 с перегрузками и шаблонами.

Скрытый текст
void spawn(int count);
void spawn(Entity* parent);

spawn(0);          // вызывает spawn(int), а если хотели parent?
spawn(NULL);       // тоже spawn(int)! NULL это 0 и это int, баг.
spawn(nullptr);    // однозначно spawn(Entity*) то, что нужно

Entity* e = nullptr;        // ясно читается
if (e == nullptr) { /* ... */ }

Особой «игровой специфики» тут нет и это просто общая гигиена современного C++, но в больших кодовых базах движков с тысячами указательных операций последовательное использование nullptr заметно снижает класс ошибок и улучшает читаемость намерений. Практический совет прост и в новом коде только nullptr, а при работе со старым надо заменять NULL/0-указатели на nullptr при первой возможности. Это одна из тех мелочей, которые ничего не стоят, но делают код чуть-чуть безопаснее в каждом из множества мест, где он трогает указатели.

Move Constructor

Move Constructor (и парный ему move-оператор присваивания) стал краеугольным камнем move-семантики C++11, позволяющим «переместить» ресурсы из одного объекта в другой вместо их копирования. Когда исходный объект, вроде временного (rvalue) или явно помечен как «больше не нужен» (через std::move), его внутренности (указатель на буфер, дескриптор, владение) можно не копировать, а просто украсть, т.е. перекинуть указатели в новый объект, а старый оставить в пустом, но валидном состоянии, что превращает потенциально дорогую глубокую копию в дешёвый обмен нескольких указателей.

До C++11 этого механизма не было, и возврат тяжёлого объекта из функции или вставка его в контейнер означали реальную копию (дорого), либо ухищрения вроде злополучного auto_ptr с его «копированием-перемещением». Move-семантика дала языку понятие «исходник, у которого можно отобрать содержимое», т.е. rvalue-ссылки (T&&) отличают временные/отдаваемые объекты от обычных, и move-конструктор принимает именно такую ссылку, забирая ресурсы и обнуляя источник, аstd::move это просто приведение к rvalue-ссылке, говорящее «считай этот объект отдаваемым».

Платить приходится наличием «пустого, но валидного» состояния источника после перемещения (из него больше нельзя читать осмысленные данные, но деструктор и присваивание обязаны работать), и обязательная пометка move-операций как noexcept, без которой контейнеры (как обсуждалось в non-throwing swap) откатываются на копирование. Плюс правила автогенерации move-операций все еще тонкие, и объявление деструктора или копирующих операций подавляет автоматическую генерацию move, и тогда «перемещение» превращается в копирование, которое легко не заметить.

Move-семантику в C++11 спроектировали Говард Хиннант, Бьёрн Страуструп и Дэйв Абрахамс и это было одно из самых значительных и сложных изменений языка, переосмыслившее, как C++ работает со значениями и ресурсами. Она задним числом сделала «правильным» то, чего годами добивались идиомами вроде resource return и computational constructor, и стала фундаментом, на котором стоит вся современная стандартная библиотека.

Скрытый текст
class Mesh {
    std::size_t count_ = 0;
    Vertex* verts_ = nullptr;
public:
    // Move-конструктор: крадём буфер
    // источник обнуляем. noexcept обязателен
    Mesh(Mesh&& o) noexcept : count_(o.count_), verts_(o.verts_) {
        o.count_ = 0; o.verts_ = nullptr;     
        // источник пустой, но валидный
    }
    Mesh& operator=(Mesh&& o) noexcept {
        delete[] verts_;
        count_ = o.count_; verts_ = o.verts_;
        o.count_ = 0; o.verts_ = nullptr;
        return *this;
    }
    ~Mesh() { delete[] verts_; }
};

Mesh load();        
// возврат тяжёлого меша теперь дёшев (move, не copy)
std::vector<Mesh> meshes;
meshes.push_back(load());
// меш перемещается в вектор без копии буфера вершин

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

Implicit conversions

Свод знаний и предостережений о неявных преобразованиях типов, которые C++ выполняет автоматически. Язык щедро конвертирует одно в другое без спроса: int в double, производный класс в базовый, типы через конструкторы с одним аргументом, типы через операторы преобразования. Иногда это удобно и читаемо, а иногда источник багов, когда преобразование срабатывает там, где вы его не ждали, и компилируется код, который не должен был.

Главные инструменты управления это ключевые слова explicit и (с C++11) explicit на операторах преобразования. Конструктор с одним аргументом по умолчанию задаёт неявное преобразование: void f(Widget) можно нечаянно вызвать как f(42), если у Widget есть конструктор Widget(int), и компилятор молча построит временный Widget из числа. Пометив конструктор explicit, вы запрещаете это неявное преобразование, оставляя только явное Widget(42). То же с операторами преобразования: explicit operator bool() работает в условии, но не лезет в арифметику (как мы видели в safe bool).

Платить приходится балансом и читаемостью. Слишком много неявных преобразований делает код хрупким и полным сюрпризов (особенно опасны цепочки преобразований и их взаимодействие с перегрузками), а слишком мало заставляет писать утомительные явные приведения там, где преобразование очевидно и безопасно.

Общее правило, тут делать конструкторы с одним аргументом explicit, а неявность разрешать сознательно и только там, где она действительно улучшает читаемость и безопасна (например, преобразование Seconds в Duration).

Это правила были систематизированы у Майерса (несколько пунктов Effective C++ про explicit и про опасности неявных преобразований) и они эволюционируют от стандарта к стандарту, отражая, что сообщество всё больше склоняется к «явное лучше неявного» как к значению по умолчанию.

Скрытый текст
class Health {
    int hp_;
public:
    explicit Health(int hp) : hp_(hp) {}  
    // explicit: нет неявного int -> Health
};

void apply(Health h);
// apply(100);            // ошибка: не путаем число и здоровье
apply(Health{100});       // явно и понятно

// А вот тут неявность УМЕСТНА и улучшает читаемость
class Seconds {
    float s_;
public:
    Seconds(float s) : s_(s) {}            
    // намеренно неявный: 2.0f -> Seconds естественно
};

void wait(Seconds);
wait(2.0f);               
// читается как "подожди 2 секунды"

В играх осторожность с неявными преобразованиями исторически "болит", потому что игровой код кишит типами-обёртками над примитивами (хендлы, идентификаторы, типизированные величины, флаги), и неявные преобразования между ними и сырыми int/float часто прямой путь к UB и «передал ID сущности туда, где ждали ID компонента» или «смешал время и количество», поэтому пометка конструкторов-обёрток explicit превращает такие места в ошибки компиляции.

Практическая линия в движках обычно такая, что типизированные хендлы, ID и единицы измерения делают со explicit-конструкторами, чтобы система типов ловила путаницу, а немногие действительно естественные преобразования (например, скаляр в вектор-из-одинаковых-компонент, или литерал в безопасную обёртку) оставляют неявными сознательно.

Это часть более широкой темы «сделать неверный код некомпилируемым» и чем больше бессмысленных смешений типов компилятор отвергает, тем меньше багов доживает до рантайма. Поэтому управление неявными преобразованиями через explicit давно повседневный инструмент проектирования безопасных API движка, а не абстракция, и привычка ставить explicit по умолчанию экономит немало времени на отлове перепутанных аргументов.

Recursive Type Composition (сложно)

Это система построения типов, которые рекурсивно содержат сами себя (точнее, инстанциации того же шаблона) на этапе компиляции, образуя древовидные или списочные структуры типов. Классический пример будет список типов (typelist), где «список» представлен как пара «голова + хвост», а хвост будет снова список, и так до пустого хвоста-терминатора. Или выражение, где узел содержит поддеревья того же типа узлов и структура данных существует не в рантайме, а в системе типов, разворачиваясь рекурсивно во время компиляции.

Зачем строить структуры из типов? Потому что метапрограммирование оперирует типами, и чтобы обрабатывать наборы или деревья типов (генерировать по ним код, иерархии, хранилища), нужно их как-то структурировать. Рекурсивная композиция даёт способ представить произвольной длины список или дерево типов и рекурсивно его обходить метафункциями: обработать голову, рекурсивно обработать хвост, остановиться на терминаторе. Это, по сути, функциональное программирование на типах, со списками и рекурсией вместо циклов.

Проблема обычно в глубине рекурсии и времени компиляция. Каждый уровень рекурсии будет новая инстанциация шаблона, и для длинных списков/глубоких деревьев компилятор материализует множество промежуточных типов, что серьезно замедляет сборку и может упереться в лимит глубины инстанциации.

С приходом variadic templates (C++11) ручная рекурсивная композиция «голова+хвост» во многом устарела и пакеты параметров (Ts...) теперь представляют наборы типов напрямую, а fold-выражения (C++17) обрабатывают их без явной рекурсии.

Все это наследие Александреску и его Typelist, построенным ровно как рекурсивная композиция, и Boost.MPL, где она была фундаментом метапрограммирования нулевых, когда вариативных шаблонов ещё не существовало и наборы типов приходилось кодировать вложенными парами. Современный эквивалент этоstd::tuple и вариативные пакеты, которые делают то же самое, но заметно легче.

Скрытый текст
// Рекурсивная композиция
// typelist как голова + хвост (до C++11)
struct Nil {};
template <class Head, class Tail> struct TypeList {};

using Components = 
  TypeList<Transform, TypeList<Physics, TypeList<Render, Nil>>>;

// Метафункция длины и рекурсивный обход:
template <class L> struct Length;
template <> struct Length<Nil> { static constexpr int value = 0; };
template <class H, class T> struct Length<TypeList<H, T>> {
    static constexpr int value = 1 + Length<T>::value;
};

// C++11+, то же самое вариативным пакетом, без ручной рекурсии
template <class... Ts> struct Components2 { 
  static constexpr int count = sizeof...(Ts); 
};

В играх рекурсивную композицию типов в стиле Алекстандреску сегодня редко пишут руками, но её современные формы в виде вариативных шаблонов и кортежей, пронизывают обобщённую инфраструктуру в ECS-движках, которые описывают наборы компонентов системы вариативными пакетами (System<Transform, Velocity>) и разворачивают по ним доступ к хранилищам.

Так что recursive type composition надо понимать как идею, которая никуда не делась, лишь сменив форму. Принцип «представить набор/дерево типов как данные для компайл-тайм-обработки» жив и централен для обобщённого кода движков, просто инструментарий стал гуманнее, а вариативные шаблоны и fold-выражения вместо рекурсивных пар стали заменой спискам типов.

Temporary Base Class

Старая и почти полностью устаревшая идиома оптимизации, связанная с expression templates и борьбой с временными объектами в выражениях. Её цель была в том, чтобы промежуточные результаты сложных выражений (над матрицами, векторами) представлять специальными временными типами-обёртками, наследующими общий базовый класс, что позволяло алгоритмам и операторам единообразно работать с этими временными и избегать ненужных копий и аллокаций при вычислении составных выражений.

Идея перекликается с expression templates, но вместо немедленного создания тяжёлого временного объекта для каждой подоперации, выражение представляется лёгкими временными узлами, которые откладывают вычисление. «Временный базовый класс» давал этим узлам общий интерфейс, через который конечная операция (присваивание) могла их вычислить за один проход. Это была одна из ранних попыток решить проблему временных в численном C++ до того, как expression templates и move-семантика оформились окончательно.

Механизм реализации сложен и хрупок и сегодня почти полностью вытеснен Move-семантикой, которая убрала бóльшую часть боли от временных объектов (временный теперь дёшево перемещается, а не копируется), а copy elision устранила многие временные объекты вовсе. Поэтому temporary base class представляет в основном как исторический интерес и ступень эволюции на пути к современным решениям.

Идея родилась в девяностых, она из того же круга идей, что Blitz++ и ранние работы по высокопроизводительной линейной алгебре, где каждый временный объект старались убрать, а инструментов языка для борьбы с ними было ещё мало.

Скрытый текст
// Концептуально: временные узлы выражения с общим базовым интерфейсом,
// чтобы присваивание вычислило всё за один проход 
// без тяжёлых промежуточных матриц
struct MatExprBase { /* общий интерфейс вычисления элемента */ };

template <class L, class R>
struct MatSumExpr : MatExprBase {     
    // лёгкий временный узел вместо полной матрицы
    const L& l; const R& r;
    float at(int i, int j) const { return l.at(i,j) + r.at(i,j); }
};

// Сегодня это делают expression templates + move-семантика,
// а не отдельный temporary base

Boost mutant

Откровенно «хакерская» идиома, позволяющая получить доступ к одному и тому же набору данных через разные структуры, интерпретируя одну и ту же память то как один тип, то как другой. Название отсылает к её происхождению из недр Boostа. Классический пример будет пара (first, second), к которой хочется обращаться и как к (first, second), и как к (second, first) (перевёрнутой), не дублируя данные, а накладывая на одну и ту же память две разные структуры-«вида».

Реализуется это, как правило, через union структур с идентичной раскладкой или через reinterpret_cast между типами, про которые программист уверен, что они имеют одинаковое расположение полей в памяти. Т.е. иметь несколько «фасадов» над одними байтами, выбирая удобный для конкретной операции, без физического дублирования или копирования данных. Это позволяет, например, переиспользовать алгоритмы, написанные под одну раскладку, для данных в другой, «переименовав» поля через мутант.

Проблема в самой идее и балансировании на грани (а нередко и за гранью) неопределённого поведения, когда доступ к объекту через указатель/ссылку несовместимого типа нарушает алиасинг (strict aliasing), а интерпретация одной структуры как другой через union имеет тонкие правила «активного члена», и всё это компилятор вправе оптимизировать способами, ломающими ваши ожидания. Идиома работает «на этом компиляторе при этих флагах», но переносимость и корректность под вопросом. Это типичный «умный хак», от которого современный код стараются держать подальше.

Как я уже сказал, идея пришла из Boost (отсюда имя) скорее как любопытный экспонат, чем как рекомендация и отражает дух раннего Boost, где ради эффективности и выразительности шли на приёмы, которые сегодня сочли бы слишком рискованными, и где границы неопределённого поведения исследовались довольно смело.

Скрытый текст
// Концептуально: два "вида" на одни и те же байты (рискованно, на грани UB)
struct Pair      { int first;  int second; };

struct ReversedPair { int second; int first; };  
// та же раскладка, поля наоборот

union Mutant {
    Pair pair;
    ReversedPair reversed;
};

Mutant m;
m.pair = {1, 2};
// доступ через m.reversed трактует те же байты как (second=1, first=2)
// переносимость и корректность под большим вопросом

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

Так что boost mutant стоит знать как пример того, как не надо, и как повод вспомнить про std::bit_cast более легальную и переносимую замену для случаев, когда действительно нужно посмотреть на одни и те же байты под разными типами. Подобные хаки с union и reinterpret_cast всеже наследие эпохи, когда безопасных инструментов для этого не было.

Multi-statement Macro

Это идея оформления макросов, которые разворачиваются в несколько инструкций. Проблема в том, что препроцессор C/C++ тупо подставляет текст, не понимая синтаксиса, и макрос из нескольких операторов ломается в самых обычных контекстах. Если макрос это do_a(); do_b();, то его использование в if (cond) MACRO; без фигурных скобок развернётся так, что только первый оператор попадёт под if, а второй выполнится всегда, классический баг.

Каноническое лечение будет обернуть тело макроса в do { ... } while(0). Эта конструкция группирует несколько операторов в один синтаксический блок, который при этом ведёт себя как одна инструкция и корректно требует точку с запятой после себя (MACRO;), естественно встраиваясь в if/else и циклы. while(0) гарантирует ровно одну итерацию, а компилятор тривиально его выкидывает, так что накладных расходов нет. Это идиоматический, узнаваемый способ сказать что «этот макрос есть единый составной оператор».

Расплата? Макросы вообще зло, и эта идиома лишь делает одно из их проявлений менее опасным, не устраняя коренных проблем препроцессора в виде отсутствия области видимости, многократного вычисления аргументов или невидимости для отладчика. Так чтоdo/while(0) решает скорее узкую проблему «несколько операторов как один», но не делает макрос хорошей идеей сам по себе и современный C++ (inline-функции, шаблоны, constexpr, лямбды) позволяет заменить большинство макросов нормальным кодом, что почти всегда правильнее.

Идея восходит к ранним дням языка и кодовой базе ядра Unix/Linux, где do { } while(0) в макросах встречается повсеместно, но в C++ она перешла по наследству и это, пожалуй, самый узнаваемое сишное «рукопожатие», если увидел do/while(0) вокруг тела макроса, то понимаешь что автор пришел из сишного мира.

Скрытый текст
// Плохо: ломается в if без скобок
#define LOG_AND_COUNT(msg) log(msg); ++log_count_

// if (verbose) LOG_AND_COUNT("hi");  
// ++log_count_ выполнится ВСЕГДА — баг!

// Хорошо: do/while(0) делает макрос единым оператором
#define LOG_AND_COUNT(msg) do { log(msg); ++log_count_; } while(0)

if (verbose) LOG_AND_COUNT("hi");      // теперь корректно: оба под if
else do_nothing();                     // и else работает

В геймдеве многострочные макросы всё ещё встречаются часто, несмотря на общее «макросы это зло», потому что у них есть ниши, где альтернатив мало, вроде отладочных и логирующих макросов (которым нужен FILE/__LINE__ и условной компиляции), макросы регистрации (компонентов, тестов, рефлексии), платформенные обёртки, ассерты. Во всех этих случаях, если макрос содержит несколько операторов, do/while(0) обязателен, иначе он подложит мину в чей-то if. Это одна из тех мелочей, незнание которой приводит к багам, которые невозможно объяснить, глядя на место использования (там всё выглядит правильно, а баг прячется в раскрытии макроса), а узнаваемый do/while(0) признак того, что автор макроса прошёл школу ночной отладки.

Named Loop

Идея получить помеченные циклы и управление вложенными циклами, которого в C++ нет в том виде, как, например, в Java (где можно написать break outer; и выйти сразу из внешнего цикла). В C++ break и continue действуют только на ближайший окружающий цикл, и когда вложенных циклов несколько, выйти разом из всех или перейти к следующей итерации внешнего — нетривиально. Идиома предлагает способы это выразить.

Исторически и практически тут было несколько подходов и самый прямой и "честный" было использовать goto на метку после внешнего цикла: да, тот самый goto, который обычно считают дурным тоном, но для выхода из глубоко вложенных циклов это, как ни странно, один из самых чистых вариантов, и даже Дейкстра не возражал против такого ограниченного применения. Альтернативами стали флаг-булевы переменные, проверяемая в условиях всех циклов (многословно и засоряет логику), или вынос вложенных циклов в отдельную функцию, из которой return выходит сразу из всего (часто самый чистый вариант, заодно улучшающий структуру кода).

Каждый подход имеет свою цену иgoto пугает неподготовленных читателей и в больших количествах действительно вреден, хотя для выхода-вперёд из циклов безопасен. Флаги размазывают логику выхода по нескольким местам и легко содержат ошибки, а вынос в функцию иногда искусственно дробит связный код. C++ так и не обзавёлся помеченными break/continue (предложения были, но не прошли), так что идиома остаётся набором компромиссов, и выбор между ними только вопрос вкуса и контекста.

Дискуссия вокруг практики применения стала частью вечного спора об уместности goto, восходящего к знаменитому письму Дейкстры «Go To Statement Considered Harmful» ажно 1968 года, и о том, что «вредный в общем случае» не означает «вредный всегда».

Скрытый текст
// Поиск в 2D-сетке с выходом сразу из обоих циклов
bool found = false;
for (int y = 0; y < height && !found; ++y)       // вариант с флагом
    for (int x = 0; x < width; ++x)
        if (grid[y][x] == target) { 
          found = true; 
          break; 
        }

// Вариант с goto — для выхода-вперёд это чисто и читаемо:
for (int y = 0; y < height; ++y)
    for (int x = 0; x < width; ++x)
        if (grid[y][x] == target) 
          goto done;
done:;

// Часто лучший вариант просто вынести в функцию и return
std::optional<Cell> find_in_grid(...) { 
  ... return Cell{x,y}; 
  ... return std::nullopt; 
}

В играх вложенные циклы часто повседневность и тот же обход 2D/3D-сеток (тайлмапы, воксели, клетки навигации), перебор пар объектов для коллизий, поиск в матрицах будут всегд, поэтому необходимость «выйти сразу из всего при первом совпадении» возникает регулярно.

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

Но и goto-к-метке вполне жив в коде движков, где вынос в функцию нежелателен (например, чтобы не мешать инлайнингу или не таскать много контекста через параметры), и где goto done; для выхода-вперёд из горячего вложенного цикла будет самым прямым и быстрым способом без накладных расходов. Так что named loop это в основном про осознанный выбор между «вынести в функцию» (чисто, по умолчанию) и «честный goto вперёд» (когда важна каждая мелочь в хотпасе), и про понимание, что табу на goto есть общее правило с законными исключениями, а не религиозная догма.

Named Parameter

Механизм, дающий C++ что-то похожее на именованные аргументы функций, которых в языке нет (в отличие от, скажем, Python с его func(width=100, height=50)). Когда у функции или конструктора много параметров, особенно однотипных и с значениями по умолчанию, позиционный вызов превращается в нечитаемую вереницу вроде create(800, 600, true, false, true, 4, false), где невозможно понять, что есть что, и легко перепутать порядок.

Самая распространённая реализация это method chaining через объект-builder, где вы создаёте промежуточный объект параметров, у которого методы-сеттеры возвращают ссылку на себя, что позволяет сцеплять их в цепочку .width(800).height(600).fullscreen(true), а затем передаёте этот объект в саму функцию. Каждый «параметр» называется явно своим методом, порядок становится неважен, а указываются только нужные (остальные берут значения по умолчанию). Это та же идея, что named template parameters, но для рантайм-аргументов функций.

Платить надо многословностью реализации, когда нужен целый класс-builder с методами на каждый параметр и небольшими накладными расходами на создание промежуточного объекта (обычно устраняемые оптимизатором). Плюс это не настоящие именованные аргументы и компилятор не заставит указать обязательные, так что в C++20 дали частичную альтернативу для агрегатов в виде designated initializers (Config{.width=800, .height=600}), которые позволяют инициализировать поля структуры по имени, что для конфигов-структур закрывает большую часть потребностей.

Параметр-builder этр давняя практика, и на уровне библиотек её довёл до совершенства Boost.Parameter, где это один из самых частых ответов на вопрос «почему в C++ нельзя вызвать функцию с именованными аргументами». Нельзя напрямую, но можно сэмулировать.

Скрытый текст
// Builder с method chaining 
// имена параметров возвращаются в вызов
struct WindowDesc {
    int width_ = 1280, height_ = 720;
    bool fullscreen_ = false, vsync_ = true;
    WindowDesc& width(int w)      { width_ = w; return *this; }
    WindowDesc& height(int h)     { height_ = h; return *this; }
    WindowDesc& fullscreen(bool f){ fullscreen_ = f; return *this; }
    WindowDesc& vsync(bool v)     { vsync_ = v; return *this; }
};

Window create_window(const WindowDesc&);

// Вместо create_window(800, 600, true, false)
// читаемо и без путаницы порядка
auto win = create_window(WindowDesc{}.width(800).height(600).fullscreen(true));

// C++20 для агрегатов проще
auto win = create_window({.width_=800, .height_=600, .fullscreen_=true});

В играх named parameter в виде builder'ов и конфиг-структур почти классический приём, потому что движки полны API с множеством параметров, от создание окна до настройки пайплайна рендеринга и дескрипторы текстур. У всех них десятки настроек, большинство с разумными значениями по умолчанию, и позиционный вызов был бы нечитаемым кошмаром, где true, false, true ничего не говорит читателю.

А графические API нового поколения (Vulkan, D3D12, Metal) целиком построены на дескрипторах-структурах, заполняемых по полям, это и есть named parameter в форме конфиг-структуры, и движки поверх них продолжают ту же традицию. Современный геймдев всё чаще использует C++20 designated initializers для таких дескрипторов, потому что они дают именованную инициализацию без написания builder-класса, что и короче, и эффективнее.

Так что named parameter в играх - это в основном про конфиг-структуры и дескрипторы, и про выбор между классическим builder'ом (работает везде, можно валидировать) и designated initializers (проще, C++20). В обоих случаях цель одна, чтобы вызов с десятком параметров можно было прочитать и не перепутать.

Named External Argument

Близкая к предыдущей, но более узкая идея сделать «именованными» отдельные аргументы за счёт типов-обёрток, несущих смысл аргумента в своём имени. Вместо того чтобы передавать голый bool или int, чьё назначение в точке вызова неясно, вы заворачиваете его в маленький именованный тип, и тогда вызов сам себя документирует: не set_visible(true) с неочевидным true, а нечто, где намерение явно выражено типом аргумента.

Теперь такой «ярлык» придает аргументу смысл, и это может быть тег-обёртка (Visible{true} вместо true), сильный тип-алиас (отдельный тип Width поверх int), или именованная константа-флаг. Особенно ценно это для булевых аргументов, печально известных своей нечитаемостью: цепочка true, false, true в вызове будет загадкой, а Visible::Yes, Cached::No, Async::Yes читается без обращения к документации и заодно сильные типы предотвращают перестановку аргументов одинакового базового типа.

Платить нужно типами-обёртками, и если их много, появляется некоторый шум из мелких типов. Плюс это не полноценные именованные параметры (порядок всё ещё важен), а скорее «самодокументирующиеся аргументы», но в сочетании с explicit и сильной типизацией все это даёт ощутимый прирост и читаемости, и безопасности, пресекая класс ошибок «перепутал, какой из трёх bool за что отвечает».

Скрытый текст
// Голые bool — нечитаемо и легко перепутать:
// player.respawn(true, false, true);   // что есть что???

// Named external arguments — тип несёт смысл аргумента:
enum class Invulnerable : bool { No, Yes };
enum class KeepInventory : bool { No, Yes };
enum class AtCheckpoint : bool { No, Yes };

void respawn(Invulnerable, KeepInventory, AtCheckpoint);

// Вызов документирует сам себя и защищён от перестановки:
player.respawn(Invulnerable::Yes, KeepInventory::No, AtCheckpoint::Yes);

В геймдеве эта часто является основой движка, потому что игровые API изобилуют булевыми флагами и однотипными числовыми параметрами, и нечитаемые вызовы вроде spawn(pos, true, false, true, 3) явлеяется частым источником багов при перестановке аргументов. Поэтому заворачивание флагов в именованные enum class (которые, как мы видели в type safe enum, ещё и типобезопасны) превращает такие вызовы в самодокументирующиеся и устойчивые к ошибкам.

Сильные типы для величин (отдельные типы Health, Damage, EntityId, Seconds вместо голых int/float) та же идея, защищающая от смешения семантически разных, но одинаковых по представлению значений, и экономит немало времени на отладке «перепутал ID с количеством» и «передал миллисекунды туда, где ждали секунды». Так что named external argument хорошая практика «не передавай голые примитивы там, где тип может нести смысл», и она прекрасно сочетается с enum class, explicit и сильными типами в общую культуру «сделать неверный вызов некомпилируемым или хотя бы очевидно неправильным при чтении». Это дёшево, ничего не стоит в рантайме и заметно повышает и читаемость, и надёжность кода.

Deprecate and Delete

Идея в том, что нельзя просто взять и убрать функцию, которой пользуются, потому что это сломает чужой код, а правильный путь будет сначала пометить её как устаревшую (deprecated), чтобы компилятор выдавал предупреждение при использовании, давая пользователям время мигрировать, а потом, в следующей крупной версии, удалить или явно запретить.

Оба механизма работают на разных стадиях. [[deprecated("используйте X")]] (атрибут C++14, и ранее компиляторные declspec(deprecated)/attribute__((deprecated))) помечает функцию как нежелательную, но код с ней всё ещё компилируется, а компилятор предупреждает, часто с вашим сообщением, куда мигрировать. А= delete (C++11) идёт дальше и явно запрещает функцию, а попытка её вызвать даёт ошибку компиляции. delete применяют и для окончательного «выпиливания» устаревшего, и (это его основное применение) для запрета нежелательных операций, вроде копирования, опасных перегрузок, неявных преобразований.

Расплата это в основном дисциплина процесса, а не техническое решение и нужно выдержать цикл «пометил → подождал → удалил», не торопясь с удалением и не забывая про deprecated-предупреждения навечно. = delete мощнее, чем кажется и им можно запретить конкретную перегрузку (например, = delete для f(double), чтобы запретить вызов целочисленной функции с дробным аргументом и не дать молча сконвертировать), что тонко управляет разрешением перегрузок.

Оба механизма пришли в C++11 (атрибут [[deprecated]] — в C++14) из давних компиляторных расширений. А= delete былспроектирован в том числе для замены идиомы «приватный необъявленный метод» (как в boost::noncopyable) и раньше копирование запрещали приватным необъявленным конструктором, а теперь ясным = delete с понятной ошибкой. Это часть общего движения языка к выражению намерений явными средствами языка вместо идиоматических обходов.

Скрытый текст
class Texture {
public:
    Texture(const Texture&) = delete;             
    // запрет копирования — ясной ошибкой
    Texture& operator=(const Texture&) = delete;

    [[deprecated("используйте load_async вместо load")]]
    void load(const char* path);                  
    // ещё работает, но предупреждает
    void load_async(const char* path);
};

void bad(double);
void bad(int) = delete;     // запретить вызов с int: bad(5) и ошибка компиляци

Управляемое устаревание критично для движков, у которых есть пользователи, другие команды, моддеры, лицензиаты, и ломать чужой код при каждом обновлении API недопустимо. [[deprecated]] даёт цивилизованный путь миграции, когда старая функция помечается, документируется замена, пользователи получают предупреждения и время на переход, и только потом, в мажорной версии, функция удаляется. Unreal и другие большие движки систематически используют deprecation-макросы именно для этого.

= delete же в играх давно повседневный инструмент проектирования безопасных типов и запрет копирования ресурсов и не-копируемых объектов (ясной ошибкой вместо линковочной), запрет опасных неявных преобразований и нежелательных перегрузок, явное «эта операция бессмысленна для данного типа».

Это всё та же сквозная тема «сделать неверный код некомпилируемым» и = delete превращает целый класс потенциальных ошибок использования в ошибки компиляции с понятным сообщением. Так что deprecate-and-delete в играз это и про вежливую эволюцию публичного API движка (deprecated), и про жёсткое пресечение неверного использования типов (delete), и оба механизма давно стали стандартной часть инструментария проектировщика движка.

Function Poisoning

Это идиома намеренного «отравления» определённых функций, чтобы их использование стало ошибкой компиляции или хотя бы предупреждением, но в отличие от deprecate-and-delete, нацеленного на эволюцию вашего собственного API, poisoning обычно направлен на запрет опасных, небезопасных или запрещённых в данном проекте функций. Чаще всего чужих, стандартных или системных, которые вы по какой-то причине не хотите видеть в своей кодовой базе.

Способов несколько и самый прямой на уровне инструментов будет#pragma GCC poison имя, который заставляет компилятор отвергать любое появление указанного идентификатора (буквально «отравляет» имя). Второй это на уровне кода объявить запрещённую функцию с = delete или с [[deprecated]], либо перекрыть её собственной версией, которая не компилируется или выдаёт static_assert. Цель та же, пресечь использование функций вроде небезопасных strcpy/sprintf/gets, неугодных malloc/free (если в проекте всё должно идти через свой аллокатор), printf (если запрещён прямой вывод), или любых других, которые в данном проекте под запретом.

Расплата за отравление чужих, особенно стандартных будет, что это хрупко и непереносимо и тот же #pragma poison есть не у всех компиляторов и может конфликтовать с самими заголовками, где «отравленное» имя легитимно используется (отравишь malloc и стандартные заголовки, использующие его внутри, перестанут компилироваться). Поэтому poisoning требует аккуратности в том, где именно он включён, и часто ограничивается специальным заголовком, подключаемым только в «своём» коде, но не там, где включаются системные хедеры.

Идиома описана в работах по расширениям GCC/Clang, и появившееся как инструмент для запрета небезопасных функций в кодовых базах с жёсткими требованиями безопасности. Это часть более широкой практики enforced coding standards как принудительного соблюдения стандартов кодирования средствами компилятора, а не только ревью.

Скрытый текст
// forbidden.h подключается в своём коде (но не там, где системные заголовки)
#pragma GCC poison strcpy strcat sprintf gets   // любое использование — ошибка

// В проекте, где вся память идёт через свой аллокатор,
// можно запретить голый new:
void* operator new(std::size_t) = delete;       
// заставляет использовать движковые аллокаторы

// Запрет конкретной небезопасной перегрузки через = delete:
char* strcpy(char*, const char*) = delete;   
// "не используйте, есть safe_copy"

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

Это часть инфраструктуры «принудительной чистоты» кодовой базы, когда вместо того чтобы полагаться на то, что каждый разработчик помнит все запреты и что ревьюер их выловит, движок отравляет запрещённые функции, и компилятор ловит нарушения автоматически, ещё до ревью.

Для больших команд это становится стандартом кодирования, который соблюдается сам собой, надёжнее любого документа, который все «прочитали». Так что function poisoning это про автоматическое принуждение к правилам проекта средствами компилятора, и хотя приём технически хрупковат (особенно отравление стандартных имён), в дисциплинированной кодовой базе с правильно изолированным заголовком запретов он экономит много сил и предотвращает целые классы проблем, которые на консоли могли бы стоить провала сертификации.

Inner Class

Использование вложенных (внутренних) классов нужно для решения задач инкапсуляции, реализации интерфейсов и организации тесно связанных типов. В C++ вложенный класс будет полноценным классом в области видимости внешнего, имеющий (с некоторыми оговорками) доступ к его приватным членам через объект и логически принадлежащий ему.

Применений несколько и первое как раз скрытие деталей реализации, когда внутренний класс, объявленный в приватной секции, не виден снаружи и служит вспомогательной структурой (узел списка, итератор контейнера, состояние конечного автомата), не засоряя внешнее пространство имён. Второе сделан для реализации интерфейса «изнутри», когда внешний класс не наследует интерфейс сам (чтобы не раздувать свой публичный контракт и не плодить конфликты), а заводит внутренний класс, который реализует интерфейс и имеет доступ к внутренностям внешнего, это способ «реализовать несколько интерфейсов» без множественного наследования и его проблем. И третье - это группировка тесно связанных типов под именем-владельцем (Graph::Node, Graph::Edge).

Платить приходится сложнстью объявления (особенно при шаблонах: typename Outer<T>::Inner с его typename-церемониями), и важно помнить, что вложенность - это про область видимости и доступ, а не про время жизни. Объект внутреннего класса не привязан автоматически к объекту внешнего и может жить отдельно, а чрезмерная вложенность ухудшает читаемость. Плюс есть исторические тонкости с тем, какой именно доступ внутренний класс имеет к приватным членам внешнего (правила уточнялись от C++98 к C++11).

Вложенные классы это давняя часть языка, а их использование (особенно для итераторов контейнеров и для реализации интерфейсов изнутри) стандартная практика, видная в любой реализации STL, где iterator реализован через вложенный тип контейнера.

Скрытый текст
template <class T>
class LinkedList {
    struct Node {                
        // вложенный, скрытый: деталь реализации
        T value;
        Node* next;
    };
    Node* head_ = nullptr;
public:
    class iterator {              
        // вложенный, публичный: часть интерфейса контейнера
        Node* cur_;
    public:
        T& operator*() { return cur_->value; }
        iterator& operator++() { cur_ = cur_->next; return *this; }
        bool operator!=(const iterator& o) const { return cur_ != o.cur_; }
    };
    iterator begin() { return {head_}; }
};

В играх вложенные классы также повседневный инструмент организации кода и кастомные контейнеры движка заводят вложенные iterator/const_iterator (как требует идиома generic container) и скрытые Node-структуры, а конечные автоматы прячут состояния как вложенные классы. Системы заводят вложенные типы дескрипторов и хендлов под именем системы (Renderer::CommandBuffer, Physics::Contact) и все это держит тесно связанные типы вместе, в области видимости их владельца, и убирает их из глобального пространства имён, где они только мешали бы.

Rule of Zero/Three/Five

Правило трёх, пяти и нуля - это свод рекомендаций, какие специальные функции класса нужно определять вместе, чтобы класс корректно управлял своими ресурсами. Правило трёх (эпоха C++98) гласит, что если вам понадобилось определить хотя бы одну из тройки «деструктор, конструктор копирования, оператор копирующего присваивания», то почти наверняка нужны все три, потому что их наличие сигнализирует о ручном управлении ресурсом, и компиляторные версии остальных двух сделают неправильно (поверхностное копирование указателя ведёт к двойному освобождению).

Правило пяти - это расширение тройки на эпоху C++11 и к деструктору и копирующим операциям добавились move-конструктор и move-оператор присваивания, теперь «всё или ничего» распространяется на всю пятёрку. Если класс управляет ресурсом вручную и вы определяете деструктор с копированием, стоит определить и перемещение, иначе оно либо не сгенерируется (и перемещение тихо станет копированием, теряя производительность), либо сгенерируется некорректно.

А правило нуля переворачивает всё предыдущее и лучший способ соблюсти правила трёх и пяти будет не писать ни одной из этих функций вообще. Тогда каждый член класса сам корректно управляет своим временем жизни (это std::vector, std::string, std::unique_ptr, а не сырые указатели и дескрипторы), и компиляторные версии всех пяти функций автоматически окажутся правильными.

Правило трёх достаточно давнее правило, которое Марк Клайн и другие фиксировали ещё в девяностых, а правило пяти оформилось с move-семантикой C++11, а правило нуля сформулировал и популяризировал Р. Мартиньо Фернандес в 2012 году, и его подхватили Core Guidelines как предпочтительный подход.

Скрытый текст
// Правило пяти: класс с сырым ресурсом обязан определить все пять
class Buffer {
    float* data_; std::size_t size_;
public:
    ~Buffer() { delete[] data_; }                         // 1
    Buffer(const Buffer&);                                // 2
    Buffer& operator=(const Buffer&);                     // 3
    Buffer(Buffer&&) noexcept;                            // 4
    Buffer& operator=(Buffer&&) noexcept;                 // 5
};

// Правило нуля: ничего не пишем и члены сами управляют собой, и это правильно
class Mesh {
    std::vector<float> vertices_;  
    // сам копируется, перемещается, чистится
    std::string name_;
    // ни деструктора, ни copy/move — компилятор сделает всё корректно
};

Most Vexing Parse

Это не приём, а ловушка синтаксиса C++, знать о которой обязательно, чтобы не потерять час на загадочной ошибке. Суть в правиле грамматики, если что-то можно разобрать и как объявление функции, и как определение объекта с инициализацией, стандарт предписывает разобрать это как объявление функции. То есть когда вы пишете то, что вам кажется созданием объекта, компилятор иногда видит объявление функции и происходит это в самых невинных на вид местах.

Каноничный пример: вы хотите создать объект Widget w(Gadget());, передав в конструктор временный Gadget. Но компилятор разбирает эту строку как объявление функции w, которая возвращает Widget и принимает... указатель на функцию без аргументов, возвращающую Gadget. Никакого объекта не создаётся и объявлена функция, а дальнейшее использование w как объекта даёт каскад непостижимых ошибок, в которых ни слова про то, что w на самом деле функция. Ещё коварнее пустые скобки: Widget w(); это уже не «создать Widget конструктором по умолчанию», а объявление функции w, возвращающей Widget.

Лечится это устранением неоднозначности в пользу «это точно объект» и добавить лишние скобки вокруг аргумента (Widget w((Gadget()));), которые делают разбор как функции невозможным. Современный и куда более чистый способ, и надо использовать фигурные скобки списковой инициализации (Widget w{Gadget{}}; или Widget w{}; для дефолтного), которые в принципе не могут быть разобраны как объявление функции и потому начисто убивают most vexing parse.

Имя «most vexing parse» придумал Скотт Майерс в Effective STL и оно мгновенно прижилось, потому что точно передаёт ощущение, что это самый раздражающий из всех способов, которыми грамматика C++ может вас обмануть.

Скрытый текст
struct Gadget {};
struct Widget { Widget(Gadget); Widget(); void use(); };

// Ловушка: это ОБЪЯВЛЕНИЯ ФУНКЦИЙ, а не создание объектов!
Widget a();              // функция a(), возвращающая Widget не объект!
Widget b(Gadget());      // функция b(указатель на функцию) не объект!
// a.use();              // ошибка: a это функция, у неё нет .use()

// Решения:
Widget c;                // объект (без скобок) — конструктор по умолчанию
Widget d{};              // объект (фигурные скобки) — однозначно
Widget e{Gadget{}};      // объект + фигурные скобки убивают неоднозначность

Pass-key

Это элегантный способ дать доступ к конкретному методу класса строго определённым другим классам, не делая их полноценными друзьями и не открывая им всё приватное. Это решает ту же проблему «дружба в C++ слишком груба», что и Attorney-Client, но другим, во многих случаях более чистым приёмом. Метод остаётся публичным, но требует в аргументах «ключ» и объект крошечного типа, сконструировать который может только разрешённый класс.

Заводится пустой класс-ключ с приватным конструктором, и он объявляет своими друзьями ровно те классы, которым позволено им пользоваться. Целевой публичный метод принимает этот ключ параметром, а поскольку создать ключ может только друг ключа, то и вызвать метод способен лишь тот, кто умеет сконструировать ключ, то есть только разрешённые классы.

Все остальные видят публичный метод, но не могут создать ключ для его вызова и получают ошибку компиляции. Дружба тем самым становится тесной и "золотой ключик" открывает доступ к одному конкретному методу конкретным классам, а не ко всему классу сразу.

Преимущество перед обычным friend и перед Attorney-Client в точечности и отсутствие транзитивности, если обычный друг видит вообще всё приватное, то pass-key открывает ровно один метод и не нужен отдельный класс-посредник с переадресацией, достаточно крошечного типа-ключа.

Идиома получила имя и популярность в C++-сообществе начала 2010-х (её описывали в обсуждениях на Stack Overflow и в блогах под именами «passkey» и «pass-key»), как более лёгкая альтернатива attorney-client для частого случая «дать доступ к конструктору или одному методу только фабрике или менеджеру».

Скрытый текст
class Entity;

class EntityKey {                 
    // ключ: создать может только EntityManager
    EntityKey() = default;
    friend class EntityManager;
};

class Entity {
public:
    // метод публичный, но требует ключ 
    // значит звать может только друг ключа
    void set_id(int id, EntityKey) { id_ = id; }
private:
    int id_ = 0;
};

class EntityManager {
public:
    void assign(Entity& e, int id) { e.set_id(id, EntityKey{}); }  
    // умеет создать ключ
};
// любой другой код: e.set_id(5, EntityKey{}); 
// ошибка, конструктор ключа недоступен

В играх pass-key хорошо ложится на распространённый паттерн «только менеджер/фабрика имеет право на эту операцию». Только EntityManager должен присваивать ID сущностям; только ResourceManager может менять внутреннее состояние ресурса, или только система загрузки может вызывать «сырой» инициализатор объекта. Pass-key выражает это на уровне типов, не делая менеджер всеобъемлющим другом каждого класса и не распахивая всю инкапсуляцию ради одной доверенной операции.

Это обходит классическую проблему «приватный конструктор не дружит с make_unique». Так что pass-key будет практичным инструментом для менеджеров и фабрик, где надо аккуратно ограничить, кто имеет право на чувствительные операции, и он часто оказывается чище и легче, чем и голый friend, и полноценный Attorney-Client.

Defaulted Comparisons и оператор <=> (Spaceship)

Defaulted comparisons - это возможность C++20 попросить компилятор сгенерировать операторы сравнения за вас, и идиома «не пиши сравнения руками, если они тривиальны» была исторической болью и чтобы сделать тип сравнимым, нужно было определить до шести операторов (==, !=, <, >, <=, >=), причём руками, согласованно и без ошибок, и так для каждого сравнимого типа.

Это были горы шаблонного бойлерплейта, в котором легко ошибиться (написать < несогласованно с ==) и каждый раздувал класс своей реализацией. Оператор <=> (формально «трёхстороннее сравнение», в народе «spaceship» за внешний вид) и defaulted == решают это тем, что вы пишете auto operator<=>(const T&) const = default; и компилятор генерирует все операции порядка (<, >, <=, >=), сравнивая члены лексикографически в порядке объявления.

Отдельно bool operator==(const T&) const = default; даёт == и !=, так что одна-две строки вместо шести рукописных операторов, и они гарантированно согласованы между собой. <=> к тому же возвращает не bool, а специальный тип «категории сравнения» (strong_ordering, weak_ordering, partial_ordering), точно выражающий, какого рода это упорядочивание. Оператор <=> спроектировал Херб Саттер (вместе с Джен Маурер) и ввёл в C++20 именно чтобы покончить с бойлерплейтом сравнений, одной из самых занудных рутин в C++.

Скрытый текст
struct Version {
    int major, minor, patch;
    auto operator<=>(const Version&) const = default;   
    // даёт <, >, <=, >= разом
    bool operator==(const Version&) const = default;     
    // даёт == и !=
};

Version a{1, 2, 0}, b{1, 3, 0};
bool older = a < b;        
// работает: лексикографически по major, minor, patch
bool same  = a == b;
// тоже работает

// std::sort, std::set, std::map<Version,...> 
// теперь работают без рукописных операторов

Что со всем этим делать

Похоже у менять есть материал для продолжения Game++ Ж) А если серьезно, то проступают несколько сквозных линий, которые важнее любой отдельной идиомы, идеи или механизма.

Добрая половина этих идиом - это костыли. Костыли, которые программисты вытачивали вручную, потому что языку не хватало возможности, а работу надо было делать сегодня. Safe bool ждал explicit operator bool. Resource return и computational constructor ждали move-семантику. Int-to-type и enable-if ждали if constexpr и концепты. Type generator ждал alias-шаблоны. Nullptr-обёртки ждали nullptr. И когда язык наконец догонял, идиома схлопывалась в одну строчку или растворялась в синтаксисе, оставляя после себя только археологический слой в старом коде. Читать эти идиомы стоит в том же двойном смысле, что и архитектуры памяти консолей из одной из моих статей, как исторический артефакт, объясняющий, почему древний код выглядит именно так, и как живой приём там, где старый стандарт всё ещё в строю.

Вторая линия будет про вечный размен между гибкостью и скоростью, и игрострой в нём занимает очень определённую сторону. Виртуальные функции, type erasure, polymorphic value types, acyclic visitor: всё это покупает рантайм-гибкость ценой индирекции, аллокаций и кеш-промахов, и всё это движки сознательно выпихивают из себя в инструменты и в редакторы, не пуская в ядро движка. А там правят CRTP, traits, tag dispatching, policy-based design, concrete data types и небольшая армия приемов, которые переносят решения из рантайма в компайл-тайм, чтобы процессор не делал ничего лишнего.

И последнее правило «сделай неверный код некомпилируемым». Non-copyable, = delete, explicit, type safe enum, named external argument, сильные типы, function poisoning, checked delete: все они про то, чтобы ошибку поймал компилятор, а не рантайм, QA или сертификация. В большой команде, где код пишут десятки людей разного опыта, это, возможно, самая ценная категория идиом и каждая запрещённая на уровне типов глупость будет неродившимся в проде багом.

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

З.Ы. @BoomburumТы мне выдашь ачивку за самую длинную статью на хабре за 20 лет? А то я тут чуть-чуть не дожал.

З.З.Ы. Кажется я сломал редактор Хабра, и теперь он не хочет сохранять такой длинный текст.

З.З.З.Ы. А нет, спустя десять минут сохранил... Фух...

З.З.З.З.Ы. Хабрторт! Спасибо всем, кто присылает исправления.

tg

Если понравилось - заходите в https://t.me/game_cpp_book, пишу редко

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


  1. rsashka
    08.06.2026 20:05

    Вот это прикол. Дочитал до Pimpl (Handle Body, Compilation Firewall, Cheshire Cat) и немного устал. Посмотрел на бегунок и не поверил! Прочитал значительно меньше половины. Отложил в закладки, дочитаю потом. Статья интересная, но уже поздно.


    1. dalerank Автор
      08.06.2026 20:05

      Это вы устали читать :) шутка. А Мейерс с Александреску половину этого придумали


    1. artden111
      08.06.2026 20:05

      Для тех, кто не первый год в с++, это просто обыденность. Правда, повторить азы тоже полезно)


    1. vvzvlad
      08.06.2026 20:05

      А ведь нас предупреждали что это половина книги..


  1. tenzink
    08.06.2026 20:05

    Фундаментальный труд. Respect!


  1. Boomburum
    08.06.2026 20:05

    Обсужу с коллегами ачивку ))


  1. NeoCode2
    08.06.2026 20:05

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

    Спасибо вам автор, читать тут конечно не на один день, но дочитаю обязательно, хоть вспомню C++, профессионалом в котором так толком и не стал. Но беглый просмотр уже впечатлил - написано понятно и интересно.


  1. oficsu
    08.06.2026 20:05

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


  1. sergio_nsk
    08.06.2026 20:05

    Странный заголовок с числом ни разу не упомянутым в статье.


    1. kemiisto
      08.06.2026 20:05

      Это идиома такая. Самому лень расписывать. Вот от Gemini, что называется, одной строкой:

      В американской академической системе индекс «101» традиционно означает вводный или базовый курс по какому-либо предмету. В повседневной речи и книгах выражение «что-то 101» стало идиомой, обозначающей самые азы или фундаментальные основы чего-либо (например, English 101 — основы английского языка).


    1. dalerank Автор
      08.06.2026 20:05

      Ну почему же не упомянутым, секций ровно 101-а. Разных базовых идей, понятий и механизмов, с которыми сталкивается с++ разработчик (да и не только с++) больше, но я постарался выбрать те, которые широко известны и применяются. Как сказали выше еще 101 это вводный курс, где в том числе дают и историю возникновения отдельных терминов и понятий.


  1. Katasonov
    08.06.2026 20:05

    Free function allocators мой любимый. Помню давным давно на заре игростроя в РФ, мы его в самописном движке использовали. При старте игра выделяла сразу большой кусок памяти в куче, а потом через new мы все обьекты создавали в этом куске памяти. Даже свое фрагментирование было. Никаких утечек памяти и шустро работало. Автору респект, редко доводится почитать что-то действительно интересное и полезное на Хабре.


  1. kuznet1
    08.06.2026 20:05

    Отличная статья. Но за все надо платить, и в ней очень много букв


    1. dalerank Автор
      08.06.2026 20:05

      Ага, как оказалось платить в С++ приходится за всё, даже за бесплатное и за Zero-cost, но язык даёт контроль над тем, кто платит, когда и чем.


  1. Gay_Lussak
    08.06.2026 20:05

    Статья огонь. Один момент не понятен.

    Расплачиваться приходится сложностью реализации и бОльшими размерами класса, который специально раздувают чтобы поместить побольше данных

    Так маленький буфер кладется на место указателя на динамическую память. В этом же и смысл SSO, что пока данные маленькие, можем использовать место выделенное под указатель. Или я что-то не понимаю?


    1. dalerank Автор
      08.06.2026 20:05

      В пределе строку можно ужать до одного указателя и 8 байт, но это слишком мало и размер и capacity тогда приходится держать в куче, рядом с самими данными. Объект (строка) выходит крошечный, массивы таких строк плотно ложатся в кеш, но за каждое обращение к длине или ёмкости платим индирекцией и лишним походом в память.
      Чтобы её убрать, размер и capacity вытаскивают обратно в сам объект, рядом с указателем.
      Дальше работает правило (для строк) N байт SSO-буфер вмещает N−1 символ и отсюда два типовых расклада: либо { char* + uint32 size + uint32 cap } и 16 байт, 15 символов inline.
      Либо { size_t cap; size_t size; char* data; } и 24 байта, и при удачной упаковке 23 символа + терминатор.

      23 символа звучит много, но на практике это покрывает где 40% строк, значит остальные 60% будут болтаться в памяти. А хочешь больше, ну скажем, 60% под ногами и нужен буфер символов эдак на 40, а это объект уже за 40 байт. И вот тут и приходит та самая расплата, чтто 40 байт против 8 или даже 24 уже жирный объект и две такие строки в одну кеш-линию (64 байта) уже не положить, и при проходе будет чтение из другой кешлинии.

      Получается дилемма: либо мелкий объект и плотные массивы, но всего ~8 байт под SSO и индирекция на длинных строках, либо большой SSO-буфер и быстрый доступ к содержимому, но жирные строки, которые уже в кеш-линию не лезут.
      Я на строках объяснил, но для всего остального суть таже. Нужный размер нужно мерять для своего домена применения, про 40 символов строки я могу сказать об играх, потому там есть замеры.



  1. alamat42
    08.06.2026 20:05

    Кстати, в статье не упомянуто, но в С++23 на замену CRTP пришел dedusing this, который позволяет писать миксины более понятно и менее многословно