Некоторое время назад в одном из проектов мне понадобилось управление зависимостями на этапе компиляции. То есть 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 подсистем. Каждая подсистема декларирует, какие сервисы ей нужны. При этом каждая подсистема независима и не знает про остальные. Нужен контейнер, в котором каждый сервис лежит ровно один раз, и обращение к нему не зависит от того, кто ещё какие сервисы запросил.
Обычно это делают так:
-
Контейнер указателей на базовый класс.
vector<unique_ptr<IService>>плюсdynamic_castили картаtype_index → указатель. Косвенный вызов на каждое обращение, аллокация на каждый компонент, набор компонентов известен только в рантайме. -
Руками написанный
std::tupleс константами-индексами. Индексы — второй источник правды, который расходится с объявлением при первой же вставке в середину. -
Кодогенератор. Работает, но добавляет в сборку шаг, отдельный язык и отдельную категорию ошибок.
Четвёртый способ — сделать саму сборку вычислением над типами:
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 — склеить и отдать.
Куски повторяются. Когда я выписал их все, набор оказался маленьким:
|
Операция |
Что делает |
|---|---|
|
|
выбросить дубликаты, отдать выживших шаблону |
|
|
склеить элементы списков, выбросить дубликаты, отдать |
|
|
склеить, дубликаты оставить |
|
|
оставить элементы, у которых выставлен бит маски |
|
|
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.
|
компонентов |
только заголовки |
|
|
сборка, гибрид |
сборка, билтин |
|---|---|---|---|---|---|
|
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 глубины |
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 на удвоение, как по линейке.
Четыре стены, в порядке, в котором в них упираешься
Три из четырёх поставлены флагом компилятора по умолчанию, а не тем, что делает библиотека.
-
Свёртка-выражение примерно на 2 048 аргументов.
error: instantiating fold expression with 4000 arguments exceeded expression nesting limit of 2048. Обе реализации раскрывают пачку свёрткой, так что обе останавливаются на 2 048 упоминаний, пока не поднимешь-fbracket-depth. В алгебре при этом не меняется ничего. -
Стек компилятора, примерно на 12 000 упоминаний.
clangумирает поSIGSEGVчерез четыре секунды при 165 МиБ, не напечатав вообще ничего. Это парсер рекурсирует по свёртке, и лечитсяulimit -s unlimited. Падение без единой диагностики очень легко принять за баг библиотеки — я и принял, пока не посмотрел на код возврата. -
Память компилятора — у шаблонной реализации, на ~4 000 упоминаний. 7,9 ГиБ, ×4 на удвоение. Единственная из четырёх, которая принадлежит самой библиотеке, и ровно та, ради которой рефлексивная реализация писалась.
-
65 535 упоминаний типов — абсолютный потолок Clang. Дальше фронтенд не может представить список — и рефлексия тут не спасает, потому что вход в любом случае приходит пачкой шаблонных аргументов.
Про последнюю стену отдельно: sizeof… молча переполняется
Начиная с некоторого размера пачки Clang возвращает из sizeof... неверное число. Без диагностики. Раскрытие пачки при этом остаётся корректным — врёт только счётчик. Замеры на Clang 21.1.8 и на форке P2996:
|
размер пачки |
|
|
|---|---|---|
|
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_of (у std::meta::info есть равенство, но нет порядка — сортировка отпадает, а хеш — нет). Дедупликация стала ровно линейной: 8 мс и 41 КиБ на упоминание, стабильно на всём диапазоне до 65 532. На потолке это 579 с против примерно 7 400 с у скана. Типы выходят идентичные шаблонной реализации — проверено отдельным тестом именно на той ветке, куда короткие списки не заходят.
По умолчанию я её всё-таки не включил: хеш обгоняет скан только после ~5 000 упоминаний, то есть на порядок дальше рабочего диапазона библиотеки, и платит за скорость ровно тем ресурсом, которым рефлексия выигрывала — 2,6 ГиБ на потолке против нуля.
Здесь важен не победивший вариант, а то, что вариантов оказалось два. В шаблонной реализации стоимость дедупликации задана тем, как компилятор представляет промежуточные результаты, и обойти это нельзя: каким бы ни был алгоритм, платить за него будет создание типов. В рефлексивной дедупликация — обычный цикл по вектору, и замена линейного скана на хеш-таблицу заняла тридцать строк. Когда нагрузка этого потребует, менять будет что.
Что вся эта возня покупает в рантайме
Компиляционная цена платится за то, чтобы в рантайме не осталось ни индирекции, ни поиска. -O2, Intel Core i7-3820 @ 3,60 ГГц, лучшее из семи прогонов, обе стороны каждого сравнения загорожены одинаковым оптимизационным барьером, чтобы ни один цикл нельзя было свернуть.
|
операция |
результат |
обычная альтернатива |
|---|---|---|
|
обойти 8 подсистем, на вызов |
0,49 нс ( |
5,12 нс — виртуальный вызов через |
|
рантайм-значение → compile-time константа |
2,00 нс ( |
5,13 нс — |
|
|
совпадает с |
— |
Речь тут не о том, что шаблоны быстрее виртуальных функций — так вопрос не стоит. Речь о том, что набор, зафиксированный на этапе компиляции, не требует индирекции для обхода, а константа, известная на этапе компиляции, не требует таблицы для поиска. Десятикратная разница — это стоимость самой индирекции.
По 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/