Шаблоны против статической рефлексии в C++: пять операций, две реализации и дедупликация, которая не стоит памяти

от автора

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

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

Обе реализации живут в одном дереве и проходят один и тот же набор тестов, а отдельный тест сравнивает их операцию за операцией и требует идентичного типа — не «списка того же размера» и не «контейнера того же sizeof»:

static_assert(std::same_as<    tessera::detail::tmpl::splice_unique_into<tessera::mosaic, L1, L2, L3>,    tessera::detail::refl::splice_unique_into<tessera::mosaic, L1, L2, L3>>);

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

Семантика

tessera (проект, namespace) — отдельная плитка мозаики, наименьшая единица материала

tessera::mosaic<Ts...> — целое, собранное из таких плиток: по одному значению на тип

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

Короткий ответ, если дальше читать некогда. Дедупликация через рефлексию не стоит компилятору измеримой памяти вообще: пик трансляционной единицы, которая дедуплицирует, совпадает с пиком той, которая не дедуплицирует, с точностью ±21 МиБ на 32-кратном диапазоне размеров. Шаблонная реализация на том же диапазоне доходит до 7,9 ГиБ. По времени при этом обе квадратичны, и рефлексия выигрывает пятую часть, не больше.

Вот эта асимметрия — минус вся память и минус пятая часть времени — и есть содержание статьи. Рефлексия здесь не даёт лучшего алгоритма: она даёт более дешёвое представление для того же самого алгоритма. Дальше — откуда это берётся и где кончается.

Задача, из которой всё выросло

Есть N подсистем. Каждая подсистема декларирует, какие сервисы ей нужны. При этом каждая подсистема независима и не знает про остальные. Нужен контейнер, в котором каждый сервис лежит ровно один раз, и обращение к нему не зависит от того, кто ещё какие сервисы запросил.

Обычно это делают так:

  1. Контейнер указателей на базовый класс. vector<unique_ptr<IService>> плюс dynamic_cast или карта type_index → указатель. Косвенный вызов на каждое обращение, аллокация на каждый компонент, набор компонентов известен только в рантайме.

  2. Руками написанный std::tuple с константами-индексами. Индексы — второй источник правды, который расходится с объявлением при первой же вставке в середину.

  3. Кодогенератор. Работает, но добавляет в сборку шаг, отдельный язык и отдельную категорию ошибок.

Четвёртый способ — сделать саму сборку вычислением над типами:

struct Renderer {    using dependencies = tessera::type_list<Clock, FrameBuffer, AssetCache>;};struct Physics {    using dependencies = tessera::type_list<Clock, InputState>;};using Systems = tessera::of<Renderer, Physics>;using Engine  = Systems::flat_map<tessera::dependencies_of_t>;// Объединение объявлений, дедуплицированное, посчитанное до запуска программы.static_assert(std::same_as<Engine, tessera::mosaic<Clock, FrameBuffer, AssetCache, InputState>>);Engine engine;Systems systems;engine.get<FrameBuffer>().width = 1920;              // адресация по типуsystems.for_each([&](auto& s) { s.step(engine); });  // прямые вызовы, без индирекции

Clock назвали обе подсистемы — он существует один раз. Добавили подсистему — её сервисы появились. Убрали последнего потребителя сервиса — он исчез из бинарника. Никакого центрального списка. Результат — плоская структура с той же раскладкой, что вы написали бы руками, и [[no_unique_address]] на каждом элементе, так что компоненты без состояния не стоят ни байта.

Но для DI этого мало. Здесь список зависимостей всё ещё пишется целиком: тот, кому нужен Router, обязан знать, что за ним стоит ConnectionPool, а за тем — Config. Настоящий DI-контейнер должен уметь пройти по графу сам:

struct Config         {};struct Log            {};struct ConnectionPool { using dependencies = tessera::type_list<Config, Log>; };struct UserRepository { using dependencies = tessera::type_list<ConnectionPool>; };struct HttpRouter     { using dependencies = tessera::type_list<UserRepository, Log>; };using Services = tessera::resolve<HttpRouter>;   // один корень, остальное приезжает самоstatic_assert(std::same_as<Services,    tessera::mosaic<Config, Log, ConnectionPool, UserRepository, HttpRouter>>);

Каждый сервис объявляет только прямые зависимости. Из этого получаются два свойства, и оба проверяются static_assert:

  • замкнутость — в наборе есть всё достижимое из корней, ровно по одному разу, сколькими бы путями к нему ни приходили (Log назван дважды и существует один раз);

  • топологический порядок — каждый элемент стоит после всего, от чего зависит.

Второе свойство и делает контейнер пригодным для DI. Мозаика обходится for_each в порядке элементов, поэтому если порядок топологический, то проход вперёд — это корректная последовательность запуска, а проход назад — корректная последовательность остановки. Ни ту, ни другую никто не пишет, и в бинарнике их нет: обход графа целиком произошёл при компиляции. Цикл в зависимостях — ошибка компиляции.

