Библиотека собрана из шаблонов: контейнер принимает любой тип, алгоритм работает с любым итератором, матрица параметризована числовым типом. Гибкость куплена честно, без наследования и виртуальных таблиц, код генерируется под каждое сочетание аргументов. Потом пользователь библиотеки передаёт в функцию не тот тип, и компилятор вываливает стену текста на два экрана: цепочка "in instantiation of", сорок строк чужого кода, и в самом хвосте настоящая причина. Диагностика шаблонов пугает так сильно, что половина споров о них сводится к вопросу: не проще ли писать обычные функции под конкретные типы. Ниже разбор обеих половин цены: где гибкость обобщённого программирования окупается по-настоящему, где она ломает чтение ошибок и время сборки, и как концепты, статические проверки и дисциплина проектирования сокращают стену до пары строк.

Как шаблоны дают контейнерам и алгоритмам работу с любыми типами без виртуальных вызовов

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

template <typename It>
void print(It first, It last) {
    for (; first != last; ++first) {
        std::cout << *first << ' ';
    }
}

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

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

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

template <typename Derived>
struct Ordered {
    friend bool operator>(const Derived& a, const Derived& b) {
        return b < a;
    }
    friend bool operator>=(const Derived& a, const Derived& b) {
        return !(a < b);
    }
};

Класс, унаследованный от Ordered<Point> и определивший оператор меньше, получает два оператора в подарок, и все три вызова разрешаются на этапе компиляции. Наконец, шаблоны умеют считать до запуска программы:

template <int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};

template <>
struct Factorial<0> {
    static constexpr int value = 1;
};

Значение Factorial<10> вычисляется при сборке и попадает в машинный код готовой константой. Отсюда растёт целое направление, метапрограммирование: таблицы констант, разложение чисел, проверки типов до первого запуска.

Почему ошибка в аргументе шаблона превращается в стену текста на несколько экранов

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

std::vector<std::unique_ptr<Task>> tasks;
auto snapshot = tasks; // уникальный владелец не копируется

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

До C++20 требования к аргументам существовали под названием именованных требований, LegacyRandomAccessIterator и иже с ним. Это были страницы документации, компилятор их не видел. Проверял человек, и не всегда. Компилятор Microsoft годами собирал шаблоны без двухфазной проверки вообще, режим соответствия /permissive- навёл порядок позже, чем в остальных компиляторах.

Концепты C++20 и сокращение стены диагностики до нескольких строк

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

template <std::input_iterator It, std::sentinel_for<It> End>
double mean(It first, End last) {
    double sum = 0;
    long n = 0;
    for (; first != last; ++first) {
        sum += *first;
        ++n;
    }
    return n ? sum / n : 0;
}

История у концептов длинная: их обещали включить в C++0x, в 2009 году сняли с поезда, к C++20 они вернулись переработанными. GCC и Clang подхватили поддержку в десятых версиях, стандарт с концептами вышел в 2020 году. Собственный концепт объявляется в три строки:

template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
};

Стандартная библиотека привозит готовый словарь: std::integral, std::ranges::input_range, std::equality_comparable. С ними же появились сокращённые шаблонные функции с параметрами auto и отказ от enable_if: концепт, который строго сильнее другого, побеждает в перегрузке сам, механизм называется поглощением ограничений.

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

template <typename T>
    requires std::floating_point<T>
T normalize(T value, T low, T high) {
    return (value - low) / (high - low);
}

Клауз удобен, когда ограничение громоздкое или проверка сложнее готового концепта.

Приёмы дружелюбной диагностики от static_assert до обнаружения типов

До концептов и рядом с ними библиотеки оборачивали требования в статические проверки. Приём первый, static_assert с понятным сообщением в первых строках шаблона:

template <typename T>
std::string serialize(const T& value) {
    static_assert(std::is_trivially_copyable_v<T>,
                  "serialize требует тривиально копируемый тип");
    return to_bytes(value);
}

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

template <typename T, typename = void>
struct has_reserve : std::false_type {};

template <typename T>
struct has_reserve<T, std::void_t<decltype(std::declval<T>().reserve(0))>>
    : std::true_type {};

В зависимости от результата вызов резерва включается или выключается через if constexpr:

template <typename T>
void reserve(T& container, size_t n) {
    if constexpr (has_reserve<T>::value) {
        container.reserve(n);
    }
}

Доступность метода перестаёт быть угадыванием. В коде C++20 тот же детектор схлопывается в концепт из четырёх строк:

template <typename T>
concept HasReserve = requires(T c, size_t n) {
    c.reserve(n);
};

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

Что шаблоны делают со временем сборки и как его возвращают обратно

Каждое инстанцирование генерирует код. Десять типов в сочетании с двадцатью операциями матрицы это двести генераций, и часть повторяется в каждом файле, куда подключён заголовок. Расчистить помогает явное инстанцирование:

// в заголовке
template <typename T> class Matrix { /* ... */ };
extern template class Matrix<double>;

// в единственном файле реализации
template class Matrix<double>;

Компилятор разворачивает Matrix<double> один раз, остальные файлы используют готовый символ. Для разбора медленных мест Clang с девятой версии рисует отчёт флагом -ftime-trace, по которому видно, какие именно инстанцирования съели минуты. Глубину рекурсивных шаблонов ограничивает флаг -ftemplate-depth, иначе метапрограммирование заканчивается сообщением о превышении глубины инстанцирования. Отчёт о времени читается и глазами, и анализатором ClangBuildAnalyzer, группирующим шаблоны по затратам.

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

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

Порядок проектирования шаблонного интерфейса библиотеки

Порядок работы над шаблонным интерфейсом выглядит так:

  1. выписать требования к аргументам до написания тела, концептом или списком свойств;
  2. предпочесть готовые концепты стандартной библиотеки самодельным, где это возможно;
  3. держать проверку на входе: requires-клауз или static_assert в первых строках тела;
  4. покрыть интерфейс тестами с архетипами, минимальными типами, удовлетворяющими только концепту;
  5. собрать пример типичной ошибки и убедиться, что сообщение читается без документации;
  6. измерить время сборки и число инстанцирований до и после изменения;
  7. вынести внутренние шаблоны из заголовков и создать явные инстанциации для ходовых типов.

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

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

Когда обобщённость оправдана и где шаблоны усложняют библиотеку без выгоды

У шаблонов есть честные альтернативы. Для границ модулей, плагинов и всего, что живёт за пределами компиляции одного процесса, подходит стирание типов: std::function, std::any, собственный интерфейс с виртуальными методами. Такие границы стабильнее к двоичной совместимости, читаются проще и дают ошибки в терминах одного конкретного типа.

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

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

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

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