Ненормальное программирование

C++101 (Ч.3)

21 июня 202636 мин

C++101 — каталог идиом и приёмов C++ в четырёх частях: Ч.1 · Ч.2 · Ч.3 · Ч.4

Оглавление этой части

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, порождённый по пакету типов) и 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. Идиома остаётся скорее изящным инструментом из арсенала автора библиотек, который полезно знать, чтобы распознать в чужом коде и понять, что перед тобой попытка сделать дружбу гранулярной. В повседневном игровом коде её увидишь редко, и это, пожалуй, нормально и если приходится часто дозировать приватный доступ, обычно проще пересмотреть, кто чем владеет.

Часть 4 →

← Все статьи