Платит за это целиком компилятор, и именно поэтому дальше столько замеров.

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

Всё сводится к пяти операциям

Вернёмся к flat_map из первого примера. Формально это одна операция, но делает она три вещи подряд: берёт у каждой подсистемы её список зависимостей, склеивает эти списки в один, выбрасывает дубликаты — и отдаёт результат мозаике.

transform устроен похоже: применить метафункцию к каждому элементу, дедуплицировать, отдать. filter — вычислить предикат на каждом элементе, оставить подходящие, отдать. concat — склеить и отдать.

Куски повторяются. Когда я выписал их все, набор оказался маленьким:

Операция

Что делает

unique_into<Target, Ts...>

выбросить дубликаты, отдать выживших шаблону Target

splice_unique_into<Target, Ls...>

склеить элементы списков, выбросить дубликаты, отдать Target

concat_into<Target, Ls...>

склеить, дубликаты оставить

select_into<Target, Mask, Ts...>

оставить элементы, у которых выставлен бит маски

nth<I, Ts...>

I-й элемент

Пять штук — и через них проходит всё публичное: filter, transform, flat_map, concat, дедупликация, сборка контейнера.

Зачем операции знают, куда класть результат

Первый параметр везде — Target, шаблон-приёмник. Он там не для симметрии.

Естественная запись была бы «верни список, а потом сконвертируй его во что надо»:

using Engine = typename Systems::flat_map<deps_of>::template into<mosaic>;

Тогда компилятор обязан создать type_list<...>, замэнглить ему имя и держать до конца трансляционной единицы — при том, что на этот тип больше никто никогда не посмотрит. Если приёмник передан внутрь, промежуточного типа просто нет:

using Engine = splice_unique_into<mosaic, ...>;   // склейка, дедупликация и мозаика за один шаг

tessera::of<A, B, C> — ровно такой вызов. Одна специализация класса, которую компилятору не надо ни создавать, ни называть, ни хранить. На каждой операции и на обеих реализациях.

Что это даёт

Пять операций живут в tessera::detail::ops, и реализаций у них две:

type_list / mosaic        │        ▼detail::ops   unique_into · splice_unique_into · concat_into · select_into · nth        │        ├── detail::tmpl   шаблонное метапрограммирование, любой компилятор с C++23        └── detail::refl   статическая рефлексия (P2996), один consteval-проход на операцию

type_list и mosaic обращаются только к detail::ops и никогда — к тому, что под ним. Поэтому выбор реализации сводится к одной строке с псевдонимом пространства имён в одном заголовке, а переезд на рефлексию оказался сменой реализации, а не переписыванием библиотеки.

Реализация первая: шаблоны

Учебная свёртка и почему она умирает

Дедупликация — самая дорогая из пяти операций. Каноническая реализация выглядит так:

template<class Acc, class... Ts> struct unique_fold;template<class Acc> struct unique_fold<Acc> { using type = Acc; };template<class... Kept, class Head, class... Tail>struct unique_fold<type_list<Kept...>, Head, Tail...>    : unique_fold<std::conditional_t<(std::is_same_v<Head, Kept> || ...),                                     type_list<Kept...>,                                     type_list<Kept..., Head>>,                  Tail...> {};template<class... Ts>using unique_fold_t = typename unique_fold<type_list<>, Ts...>::type;// вызовstatic_assert(std::same_as<unique_fold_t<int, double, int, char>,                           type_list<int, double, char>>);

Четыре строки, читается вслух, и на коротких списках эту реализацию никто не обгонит. У него две проблемы, и обе — не про асимптотику как таковую.

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

Во-вторых — и это то, обо что спотыкаются на практике, — один уровень инстанцирования на элемент. У Clang лимит глубины 1024 по умолчанию, у GCC 900. В моих замерах на 256 компонентах (это 4×256 ≈ 1000 упоминаний типов до дедупликации) свёртка просто перестаёт компилироваться. Не медленно работает — выдаёт ошибку про превышение глубины рекурсии из середины стандартной библиотеки.

Приём 1: рекурсию — на раскрытие пачки

Глубина инстанцирования — ресурс, а раскрытие пачки параметров глубины не стоит. Везде, где алгоритм можно переформулировать как «посчитать план в consteval-функции и один раз раскрыть пачку по этому плану», это надо делать.

Конкатенация — хороший пример, потому что через неё проходят и filter, и flat_map, и слияние половинок. Свёрнутая по два списка, она стоит одну промежуточную специализацию на входной список. Вместо этого:

