Класс Array из третьего модуля умеет только int. Писать такой же для double, потом для
std::string – работа, которую должен делать компилятор. Шаблон и есть способ ему это поручить.
Шаблон – рецепт, а не код
template <typename T>
class Array {
public:
explicit Array(size_t size, const T& value = T())
: size_(size)
, data_(new T[size])
{
for (size_t i = 0; i < size_; ++i) {
data_[i] = value;
}
}
~Array() { delete[] data_; }
size_t Size() const { return size_; }
T& operator[](size_t i) { return data_[i]; }
const T& operator[](size_t i) const { return data_[i]; }
private:
size_t size_;
T* data_;
};
Array<int> numbers(10, 0);
Array<std::string> names(3, "unknown");
typename T – параметр шаблона: заглушка вместо типа. Array сам по себе классом не является;
классами становятся Array<int> и Array<std::string> после инстанцирования – момента, когда
компилятор подставляет конкретный тип и порождает настоящий код.
Вместо typename можно писать class – они полностью равнозначны в объявлении параметра. typename точнее по смыслу: параметром может быть и int, который классом не является.
Инстанцирование по требованию
Компилятор генерирует код только для тех наборов аргументов, которые реально используются, и только для тех методов, которые реально вызваны.
template <typename T>
class Widget {
public:
void Common() { /* ... */ }
void OnlyForNumbers() { T x = 0; x += 1; } // осмысленно не для всякого T
};
Widget<std::string> w;
w.Common(); // компилируется
// w.OnlyForNumbers(); // а вот это уже нет
Метод, который не вызвали, не инстанцируется – и ошибка в нём не обнаруживается. Свойство это двоякое: даёт гибкость, но откладывает диагностику до первого вызова.
Почему шаблон живёт в заголовке
Чтобы породить Array<int>, компилятору нужно тело шаблона прямо там, где Array<int>
используется. Разложить объявление и определение по .hpp и .cpp, как для обычного класса, не
получится:
// array.hpp – объявление
template <typename T>
class Array {
public:
T& operator[](size_t i);
};
// array.cpp – определение
template <typename T>
T& Array<T>::operator[](size_t i) { return data_[i]; }
// main.cpp
Array<int> a(10);
a[0]; // undefined reference: никто не породил Array<int>::operator[]
Поэтому шаблоны целиком кладут в заголовки. ODR это не нарушает: для шаблонов сделано исключение, а дубликаты убирает компоновщик.
Определять методы можно и вне тела класса – в том же заголовке:
template <typename T>
T& Array<T>::operator[](size_t i) {
return data_[i];
}
Обратите внимание: Array<T>:: – вне класса имя шаблона нужно писать с параметрами.
Обратная сторона – время сборки. Каждая единица трансляции, включившая заголовок, разбирает шаблон заново и инстанцирует свои копии; компоновщик потом их выбрасывает. На больших проектах шаблоны в заголовках – основной источник долгой компиляции, и именно поэтому в C++20 появились модули.
Параметры-значения
Параметром шаблона может быть не только тип, но и константа, известная на этапе компиляции:
template <typename T, size_t N>
class FixedArray {
public:
size_t Size() const { return N; }
private:
T data_[N]; // массив на стеке: размер известен компилятору
};
FixedArray<int, 16> buffer;
Так устроен std::array. Размер – часть типа, поэтому FixedArray<int, 16> и
FixedArray<int, 32> – разные типы, а память выделяется на стеке без единого обращения к куче.
Значения по умолчанию
template <typename T, typename Allocator = std::allocator<T>>
class Vector { /* ... */ };
Vector<int> v; // Allocator подставится сам
Vector<int, MyAllocator<int>> custom;
Так же устроены std::vector, std::map и почти все стандартные контейнеры: обязателен один
параметр, остальные имеют разумные умолчания.
Требования к типу
Шаблон молча предъявляет требования к T – те, что следуют из его тела. Array выше требует от
типа конструктор по умолчанию (для new T[size]) и оператор присваивания (для заполнения).
class NoDefault {
public:
explicit NoDefault(int value); // конструктора по умолчанию нет
};
Array<NoDefault> a(10); // ошибка компиляции внутри шаблона
Хороший шаблон требует от типа как можно меньше. Обойти оба требования можно, выделяя сырую память и создавая объекты вручную:
template <typename T>
class Array {
public:
explicit Array(size_t size, const T& value)
: size_(size)
, data_(reinterpret_cast<T*>(new char[size * sizeof(T)]))
{
for (size_t i = 0; i < size_; ++i) {
new (data_ + i) T(value); // placement new: строим на готовом месте
}
}
~Array() {
for (size_t i = 0; i < size_; ++i) {
data_[i].~T(); // явный вызов деструктора
}
delete[] reinterpret_cast<char*>(data_);
}
private:
size_t size_;
T* data_;
};
Теперь от T нужен только конструктор копирования. Ровно так внутри устроен std::vector: память
и время жизни объектов в ней разведены, поэтому capacity может быть больше size.
«Placement new» ничего не выделяет – он строит объект по указанному адресу. Парный ему явный вызов деструктора p->~T() – единственное место в C++, где деструктор вызывают руками. Оба нужны только при написании контейнеров; в прикладном коде их появление почти всегда ошибка.
Шаблоны и наследование
Шаблонный класс наследуют так же, как обычный:
struct ICloneable {
virtual ICloneable* Clone() const = 0;
virtual ~ICloneable() = default;
};
template <typename T>
struct ValueHolder : ICloneable {
explicit ValueHolder(const T& data) : data_(data) {}
ICloneable* Clone() const override { return new ValueHolder<T>(data_); }
T data_;
};
Здесь встречаются оба вида полиморфизма: ValueHolder<T> – статический, интерфейс ICloneable –
динамический. Именно на этой связке строят контейнеры, хранящие значения произвольного типа: снаружи
работают через указатель на интерфейс, внутри лежит шаблонная обёртка над конкретным типом.