Шаблоны глубоко: специализация, variadic, SFINAE

Мы уже видели, как шаблоны позволяют писать код один раз для разных типов. Теперь разберёмся, как это работает «под капотом», и познакомимся с продвинутыми возможностями: специализацией, вариативными шаблонами и SFINAE.

Инстанцирование Специализация Variadic templates SFINAE / enable_if

Как компилятор превращает шаблон в код

Шаблон — это не функция и не класс. Это «шаблон-заготовка», из которой компилятор при использовании создаёт конкретные версии. Этот процесс называется инстанцированием.

шаблон
template<class T>
T maximum(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    int i = maximum(3, 7);        // T = int
    double d = maximum(2.5, 1.8); // T = double
    char c = maximum('a', 'z');   // T = char
}
Что делает компилятор. Он видит maximum(3, 7) — из типов аргументов определяет T = int — и генерирует отдельную функцию int maximum(int, int). Для double — свою. Получается три независимые функции, как если бы вы написали их вручную. Код шаблона при этом — один.

Интерактивно: что создаёт компилятор

Выберите типы, и увидите, какие конкретные функции сгенерирует компилятор.

$ Компилятор вывел T = int и создал функцию int maximum(int, int)

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

Иногда для конкретного типа нужно не общее решение из шаблона, а специальное. Например, для типа bool функция maximum должна возвращать логическое ИЛИ, а не «большее из двух» (у которого нет смысла для bool).

явная специализация
// Общий шаблон
template<class T>
T maximum(T a, T b) {
    return (a > b) ? a : b;
}

// Явная специализация для типа bool
template<>
bool maximum<bool>(bool a, bool b) {
    return a || b;   // «максимум» для bool — это «или»
}
Когда нужна специализация. Когда шаблонный алгоритм для конкретного типа даёт неправильный или неоптимальный результат. Классический пример — std::vector<bool>: он реализован как специализация, которая хранит биты, а не байты.

Частичная специализация

Для шаблонов классов можно специализировать не конкретный тип, а целую категорию типов.

частичная специализация для указателей
template<class T>
class Storage {
public:
    void describe() { cout << "обычный объект"; }
};

// Частичная специализация для указателей
template<class T>
class Storage<T*> {
public:
    void describe() { cout << "указатель на объект"; }
};
Порядок выбора. Компилятор сначала ищет наиболее подходящее совпадение среди специализаций. Явная специализация <int> побеждает частичную <T*>, а та — общий шаблон <T>.

Вариативные шаблоны: любое количество аргументов

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

простой пример — сумма любого числа аргументов
// Базовый случай — один аргумент
template<class T>
T sum(T value) {
    return value;
}

// Рекурсивный случай: отделяем первый аргумент
// и складываем его с суммой остальных
template<class T, class... Rest>
T sum(T first, Rest... rest) {
    return first + sum(rest...);
}

int main() {
    cout << sum(1, 2, 3, 4, 5) << endl;              // 15
    cout << sum(1.5, 2.5) << endl;                  // 4.0
    cout << sum(1, 2.5, 3) << endl;                  // выведет ошибку — типы разные
}
Как это читать. class... Rest — «пакет типов» (может быть пустым). Rest... rest — «пакет аргументов». Выражение rest... «раскрывает» пакет: подставляет через запятую все аргументы.

Интерактивно: раскрытие пакета

Нажимайте «Шаг», чтобы увидеть, как рекурсивно раскрывается пакет аргументов.

sum(1, 2, 3, 4, 5) // вызываем с 5 аргументами
1 + sum(2, 3, 4, 5) // отделили first=1, rest={2,3,4,5}
1 + 2 + sum(3, 4, 5) // first=2, rest={3,4,5}
1 + 2 + 3 + sum(4, 5) // first=3, rest={4,5}
1 + 2 + 3 + 4 + sum(5) // first=4, rest={5}
1 + 2 + 3 + 4 + 5 // базовый случай: sum(5) = 5
Результат: 15 // раскручиваем обратно
Готово к запуску
C++17 — fold expressions. Начиная с C++17, то же самое можно записать без рекурсии:
свёртка (C++17)
template<class... Args>
auto sum(Args... args) {
    return (args + ...);   // раскрывается в (a + (b + (c + ...)))
}

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

Расшифровывается как Substitution Failure Is Not An Error. Это ключевой принцип, позволяющий выбирать нужную перегрузку функции, отбрасывая неподходящие варианты без ошибки компиляции.

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

Классический пример: разные ветки для чисел и строк

SFINAE через std::enable_if
#include <type_traits>

// Версия для чисел: T — целое или вещественное
template<class T>
typename enable_if<is_arithmetic<T>::value>::type
process(T value) {
    cout << "обработка числа: " << value * 2 << endl;
}

// Версия для строк: T — строковый тип
template<class T>
typename enable_if<is_same<T, string>::value>::type
process(T value) {
    cout << "обработка строки: " << value.size() << " символов" << endl;
}

int main() {
    process(42);            // вызовет первую версию
    process(3.14);          // тоже первую
    process(string("hi"));  // вторую
}

Интерактивно: как компилятор выбирает перегрузку

Выберите тип аргумента — увидите, какие кандидаты отбрасываются по SFINAE, а какой выбирается в итоге.

$ Выбран process<int> — числовая версия

Концепты (C++20): SFINAE по-человечески

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

SFINAE (C++11)

сложно
template<class T>
typename enable_if<
    is_arithmetic<T>::value,
    T
>::type
double_it(T x) {
    return x * 2;
}

Concepts (C++20)

понятно
template<arithmetic T>
T double_it(T x) {
    return x * 2;
}
Что это даёт. Код становится читаемым, а сообщения об ошибках — осмысленными. Если раньше компилятор писал 200 строк про «substitution failure», то теперь — «тип std::string не удовлетворяет концепту arithmetic».

Что выбрать: шпаргалка

Задача Инструмент
Обобщённый алгоритм для разных типов Обычный шаблон template<class T>
Особое поведение для конкретного типа Явная специализация template<>
Особое поведение для категории типов Частичная специализация template<class T> class X<T*>
Произвольное число аргументов Variadic template template<class... Args>
Условная перегрузка (C++11) std::enable_if + SFINAE
Условная перегрузка (C++20) Концепты template<concept>

Задание

  1. Напишите шаблонную функцию print(const T& value). Убедитесь, что она работает для int, double, string.
  2. Добавьте явную специализацию print для bool, которая выводит «true» / «false» вместо 1 / 0.
  3. Напишите рекурсивный variadic-шаблон count_args(...), который возвращает количество аргументов.
  4. Напишите variadic-шаблон print_all(...), выводящий каждый аргумент на новой строке.
  5. Реализуйте SFINAE-функцию length, которая для строк возвращает size(), а для чисел — количество цифр.
  6. Перепишите предыдущую задачу через концепты C++20.
  7. Что произойдёт при инстанцировании sum(1, 2.5, 3)? Почему?