template<template<class...> class Target, class... Ls>struct concat_into_impl {    static constexpr std::size_t total = (std::size_t{0} + ... + Ls::size);    // Обычный consteval-цикл: для каждого элемента результата — из какого он списка и с какой позиции.    static constexpr auto plan = [] {        std::array<std::size_t, total> fromList{}, fromPosition{};        const std::array<std::size_t, sizeof...(Ls)> sizes{Ls::size...};        std::size_t out = 0;        for (std::size_t list = 0; list < sizes.size(); ++list)            for (std::size_t position = 0; position < sizes[list]; ++position)                fromList[out] = list, fromPosition[out] = position, ++out;        return std::pair{fromList, fromPosition};    }();    template<std::size_t... Is>    static auto build(std::index_sequence<Is...>)        -> Target<typename list_element<pack_element_t<plan.first[Is], Ls...>,                                        plan.second[Is]>::type...>;    using type = decltype(build(std::make_index_sequence<total>{}));};// вызов: списки склеиваются сразу в нужный шаблон-приёмникusing Joined = concat_into_impl<type_list, type_list<int, char>, type_list<double>>::type;static_assert(std::same_as<Joined, type_list<int, char, double>>);

Одно инстанцирование, постоянная глубина, никакого аккумулятора. Обратите внимание, что тяжёлая часть уехала в constexpr-вычисление над std::array<std::size_t> — то есть в интерпретатор констант, который ничего не мемоизирует и ничего не удерживает. Это, забегая вперёд, ровно та же идея, на которой стоит рефлексия. Просто здесь она применена к индексам, а там — к самим типам.

Приём 2: членство — через таблицу базовых классов

Наивная проверка «есть ли T в списке A» стоит |A| инстанцирований is_same на каждый запрос, то есть |A|×|B| на слияние. Но у компилятора уже есть структура данных для быстрого ответа на вопрос «является ли X базой Y» — таблица базовых классов, построенная один раз при инстанцировании класса.

template<class T>     struct type_tag {};template<class... Ts> struct type_set : type_tag<Ts>... {};template<class T, class Set>inline constexpr bool is_member_of = std::is_base_of_v<type_tag<T>, Set>;// вызов: таблица строится один раз, спрашивать можно сколько угодноusing Set = type_set<int, double, char>;static_assert(is_member_of<double, Set>);static_assert(!is_member_of<float, Set>);

Строим type_set один раз, спрашиваем сколько угодно. Тонкость: наследоваться от одного и того же базового класса дважды нельзя — конструкция корректна, только если список уже уникален. В моём случае это ровно тот инвариант, который алгоритм и поддерживает (левая половина при слиянии уже дедуплицирована), так что запрет работает как бесплатная проверка.

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

Приём 3: ленивость

Это стоило мне вечера отладки, а сводится к одной фразе:

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

Первая версия type_list имела using unique = ...; и using flatten = ...; обычными членами. В результате любое упоминание любого списка — включая промежуточные списки, которые создаёт сама дедупликация, — дедуплицировало себя. Немедленно, независимо от того, просил кто-нибудь или нет. Помимо очевидной лишней работы, это ещё и циклично: чтобы вычислить unique, нужен L::size, для которого класс должен быть полным, а он в этот момент как раз инстанцируется.

Поэтому в итоге дедупликация и разворачивание — свободные псевдонимы (unique_t<L>, flatten_t<Ts...>), а всё, что делает реальную работу (filter, transform, flat_map, contains, nth), — шаблоны. Членами класса остались только константы и однострочные псевдонимы, которые ничего не стоят.

Если у вас метапрограммирование внезапно стало компилироваться в разы дольше после безобидного рефакторинга — проверьте это в первую очередь.

Обход графа: та же ленивость ещё раз

Разрешение зависимостей — обычный поиск в глубину с записью узла в постпорядке:

walk(T):    если T уже выписан:              ничего не делаем    если T на текущем пути:          цикл    для каждой прямой зависимости D: walk(D)    выписать T

Три случая. И в C++ их приходится разносить по трём специализациям класса, а не писать одной с std::conditional_t внутри.

Причина — та же ленивость, что и в предыдущем подразделе. Член-псевдоним инстанцируется вместе с классом. Значит, класс, который считает все три ответа сразу, вычислит и рекурсивную ветку — даже в том узле, который уже закончен. А в узле, замыкающем цикл, он от этого не остановится вообще.

Поэтому рекурсия живёт ровно в одной специализации — в той, которая обязана рекурсировать:

enum class walk_kind { finished, cyclic, descend };template<walk_kind Kind, class State, class Path, class T> struct walk_node;// уже выписан — ничего не делаем, рекурсии здесь просто нетtemplate<class State, class Path, class T>struct walk_node<walk_kind::finished, State, Path, T> { using type = State; };// не выписан — сначала зависимости, потом сам узелtemplate<class State, class Path, class T>struct walk_node<walk_kind::descend, State, Path, T> {    using after = typename walk_list<State, typename Path::template append<T>,                                     elements_of_t<dependencies_of_t<T>>>::type;    using type  = walk_state<typename after::seen::template append<T>, after::cycle>;};// вызовstruct Config {};struct Pool   { using dependencies = tessera::type_list<Config>; };struct Router { using dependencies = tessera::type_list<Pool, Config>; };static_assert(std::same_as<tessera::resolve<Router>,                           tessera::mosaic<Config, Pool, Router>>);

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

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

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

Приём 4: не создавать промежуточных типов вообще

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

Что компилятор умеет сам

Clang 22 добавил __builtin_dedup_pack<Ts...> — дедупликация пачки типов одним раскрытием, без библиотечной рекурсии:

template<class... Ts>using unique_builtin_t = type_list<__builtin_dedup_pack<Ts...>...>;// вызовstatic_assert(std::same_as<unique_builtin_t<int, double, int>, type_list<int, double>>);

Это одна строка вместо описанного выше гибрида, и она бьёт его в 1,6 раза по времени и в 2,6 по памяти (цифры ниже). Пользоваться такими вещами стоит, но держать их за одним макросом: у меня весь вендор-специфичный синтаксис живёт в единственном заголовке config.hpp за defined(__clang__) && __has_builtin(...), и у каждого билтина есть портабельный запасной путь. Тогда билтин даёт скорость на одной платформе и не разветвляет кодовую базу.

Реализация вторая: статическая рефлексия

Ликбез на три минуты

P2996 добавляет в язык одну сущность и две операции:

  • std::meta::infoзначение, представляющее сущность программы: тип, член, шаблон, что угодно;

  • ^^T — оператор рефлексии: получить info для T;

  • [: expr :] — сплайсер: превратить info обратно в сущность языка.

Плюс библиотека std::meta с обычными функциями над info: substitute (подставить аргументы в шаблон), template_arguments_of, nonstatic_data_members_of, type_of, display_string_of и так далее. Всё это consteval, то есть исполняется интерпретатором констант на этапе компиляции.

Главное отличие от шаблонов в том, что info — это значение, а не тип. Его можно положить в std::vector, отсортировать, отфильтровать, посчитать по нему что угодно обычным циклом — и всё это живёт внутри одного вызова consteval-функции и умирает вместе с ним. Компилятору нечего мемоизировать и нечего удерживать.

Те же пять операций

Вот главная из них целиком — та, на которой стоит вся сборка:

consteval void push_unique(std::vector<std::meta::info>& kept, std::meta::info meta) {    for (const std::meta::info already : kept)        if (already == meta) return;    kept.push_back(meta);}template<template<class...> class Target, class... Ls>consteval std::meta::info splice_unique_into_info() {    std::vector<std::meta::info> kept;    const auto splice = [&kept](std::meta::info list) {        for (const std::meta::info element : std::meta::template_arguments_of(list))            push_unique(kept, element);    };    (splice(^^Ls), ...);    return std::meta::substitute(^^Target, kept);}template<template<class...> class Target, class... Ls>using splice_unique_into = [:splice_unique_into_info<Target, Ls...>():];// вызов: два списка зависимостей склеиваются, дедуплицируются и становятся мозаикойusing Engine = splice_unique_into<mosaic, type_list<Clock, FrameBuffer>,                                          type_list<Clock, InputState>>;static_assert(std::same_as<Engine, mosaic<Clock, FrameBuffer, InputState>>);

Это — гибридная дедупликация, план конкатенации, таблица базовых классов и финальная конвертация, вместе взятые. Обычный вложенный цикл по вектору. tessera::of<A, B, C> — это он же с Target = mosaic.

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

Что не переехало и переехать не может: пользовательский предикат. Predicate<T>::value — это шаблон пользователя, и компилятор инстанцирует его по разу на элемент в любом случае. Поэтому filter передаёт в алгебру битовую маску, а не сам предикат — чтобы в рефлексивную реализацию не утекала работа, которую она всё равно не удешевит. Это общий принцип: рефлексия удешевляет хирургию над списками, а не вычисления, которые вы над элементами делаете.

Пять граблей, каждые по вечеру

Всё это всплывает сразу же, если брать P2996 сегодня.

1. Feature-test макроса может не быть. Эталонная реализация — форк Clang от Bloomberg — не определяет ни __cpp_impl_reflection, ни __cpp_reflection. Она отвечает только на __has_feature(reflection), и только с флагом -freflection-latest. Заголовок при этом может называться <meta>, а может <experimental/meta>. Проверять надо все три написания и оба заголовка:

#if defined(__cpp_impl_reflection) || defined(__cpp_reflection) || TESSERA_HAS_FEATURE(reflection)#  if   __has_include(<meta>)               // ...#  elif __has_include(<experimental/meta>)  // ...

2. Операнд сплайсера обязан быть константным выражением, и consteval-вызов, написанный по месту, им не является. Вот это не компилируется:

using T = [: std::meta::substitute(^^type_list, unique_metas({^^Ts...})) :];   // отвергается

а это компилируется:

template<class... Ts> consteval std::meta::info make() { /* ... */ }using T = [: make<Ts...>() :];                                                // ок

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

3. Проверка на возможности не должна вызывать consteval-функции. Очевидная проверка «есть ли у этого компилятора параметр контекста доступа из P2996R10» всегда ложна, потому что consteval-вызов внутри requires-выражения там не является константным выражением:

requires { nonstatic_data_members_of(^^T, std::meta::access_context::current()); }  // ВСЕГДА falserequires { typename std::meta::access_context; }                                    // работает

4. CMake тихо выбирает не тот стандарт. Ни один выпущенный CMake не знает про C++26 для этого компилятора и молча ставит -std=gnu++2b. Рефлексия выключается, а ошибка вылезает стеной диагностики из недр <meta> и читается как баг библиотеки. Лечится файлом тулчейна, который обнуляет CMAKE_CXX_STANDARD_DEFAULT, чтобы CMake не добавлял -std вообще.

5. Бюджет константных вычислений придётся поднимать. Дедупликация, которая раньше была инстанцированиями шаблонов, теперь обычный квадратичный цикл, и на списках в пару сотен элементов Clang упирается в -fconstexpr-steps. Симптом — «not a constant expression» на операнде сплайсера, то есть диагностика указывает совсем не туда, где проблема. У меня в бенчмарках стоит -fconstexpr-steps=1000000000.

Замеры

Методика важнее чисел

Библиотека, которая делает свою работу во время трансляции, должна и измеряться во время трансляции. Что я делаю:

  • Меряю фронтенд, а не бэкенд. Всё компилируется с -O0. Инстанцирование шаблонов и константные вычисления — это фронтенд; оптимизатор в этой картине только шум.

  • Читаю потребление ресурсов самого компилятора. Скрипт форкает компилятор напрямую и берёт статистику через os.wait4, так что пиковая RSS — это точный максимум процесса, а не результат сэмплирования. Пятнадцать строк на Python, и никакой /usr/bin/time не нужен:

pid = os.fork()if pid == 0:    os.execvp(argv[0], argv)_, status, usage = os.wait4(pid, 0)peak_rss_mib = usage.ru_maxrss / 1024      # Linux отдаёт КиБ
  • Вычитаю, а не сравниваю с нулём. Компилируются четыре варианта одной и той же TU: только заголовки (фиксированная цена тулчейна), N компонентов в std::tuple, те же N в mosaic и сборка N подсистем с четырьмя перекрывающимися зависимостями каждая (4N упоминаний типов, дедуплицируемых до N).

    В колонках std::tuple и mosaic никакой дедупликации нет: там N уже различных типов, выписанных списком. Дедупликация появляется только в колонке «сборка». Поэтому tuple против mosaic изолирует цену контейнера, mosaic против сборки — цену собственно алгебры, и от реализации зависит только строка «сборка».

Шаблоны: сколько стоит контейнер и сколько — сборка

Clang 22.1.8, -std=c++23 -O0, x86-64 Linux.

компонентов

только заголовки

std::tuple

mosaic

сборка, гибрид

сборка, билтин

64

0,32 с / 92 МиБ

0,80 с / 114 МиБ

0,74 с / 101 МиБ

1,18 с / 136 МиБ

0,95 с / 113 МиБ

128

0,33 с / 92 МиБ

1,45 с / 162 МиБ

1,50 с / 128 МиБ

2,93 с / 270 МиБ

2,07 с / 157 МиБ

256

0,33 с / 91 МиБ

3,46 с / 316 МиБ

4,54 с / 227 МиБ

8,98 с / 793 МиБ

5,71 с / 305 МиБ

Что здесь видно:

  • Контейнер, адресуемый по типу, не дороже кортежа. До 128 компонентов mosaic в пределах процентов от std::tuple из тех же типов, на 256 — на треть медленнее по времени и на четверть дешевле по памяти. Что бы проект ни платил, он платит это не за адресацию по типу.

  • Платит он за дедупликацию, и реализация тут решает. На 256 компонентах билтин делает сборку за 5,7 с и 305 МиБ против 9,0 с и 793 МиБ у библиотечного гибрида — в 1,6 раза по времени и в 2,6 по памяти. Это и есть аргумент за то, чтобы держать реализацию сменной.

  • Всё растёт суперлинейно, и учебная свёртка на 256 компонентах просто перестаёт компилироваться — до этого она была неотличима от гибрида, 2,87 с против 2,93 с. Насколько суперлинейно и обо что это упирается — отдельный разговор ниже.

Сколько стоит пройти по графу

Отдельный вопрос — во что обходится разрешение зависимостей против плоской сборки. Нагрузки одного размера: assembly — N подсистем с четырьмя перекрывающимися зависимостями, 4N упоминаний под дедупликацию; resolve — N сервисов в DAG глубины log N, 3N рёбер, транзитивное замыкание плюс топологическая сортировка. Обе заканчиваются мозаикой из N элементов, поэтому её стоимость вычтена.

N

сборка

разрешение

64

0,35 с / 28 МиБ

0,10 с / 10 МиБ

128

1,00 с / 136 МиБ

0,30 с / 36 МиБ

256

2,95 с / 558 МиБ

1,10 с / 142 МиБ

Пройти по графу оказывается дешевле, чем сплющить плоский список того же размера — в 2,7 раза по времени и в 3,9 по памяти. От операции, которая делает строго больше, ожидаешь обратного, и разница ровно в том, что именно каждая из них заставляет компилятор построить. Дедупликация пересобирает список: каждый промежуточный результат — специализация класса, которую компилятор создаёт, именует и держит до конца TU, и таких ~4N. Обход задаёт другой вопрос: type_list::contains — это свёртка-выражение по уже существующему списку, и проверка вхождения не создаёт вообще ничего. Новый список строит только append при выписывании узла, и таких N.

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

форма графа

потолок

цепочка, глубина равна размеру

510 звеньев

один корень с широким списком зависимостей

~1010 прямых зависимостей

N корней над DAG глубины log N

512 корней

То есть ограничивает не размер графа, а его форма. DAG-форма успевает дойти до 512 сервисов за 14,2 с и 1,2 ГиБ; цепочка на 1000 сервисов собирается только с поднятыми -ftemplate-depth и ulimit -s unlimited — и стоит 80,7 с и 5,3 ГиБ, что и есть аргумент против цепочек. Сто сервисов в неглубоком графе — это полторы секунды и никакого приближения к лимиту.

Рефлексия против шаблонов на одном тулчейне

Рефлексивную реализацию не собирает ни один выпущенный компилятор, поэтому у неё отдельный прогон на форке P2996 (база — Clang 21, -std=c++26 -freflection-latest -stdlib=libc++). Билтина __builtin_dedup_pack там нет, так что сравнение честное: рефлексия против двух шаблонных реализаций на одном тулчейне.

Строить мозаику из N элементов — одна и та же работа на обеих реализациях, поэтому её стоимость вычитается и остаётся цена собственно сборки: склейки, дедупликации, порождения типа. По итогам на 256 компонентах это 9,00 с против 9,85 с и 282 МиБ против 836 МиБ, а за вычетом мозаики:

компонентов

шаблоны

рефлексия

изменение

128

время

1,44 с

1,15 с

−20 %

128

память

142 МиБ

7 МиБ

−95 %

256

время

4,26 с

3,41 с

−20 %

256

память

569 МиБ

15 МиБ

−97 %

Как это читать

Рефлексия здесь — не лучший алгоритм, а более дешёвое представление. Это главное, что говорят эти четыре строки, и всё остальное из них следует. Константное вычисление делает ту же самую квадратичную работу по проверке членства, что и шаблонная свёртка, просто не материализуя типы. Отсюда и асимметрия: время падает на пятую часть, потому что работы столько же, а память падает почти вся, потому что от работы ничего не остаётся. Дедуплицировать std::vector<std::meta::info> и один раз подставить — значит не оставить после себя ничего; шаблонная реализация создаёт специализацию класса на каждый промежуточный список, и компилятор держит их все.

Практический вывод из этого прямой: компиляция мгновенной не станет, а отваливаться по памяти на CI перестанет.

Закон Амдала работает и на этапе компиляции. На 256 компонентах сам mosaic — это 5,59 с из 9,00 с итога. Даже если сделать алгебру бесплатной, трансляционная единица дешёвой не станет. Дешевле её сделает только меньшее число типов в ней.

Насколько это масштабируется и обо что упирается

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

То же вычитание, что и выше, но с другой парой: мозаика из N элементов сама по себе стоит дорого и доминирует задолго до дедупликации, поэтому контейнер из нагрузки убран совсем. setup собирает 4N упоминаний в type_list без дедупликации, algebra — те же 4N с дедупликацией, и больше в трансляционной единице нет ничего. Разность — цена дедупликации, из которой вычтено инстанцирование самих типов.

Цена дедупликации, algebra минус setup

clang-p2996 trunk 2026-09-03, -std=c++26 -O0, один прогон на ячейку, машина без нагрузки.

упоминаний типов

шаблоны, время

шаблоны, память

рефлексия, время

рефлексия, память

1 000

3,91 с

494 МиБ

1,56 с

−1 МиБ

2 000

12,98 с

1992 МиБ

6,57 с

−2 МиБ

4 000

43,69 с

7857 МиБ

26,95 с

−2 МиБ

8 000

остановлено на 21 ГиБ

108,60 с

−6 МиБ

16 000

434,78 с

−12 МиБ

32 000

1769,78 с

−21 МиБ

Шаблонную колонку я оборвал сознательно: на 8 000 упоминаний компилятор прошёл 21 ГиБ и не закончил, и я его снял, а не стал доводить машину до предела.

Главное в этой таблице — колонка памяти у рефлексии. Пик трансляционной единицы, которая дедуплицирует, совпадает с пиком той, которая не дедуплицирует: ±21 МиБ на 32-кратном диапазоне, причём знак разности произвольный. То есть дедупликация не стоит компилятору измеримой памяти. Шаблонная реализация на том же диапазоне идёт 494 → 1992 → 7857 МиБ, ×3,94…×4,03 на удвоение — ровно столько удержанных специализаций, сколько алгоритм создаёт. Раньше у меня это было записано как «569 МиБ против 15» на 256 компонентах; на четыре удвоения дальше понятно, что 15 МиБ были просто шумом измерения.

По времени квадратичны обе — скан идёт ×4,21, ×4,10, ×4,03, ×4,00, ×4,07 на удвоение, как по линейке.

Четыре стены, в порядке, в котором в них упираешься

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

  1. Свёртка-выражение примерно на 2 048 аргументов. error: instantiating fold expression with 4000 arguments exceeded expression nesting limit of 2048. Обе реализации раскрывают пачку свёрткой, так что обе останавливаются на 2 048 упоминаний, пока не поднимешь -fbracket-depth. В алгебре при этом не меняется ничего.

  2. Стек компилятора, примерно на 12 000 упоминаний. clang умирает по SIGSEGV через четыре секунды при 165 МиБ, не напечатав вообще ничего. Это парсер рекурсирует по свёртке, и лечится ulimit -s unlimited. Падение без единой диагностики очень легко принять за баг библиотеки — я и принял, пока не посмотрел на код возврата.

  3. Память компилятора — у шаблонной реализации, на ~4 000 упоминаний. 7,9 ГиБ, ×4 на удвоение. Единственная из четырёх, которая принадлежит самой библиотеке, и ровно та, ради которой рефлексивная реализация писалась.

  4. 65 535 упоминаний типов — абсолютный потолок Clang. Дальше фронтенд не может представить список — и рефлексия тут не спасает, потому что вход в любом случае приходит пачкой шаблонных аргументов.

Про последнюю стену отдельно: sizeof… молча переполняется

Начиная с некоторого размера пачки Clang возвращает из sizeof... неверное число. Без диагностики. Раскрытие пачки при этом остаётся корректным — врёт только счётчик. Замеры на Clang 21.1.8 и на форке P2996:

размер пачки

sizeof... для пачки типов

sizeof... для пачки значений

32 767

32 767

32 767

32 768

32 768

0

65 535

65 535

32 767

65 536

0

0

100 000

34 464

1 696

Счётчик заворачивается по модулю 65 536 для пачек типов и по модулю 32 768 для пачек значений. GCC 11.5 считает все эти случаи правильно, так что это потолок Clang, а не языка. Это LLVM #119600 — открыт с декабря 2024, помечен как miscompilation и регрессия с Clang 16; конкретных порогов в отчёте нет, они мои.

Если вы считаете что-нибудь на очень больших пачках, поставьте static_assert на длину результата. У меня такой ассерт стоял в бенчмарке и оказался единственным, что вообще заметило переполнение: без него бенчмарк молча мерил бы список из 6 784 элементов и подписывал результат как 400 000. По этой же причине в статье нет строк для 100 000 и 1 000 000 типов — такие списки не дорогие, а непредставимые.

Что с этим можно сделать

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

Я заменил линейный скан на открытую адресацию с ключом-хешем от display_string_ofstd::meta::info есть равенство, но нет порядка — сортировка отпадает, а хеш — нет). Дедупликация стала ровно линейной: 8 мс и 41 КиБ на упоминание, стабильно на всём диапазоне до 65 532. На потолке это 579 с против примерно 7 400 с у скана. Типы выходят идентичные шаблонной реализации — проверено отдельным тестом именно на той ветке, куда короткие списки не заходят.

