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

Шаблоны классов

Класс 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 – динамический. Именно на этой связке строят контейнеры, хранящие значения произвольного типа: снаружи работают через указатель на интерфейс, внутри лежит шаблонная обёртка над конкретным типом.