This lesson is not translated yet – the Russian original is below.
Последний урок модуля – о приёмах, ради которых шаблоны и оказались в 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
Читать нужно:
- первую строку – в чём собственно несоответствие (
operator<не определён); - последнее «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, концепты, корутины. Что из этого понадобится первым, зависит от задач – но
основание, на котором всё это стоит, уже разобрано.