Синтаксис си/с++

Шаблоны классов: контейнеры и обобщённые структуры

Введение

Ранее разбирали шаблоны функций — шаблоны классов применяют ту же идею обобщённого программирования к целым классам, что и лежит в основе всех стандартных контейнеров библиотеки (std::vector<T>, std::map<K, V>, уже неоднократно использованных в предыдущих циклах статей без подробного объяснения их шаблонной природы).

Концепция

Шаблон класса объявляется аналогично шаблону функции, с параметрами шаблона, используемыми внутри определения класса для типов полей, параметров методов, и возвращаемых значений — каждое конкретное инстанцирование (MyContainer<int>, MyContainer<std::string>) фактически создаёт отдельный, независимый тип, хотя оба происходят из одного и того же шаблонного описания. В отличие от шаблонов функций, для шаблонов классов параметр шаблона обычно необходимо указывать явно при объявлении переменной (хотя начиная с C++17 вывод типа шаблона класса из аргументов конструктора во многих случаях стал возможен автоматически, аналогично выводу для функций).

Пример кода

#include <iostream>

template<typename T>
class Box {
public:
    Box(T value) : m_value(value) {}

    T getValue() const { return m_value; }
    void setValue(T value) { m_value = value; }

private:
    T m_value;
};

int main()
{
    Box<int> intBox(42);              // явное указание типа шаблона — ОБЯЗАТЕЛЬНО в более старых стандартах
    Box<std::string> stringBox("Привет");

    std::cout << intBox.getValue() << std::endl;
    std::cout << stringBox.getValue() << std::endl;

    Box deducedBox(3.14); // C++17 — вывод типа шаблона класса из аргумента конструктора, без явного <double>

    return 0;
}
// Простой обобщённый контейнер — концептуальная демонстрация принципа std::vector
#include <iostream>
#include <stdexcept>

template<typename T>
class SimpleDynamicArray {
public:
    SimpleDynamicArray(size_t capacity) : m_capacity(capacity), m_size(0)
    {
        m_data = new T[capacity];
    }

    ~SimpleDynamicArray() { delete[] m_data; }

    void add(T value)
    {
        if (m_size >= m_capacity) {
            throw std::overflow_error("Контейнер заполнен"); // статья 540 — исключения
        }
        m_data[m_size++] = value;
    }

    T &operator[](size_t index) // статья 535 — перегрузка оператора [] для шаблонного класса
    {
        if (index >= m_size) {
            throw std::out_of_range("Индекс вне диапазона");
        }
        return m_data[index];
    }

    size_t size() const { return m_size; }

private:
    T *m_data;
    size_t m_capacity;
    size_t m_size;
};

int main()
{
    SimpleDynamicArray<int> numbers(10);
    numbers.add(1);
    numbers.add(2);
    numbers.add(3);

    for (size_t i = 0; i < numbers.size(); ++i) {
        std::cout << numbers[i] << " "; // 1 2 3
    }
    return 0;
}

Пояснения к коду

Box<T> показывает минимальный, базовый шаблон класса — параметр T используется и для типа поля m_value, и для типов параметров/возвращаемых значений методов, и каждое конкретное использование (Box<int>, Box<std::string>) компилятор обрабатывает как создание отдельного, специализированного типа со всеми методами, реализованными конкретно для этого типа. Box deducedBox(3.14) без явного <double> показывает современную возможность C++17 (CTAD, class template argument deduction) — компилятор способен сам вывести параметр шаблона класса из аргумента конструктора, аналогично давно работающему выводу типа для шаблонов функций, что упрощает синтаксис для многих типичных случаев использования. SimpleDynamicArray<T> показывает упрощённую, концептуальную демонстрацию принципа, лежащего в основе std::vector — обобщённый, типобезопасный контейнер, способный хранить элементы произвольного типа T, с методами добавления и индексированного доступа, написанными один раз и работающими корректно для любого конкретного типа, для которого этот шаблон будет инстанцирован.

Подводные камни

  • Попытка разделить объявление и определение методов шаблона класса на заголовочный (.h) и файл реализации (.cpp) тем же способом, что и для обычных, нешаблонных классов — поскольку компилятор должен видеть ПОЛНОЕ определение шаблона (включая тела всех методов) в каждой единице трансляции, где этот шаблон инстанцируется конкретным типом, обычное разделение на .h/.cpp (с объявлениями в заголовке и определениями в отдельном файле реализации) не работает напрямую для большинства шаблонов — реализация шаблонных методов обычно либо размещается непосредственно в заголовочном файле (наиболее распространённый подход), либо требует более сложных техник явного инстанцирования для специфичных случаев.
  • Ожидание автоматического полиморфного отношения между разными инстанциациями одного и того же шаблона (например, ожидание, что Box<int> можно неявно использовать там, где ожидается Box<double>, аналогично отношению между производным и базовым классом) — Box<int> и Box<double> являются полностью отдельными, независимыми типами без какого-либо автоматического отношения наследования между ними, несмотря на происхождение из общего шаблона, и попытка передать объект одного инстанцирования туда, где ожидается другое, является ошибкой типов, требующей явного, осознанного решения (если такое преобразование действительно нужно и осмысленно для конкретного случая).
  • Создание избыточно сложных шаблонов классов с большим числом параметров шаблона без действительно ясной, обоснованной необходимости в каждом из них — каждый дополнительный параметр шаблона увеличивает сложность инстанцирования и понимания кода, использующего этот шаблон, и хорошая практика — минимизировать количество параметров шаблона до действительно необходимого минимума, рассматривая параметры со значениями по умолчанию (template<typename T, typename Allocator = std::allocator<T>>, как у реальных стандартных контейнеров) для менее часто настраиваемых аспектов.
  • Игнорирование того, что каждая отдельная инстанциация шаблона класса — это полностью независимая компиляция всего кода методов для конкретного типа, что означает, что ошибки, специфичные для конкретного типа (например, попытка использовать оператор, не поддерживаемый этим конкретным типом, в одном из методов, который реально вызывается только для этого конкретного инстанцирования), обнаруживаются только при реальной попытке использовать шаблон с этим конкретным типом, а не при первоначальном написании самого шаблона — это отличается от обычных, нешаблонных классов, где все ошибки обнаруживаются единократно, при компиляции самого класса, независимо от того, как он будет в дальнейшем использоваться.