По умолчанию я её всё-таки не включил: хеш обгоняет скан только после ~5 000 упоминаний, то есть на порядок дальше рабочего диапазона библиотеки, и платит за скорость ровно тем ресурсом, которым рефлексия выигрывала — 2,6 ГиБ на потолке против нуля.

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

Что вся эта возня покупает в рантайме

Компиляционная цена платится за то, чтобы в рантайме не осталось ни индирекции, ни поиска. -O2, Intel Core i7-3820 @ 3,60 ГГц, лучшее из семи прогонов, обе стороны каждого сравнения загорожены одинаковым оптимизационным барьером, чтобы ни один цикл нельзя было свернуть.

операция

результат

обычная альтернатива

обойти 8 подсистем, на вызов

0,49 нс (for_each по собранному набору)

5,12 нс — виртуальный вызов через vector<unique_ptr<I>>

рантайм-значение → compile-time константа

2,00 нс (value_list::dispatch)

5,13 нс — unordered_map указателей на функции

sizeof контейнера

совпадает с std::tuple из тех же элементов

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

По sizeof то же самое: три компонента без состояния занимают 1 байт, ровно как в кортеже, потому что [[no_unique_address]] стоит на каждом слоте. Политики, теги и пустые подсистемы не стоят ничего.

Какие задачи так решаются на практике

