Шаблоны в C++: функции, классы, специализация и концепты C++20

Шаблоны в C++: функции, классы, специализация и концепты C++20 Полезное

Шаблон (template) в C++ — это заготовка функции или класса, в которой тип (а иногда и значение) вынесен в параметр. По этой заготовке компилятор сам создает конкретный код для каждого набора аргументов, например maxOf<int> и maxOf<double>. Ниже разберем шаблоны функций и классов, специализацию, концепты C++20 и типовые ошибки, а в конце коротко сравним с generics в C#.

Сначала о границах «Си-семейства». Шаблоны есть только в C++. В языке C их нет: ближайшее средство — макросы и _Generic из C11, который выбирает выражение по типу аргумента, но новый код по типу не генерирует. В C# есть generics — похожий по цели, но другой механизм. А «шаблоны» в C#-документации про is и switch — это сопоставление с образцом (pattern matching), к обобщенному программированию оно отношения не имеет.

Мини-словарь, чтобы не путаться дальше:

Термин Что это Пример
Параметр шаблона «Дырка» в заготовке: тип или значение typename T, int N
Аргумент шаблона Что подставили вместо параметра int, 3
Инстанцирование Генерация конкретного кода по шаблону при компиляции maxOf<int>
Специализация Отдельная версия шаблона для особых аргументов версия для const char*

Минимальный рабочий пример

Один файл, который компилируется и запускается как есть (проверено в Apple clang 21, режим по умолчанию C++14; в GCC и MSVC работает так же):

#include <iostream>
#include <string>

// Шаблон функции: T выводится из аргументов
template <typename T>
T maxOf(T a, T b) {
    return (a < b) ? b : a;
}

// Шаблон класса: T - тип, N - нетиповой параметр (размер)
template <typename T, int N>
class FixedStack {
    T data[N];
    int size = 0;
public:
    bool push(const T& v) {
        if (size == N) return false;   // переполнение - не пишем за границу
        data[size++] = v;
        return true;
    }
    T top() const { return data[size - 1]; }
    int count() const { return size; }
};

int main() {
    std::cout << maxOf(3, 7) << "\n";                    // T = int
    std::cout << maxOf(2.5, 1.5) << "\n";                // T = double
    std::cout << maxOf<std::string>("kiwi", "apple") << "\n";

    FixedStack<std::string, 2> s;
    s.push("a");
    s.push("b");
    bool ok = s.push("c");                               // места нет
    std::cout << s.top() << " " << s.count() << " " << ok << "\n";
}

В консоли появится:

7
2.5
kiwi
b 2 0

Как это работает: разбор примера

Шаблон функции. Строка template <typename T> объявляет параметр типа T. Когда вы пишете maxOf(3, 7), компилятор выводит T = int из аргументов и инстанцирует функцию int maxOf(int, int). Для maxOf(2.5, 1.5) появится вторая, независимая функция для double. Ключевые слова typename и class в списке параметров взаимозаменяемы.

Явный аргумент. В третьей строке тип указан вручную: maxOf<std::string>. Без этого T вывелся бы как const char*, и a < b сравнивал бы адреса строк в памяти, а не текст. Шаблон подставляет тип буквально.

Шаблон класса. У FixedStack два параметра: тип элементов T и нетиповой параметр N — целое, известное на этапе компиляции. FixedStack<std::string, 2> и FixedStack<std::string, 3> — два разных типа. Для шаблона класса до C++17 аргументы указывают всегда; с C++17 компилятор умеет выводить их из конструктора (CTAD), например std::pair p(1, 2.0).

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

Типовая ошибка: вывод типа не сошелся

Неверно — аргументы разных типов при одном параметре T:

template <typename T>
T maxOf(T a, T b) { return (a < b) ? b : a; }
int main() { return maxOf(1, 2.5) > 0 ? 0 : 1; }

Результат — ошибка компиляции. Clang 21 пишет no matching function for call to 'maxOf' и поясняет deduced conflicting types for parameter 'T' ('int' vs. 'double'). У GCC и MSVC формулировка другая, но суть та же: из 1 вышел int, из 2.5 — double.

Исправление — одно из трех, по задаче:
— указать тип явно: maxOf<double>(1, 2.5);
— привести аргумент: maxOf(1.0, 2.5);
— ввести два параметра template <typename A, typename B> и вернуть общий тип через std::common_type_t<A, B> — если смешанные типы действительно нужны.

Специализация: особая версия для особых типов

Иногда общий алгоритм для конкретного типа неверен. Классика — сравнение C-строк: a == b для const char* сравнивает адреса. Тогда пишут полную специализацию — отдельную версию для конкретного аргумента. Для шаблонов классов есть еще частичная специализация — версия для целого семейства типов, например для всех указателей.

#include <iostream>
#include <cstring>

template <typename T>
bool isEqual(T a, T b) { return a == b; }

// Полная специализация для C-строк: сравниваем содержимое, а не адреса
template <>
bool isEqual<const char*>(const char* a, const char* b) {
    return std::strcmp(a, b) == 0;
}

