C++101 — каталог идиом и приёмов C++ в четырёх частях: Ч.1 · Ч.2 · Ч.3 · Ч.4
Оглавление этой части
- Traits
- Tag Dispatching
- Int-To-Type (Integer-to-Type Map)
- Type Selection
- SFINAE (Substitution Failure Is Not An Error)
- enable-if
- Member Detector
- Policy-based Design
- Policy Clone (Metafunction wrapper)
- Type Erasure
- Type Generator (Templated Typedef)
- Thin Template
- Named Template Parameters
- Coercion by Member Template
- Shortening Long Template Names
- Expression-template
- The result_of technique
- Exploding Return Type
- Return Type Resolver
- Named Constructor
- Virtual Constructor
- Computational Constructor
- Construct On First Use
- Nifty Counter
- Runtime Static Initialization Order
- Base-from-Member
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::conditional (и std::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_if (а std::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 , которых почти нет, для логирования в файл/консоль/сеть с собственным буфером. То есть это инструмент для границ с чужим кодом, навязывающим неудобный порядок инициализации.
Паттерн «базе нужен мой член» считается признаком того, что обязанности в иерарахии распределены неудачно и надо её пересмотреть, но знать идиому стоит и когда вы натыкаетесь на необходимость передать базе свой будущий член, надо понимать, что это почти всегда путь в неопределённое поведение, ну или надеяться на фазу луны и что всё обойдётся.
← Все статьи