Конкретные формы, в которых эта техника окупается.

1. Внедрение зависимостей. То, с чего начали. Сервисы объявляют прямые зависимости, resolve считает транзитивное замыкание и топологически его сортирует. Замена контейнера указателей на базовый класс там, где набор компонентов на самом деле статичен, а динамическим его сделали только потому, что иначе неудобно: разрешение и порядок запуска перестают быть работой, выполняемой при старте, и становятся свойством типа.

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

using SupportedFormats = LinkedDecoders::values<Format, format_of>;SupportedFormats::dispatch(file.format, [&]<Format F>() {    Decoder<F>::decode(file.bytes);   // F снова константа: инстанцируются Traits<F>, Decoder<F>});

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

3. Реестр, который собирается по кусочкам. Набор поддерживаемых форматов выведен из списка декодеров, а не написан рядом с ним. Добавили декодер — он появился в списке. Не бывает состояния «декодер есть, а в таблице его забыли».

4. И по мелочи. Политики без состояния занимают ноль байт, то есть конфигурацию сборки можно держать в системе типов, а не во флагах. filter по признаку «умеет сериализоваться» или «требует GPU» даёт второй контейнер, который гарантированно является подмножеством первого, потому что вычислен из него. А static_assert(std::same_as<Engine, mosaic<...>>) — это тест на архитектуру, выполняемый при каждой компиляции: он падает в тот момент, когда кто-то добавил зависимость, не заметив.

Что рефлексия делает возможным впервые

Тут уже не про оптимизацию, а про то, что на шаблонах не выражается вообще:

  • Контейнер из существующего агрегата. members_of_t<Config> читает типы полей у структуры, и написанный руками конфиг превращается в адресуемый по типу контейнер без переписывания списка полей.

  • Доступ по имени. get<"frameBuffer">() рядом с get<FrameBuffer>() — имена членов доступны через рефлексию.

  • Человеческие диагностики. Сегодня отсутствующий компонент даёт «T не является элементом этого контейнера» и замэнгленный тип на 400 символов. С рефлексией можно перечислить, что есть в контейнере, и кто именно объявил требование. Заодно type_name<T>() перестаёт быть разбором __PRETTY_FUNCTION__ по строке и становится вызовом std::meta::display_string_of(^^T).

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

Как померить своё

Если у вас медленно компилируется и вы подозреваете метапрограммирование:

  • clang++ -ftime-trace — с него и начинайте. Даёт JSON в формате Chrome Trace, открывается в chrome://tracing или в Perfetto, показывает время по каждому InstantiateClass/InstantiateFunction пофамильно. Обычно виновника видно сразу.

  • ClangBuildAnalyzer агрегирует эти трейсы по проекту и печатает топ самых дорогих шаблонов и заголовков. У MSVC есть /d1reportTime плюс vcperf, у GCC — -ftime-report, гораздо грубее.

  • Память меряйте отдельно от времени и на -O0. Время может вырасти в 1,6 раза там, где память растёт в 2,6, а на CI с лимитом на джобу отваливается именно память — по времени сборки этого не предскажешь.

  • Отличайте отказ от отказа. «Не собралось» — это не один результат, а пять разных: превышена глубина инстанцирования, превышен лимит вложенности выражений, кончился бюджет константных вычислений, кончилась память, упал компилятор. Три из них лечатся флагом, две — нет, и по времени сборки ни одну из них не предскажешь.

Рекомендации

Берите шаблоны, если:

  • вы собираетесь релизнутыми компиляторами (а это все и всегда, кроме экспериментов);

  • списки типов исчисляются десятками, максимум сотней-двумя на трансляционную единицу;

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

Берите рефлексию, если:

  • у вас есть тулчейн с P2996 и вы готовы к тому, что предложение ещё движется;

  • списки — сотни элементов и больше, и вы упираетесь в память компилятора, а не в его время: выигрыш в десятки раз именно там, по времени он остаётся в пределах пятой части;

  • вам нужно то, что на шаблонах не выражается: чтение членов агрегата, доступ по имени, сортировка типов, вменяемые сообщения об ошибках.

Не берите ни то, ни другое, если:

  • набор действительно динамический — плагины из .so, конфигурация из файла. Виртуальная функция стоит 5 наносекунд, и это нормальная цена за то, что вам правда нужно;

  • типов в одной TU больше нескольких сотен. Ответ тут не «более хитрая метафункция», а «меньше типов в трансляционной единице»: всё растёт суперлинейно, а на 65 535 упоминаниях Clang и вовсе перестаёт правильно считать длину списка;

  • команда не готова это читать. Ошибка компиляции в шаблонном коде — отдельный жанр и реальная эксплуатационная стоимость.

И независимо от выбора:

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

  • передавайте операциям приёмник, а не конвертируйте результат — промежуточные типы бесплатны только на бумаге;

  • проверяйте эквивалентность реализаций сравнением типов, а не размеров;

  • если у вас есть две реализации — соберите обе в CI, даже если пользуетесь одной.

Ссылки

  • Исходники, из которых взяты все числа: github.com/tishden/tessera — Apache 2.0, header-only, C++23. Методика и полные таблицы в docs/benchmarks.md, внутренности в docs/design.md, грабли рефлексии в docs/reflection.md.

  • P2996 «Reflection for C++26» — само предложение.

  • Bloomberg clang-p2996 — эталонная реализация; готовые сборки лежат у Compiler Explorer, ссылка на них есть в docs/reflection.md.

  • Compiler Explorer — там же можно потрогать ^^T без установки тулчейна.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1079828/