// Частичная специализация (только для шаблонов классов): все указатели
template <typename T>
struct TypeName { static const char* get() { return "value"; } };

template <typename T>
struct TypeName<T*> { static const char* get() { return "pointer"; } };

int main() {
    char buf[] = "otus";
    const char* p = buf;
    std::cout << isEqual(p, "otus") << "\n";          // 1
    std::cout << TypeName<int>::get() << " "
              << TypeName<int*>::get() << "\n";       // value pointer
}

Программа выведет 1 и value pointer. Без специализации первая строка дала бы 0: buf и литерал "otus" лежат по разным адресам. Шаблоны функций частично специализировать нельзя — для них вместо этого пишут обычную перегрузку.

Концепты C++20: ограничения на тип

Без ограничений шаблон принимает любой тип, а ошибка всплывает где-то в глубине тела. Концепт — именованное требование к типу, которое проверяется до инстанцирования. Раньше то же делали громоздкими SFINAE и static_assert. Пример требует -std=c++20 (GCC 10+, Clang 10+, MSVC 2019 16.3+ с /std:c++20):

#include <concepts>
#include <iostream>

// Свой концепт: тип поддерживает сложение и результат того же типа
template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};

template <Addable T>
T sum3(T a, T b, T c) { return a + b + c; }

// Стандартный концепт из <concepts>
template <std::integral T>
T half(T x) { return x / 2; }

int main() {
    std::cout << sum3(1, 2, 3) << "\n";           // 6
    std::cout << sum3(0.5, 0.25, 0.25) << "\n";   // 1
    std::cout << half(7) << "\n";                 // 3
    // half(7.0);  // не скомпилируется: double не удовлетворяет std::integral
}

Вывод: 6, 1, 3. Если раскомментировать half(7.0), Clang сообщит прямо в точке вызова: because 'double' does not satisfy 'integral'. Это главный практический выигрыш концептов — понятная ошибка и явный контракт в сигнатуре.

Где хранить шаблоны и что делать, если не собирается

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

Симптом Вероятная причина Что делать
undefined reference / undefined symbol при линковке Тело шаблона в .cpp, а используется в другом файле Перенести тело в заголовок или сделать явное инстанцирование template int maxOf<int>(int, int);
deduced conflicting types Аргументы разных типов при одном T Явный аргумент, приведение или два параметра
Длинная ошибка из глубины тела шаблона Тип не поддерживает нужную операцию Добавить концепт (C++20) или static_assert
concept не распознается Компилятор в режиме ниже C++20 Флаг -std=c++20 или /std:c++20

Врезка: шаблоны C++ и generics C

Цель у них общая — один код для многих типов, но устроены они по-разному:

Свойство Шаблоны C++ Generics C#
Когда создается конкретный код При компиляции, отдельно для каждого набора аргументов Обобщенный тип хранится в сборке; конкретизация во время выполнения (JIT/AOT)
Параметры-значения Есть (int N) Нет, только типы
Специализация Полная и частичная Нет
Ограничения Концепты C++20 (раньше SFINAE) where T : ...
Проверка тела Во многом при инстанцировании Сразу, по ограничениям

Аналог maxOf на C#: static T MaxOf<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) < 0 ? b : a;. Без ограничения where компилятор C# не даст вызвать CompareTo, потому что проверяет тело заранее, а не при подстановке типа.

Выводы

  • Шаблон C++ — заготовка функции или класса с параметрами-типами и параметрами-значениями; конкретный код компилятор создает при инстанцировании.
  • Для шаблонов функций типы обычно выводятся из аргументов; при конфликте типов нужен явный аргумент или второй параметр.
  • Специализация нужна, когда общий алгоритм неверен для конкретного типа; частичная доступна только шаблонам классов.
  • Концепты C++20 задают требования к типу в сигнатуре и дают ошибку в точке вызова.
  • Generics C# решают похожую задачу, но без параметров-значений и специализации; pattern matching в C# — не шаблоны.

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

Освойте тему на практике

На шаблонах построена стандартная библиотека C++: std::vector, std::map, std::sort, умные указатели. Уверенно читать и писать шаблонный код нужно почти в любом C++-проекте, от игровых движков до высоконагруженных сервисов. Системно пройти язык от основ до шаблонов, STL и современных стандартов можно на курсе «C++ Developer. Basic». Попробовать формат и задать вопросы преподавателям — на бесплатных открытых уроках.

FAQ

Замедляют ли шаблоны программу?
Обычно нет: код генерируется при компиляции и оптимизируется как обычный. Цена — дольше сборка и больше размер бинарника при многих инстанцированиях.

Чем шаблон отличается от макроса #define?
Макрос — текстовая подстановка препроцессора без проверки типов. Шаблон обрабатывает компилятор с учетом типов, областей видимости и перегрузки.

Можно ли передать в шаблон строку как значение?
Строковый литерал напрямую — нет. С C++20 можно передать объект структурного типа, например свою обертку над массивом символов, но это продвинутый прием.

OTUS Журнал