Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Ещё о шаблонах

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

Размер массива на этапе компиляции

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

template <typename T, size_t N>
size_t ArraySize(T (&a)[N]) {
    return N;
}

int ints[] = {1, 2, 3, 4};
double doubles[] = {3.14};
int* ptr = ints;

ArraySize(ints);      // 4
ArraySize(doubles);   // 1
ArraySize(ptr);       // ошибка компиляции – и это ровно то, что нужно

N выводится компилятором и известен на этапе компиляции. Это тот самый приём со ссылкой на массив из модуля про память, доведённый шаблоном до практичного состояния. В стандартной библиотеке он называется std::size (C++17).

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

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

template <typename T>
void Flatten(const Array<T>& array, std::ostream& out) {
    for (size_t i = 0; i < array.Size(); ++i) {
        out << array[i] << ' ';               // базовый случай: элементы печатаются напрямую
    }
}

template <typename T>
void Flatten(const Array<Array<T>>& array, std::ostream& out) {
    for (size_t i = 0; i < array.Size(); ++i) {
        Flatten(array[i], out);               // шаг: разворачиваем на уровень глубже
    }
}
Array<int> ints(2, 0);
Flatten(ints, std::cout);                     // "10 20"

Array<Array<int>> nested(2, ints);
Flatten(nested, std::cout);                   // "10 20 10 20"

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

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

Шаблонные методы в нешаблонном классе

Класс может быть обычным, а отдельные его методы – шаблонными. На этом строятся контейнеры, хранящие значение произвольного типа:

class Any {
public:
    Any() : holder_(nullptr) {}

    template <typename T>
    Any(const T& value) : holder_(new ValueHolder<T>(value)) {}   // шаблонный конструктор

    ~Any() { delete holder_; }

    template <typename T>
    T* Cast() const {
        auto* typed = dynamic_cast<ValueHolder<T>*>(holder_);
        return typed ? &typed->data_ : nullptr;
    }

private:
    ICloneable* holder_;
};
Any a = 42;
Any b = std::string("hello");

if (int* value = a.Cast<int>()) {
    std::cout << *value;      // 42
}
std::cout << (b.Cast<int>() == nullptr);   // 1: внутри строка, а не int

Тип стирается на входе (шаблонный конструктор) и восстанавливается на выходе (dynamic_cast). Приём называется стиранием типа (type erasure); ровно так устроены std::any и std::function.

constexpr

До C++11 вычисления на этапе компиляции делали рекурсивными шаблонами:

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

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

int a[Factorial<5>::value];    // массив на 120 элементов

Работает, но читается как ребус. Современный вариант – обычная функция:

constexpr int Factorial(int n) {
    return n <= 1 ? 1 : n * Factorial(n - 1);
}

int a[Factorial(5)];               // вычислено компилятором
int b = Factorial(runtimeValue);   // вычислено во время работы – тоже можно

constexpr не обязывает вычислять на компиляции – он разрешает. Если все аргументы известны компилятору, он посчитает; если нет, получится обычный вызов.

Ошибки компиляции

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

error: no match for 'operator<' (operand types are 'Widget' and 'Widget')
  in instantiation of 'void std::sort(...) [with _RandomAccessIterator = ...]'
  required from 'void SortAll(Container&) [with Container = std::vector<Widget>]'
  required from here

Читать нужно:

  1. первую строку – в чём собственно несоответствие (operator< не определён);
  2. последнее «required from here» – какая ваша строка это вызвала.

Всё между ними – внутренности библиотеки, и в них обычно лезть не нужно.

Основная причина такого многословия – требования к типу нигде не записаны, они выводятся из тела шаблона. В C++20 для этого появились концепты: template <std::totally_ordered T> void Sort(...) – и ошибка возникнет на границе с внятным «Widget не удовлетворяет totally_ordered», а не в глубине. Если ваш компилятор поддерживает C++20, концепты стоят того, чтобы их освоить.

Что шаблоны дают и чего стоят

Плюс Минус
Один алгоритм для всех типов код в заголовках, долгая сборка
Полиморфизм без vptr и косвенных вызовов раздувание бинарника: экземпляр на каждый тип
Требования проверяются компилятором нечитаемые ошибки при их нарушении
Вычисления на этапе компиляции сложность отладки: шагать по шаблону тяжело

Практическое правило то же, что и для остальных возможностей C++: шаблон уместен там, где обобщение действительно нужно. Шаблонный класс, инстанцируемый одним-единственным типом, – это обычный класс с лишним синтаксисом и худшей диагностикой.

Куда двигаться дальше

Шаблоны – вход в стандартную библиотеку, и это самое полезное продолжение курса. std::vector, std::map, std::string, алгоритмы из <algorithm>, итераторы, std::function, умные указатели – всё это шаблоны, и теперь их устройство не выглядит магией.

Из возможностей языка, оставшихся за рамками: семантика перемещения и rvalue-ссылки, вариативные шаблоны (template <typename... Args>), исключения и гарантии безопасности, многопоточность, модули C++20, концепты, корутины. Что из этого понадобится первым, зависит от задач – но основание, на котором всё это стоит, уже разобрано.