NSU Programming Программирование на C++ и Python

Обобщенное программирование

Шаблоны

Зачастую при реализации какой-либо структуры данных или алгоритма хочется, чтобы один и тот же код работал для разных типов элементов и в целом не зависел от их типа. С примерами вы уже сталкивались ранее — это контейнеры стандартной библиотеки такие как vector, list, set и др., а также обобщённые алгоритмы: sort, accumulate и т.д. Создавать такой код возможно благодаря использованию шаблонов.

Рассмотрим небольшой пример — мы хотим реализовать класс трёхмерного вектора, который может состоять из скаляров разного типа: double, float, int, bool и т.д.:

template <typename T>
class Vector3 {
private:
    T elements[3];

public:
    Vector3(T x, T y, T z) {
        this->elements[0] = x;
        this->elements[1] = y;
        this->elements[2] = z;
    }

    T& operator[](int i) {
        return elements[i];
    }
    const T& operator[](int i) const {
        return elements[i];
    }
};

Чтобы наш класс был шаблонным, перед его объявлением используется ключевое слово template, за которым в угловых скобках следует список параметров шаблона. Параметры имеют тип — в нашем случае это typename — то есть параметр T сам является каким-то типом. После этого мы можем свободно использовать T внутри нашего класса как какой-то определённый, но пока неизвестный нам тип.

Теперь, чтобы создать экземпляры нашего вектора, мы указываем шаблонный тип и затем его параметры в угловых скобках:

Vector3<double> real_vec(1.0, 2.0, 3.0);
Vector3<int> int_vec(2, 4, 6);

Помимо классов шаблонными могут быть также и функции — реализуем, к примеру, оператор сложения и функцию скалярного произведения наших векторов:

template <typename T>
Vector3<T> operator+(const Vector3<T>& a, const Vector3<T>& b) {
    Vector3<T> result;
    for (int i = 0; i < 3; ++i) {
        result[i] += a[i] + b[i];
    }
    return result;
}

template <typename T>
T dot(const Vector3<T>& a, const Vector3<T>& b) {
    T result = T(0);
    for (int i = 0; i < 3; ++i) {
        result += a[i] * b[i];
    }
    return result;
}

Теперь мы можем использовать эти функции:

Vector3<double> a(1.0, 2.0, 3.0), b(3.0, 2.0, 1.0);
Vector3<double> c = a + b;
double d = dot(a, b);

Обратите внимание, что после dot мы не указываем параметр <double> несмотря на то, что это шаблонная функция (как и оператор сложения). Дело в том, что в данном случае компилятор самостоятельно может определить тип параметра T из аргументов функции.

Целочисленные параметры

Наш вектор может хранить произвольный тип компонентов, однако что делать если мы захотим использовать двумерный или четырёхмерный вектор помимо трёхмерного? Оказывается, параметризовать можно также и размерность вектора.

template <typename T, int N>
class Vector {
private:
    T elements[N];
public:
    // ...
};

В данном случае N это не тип, а целое число. Пример такого шаблона с целочисленным параметром в стандартной библиотеке — массив фиксированного размера array.

Так же можно определить и шаблонные функции:

template <typename T, int N>
T dot(const Vector<T, N>& a, const Vector<T, N>& b) {
    T result = T(0);
    for (int i = 0; i < N; ++i) {
        result += a[i] * b[i];
    }
    return result;
}

Создаём наши вектора:

Vector<double, 3> real3;
Vector<int, 4> int4;

Есть очевидное ограничение — параметр N должен быть известен во время компиляции. Мы не сможем сделать так:

int n;
std::cin >> n;
Vector<double, n> v; // ошибка компиляции

Вообще, в качестве параметров шаблона можно использовать также другие сущности (например, указатели или даже другие шаблоны).

Частичная специализация

Иногда возникает ситуация, когда для какого-то определённого типа реализация шаблона должна отличаться от основной реализации. Например, в случае вектора, элементами которого является bool хранить массив bool elements[N]; было бы расточительно (т.к. каждый bool занимает целый байт, хотя достаточно одного бита). В таком случае нам поможет частичная специализация:

template <int N>
class Vector<bool, N> {
private:
    unsigned char bits[(N - 1) / 8 + 1];
public:
    bool operator[](int i) const {
        return (bits[i / 8] >> (i % 8)) & 1;
    }
    void set(int i, bool v) {
        bits[i / 8] = (bits[i / 8] & ~(1 << (i % 8))) | ((unsigned char)v << (i % 8));
    }
};

В данной реализации в одном байте хранится сразу 8 булевых значений, что гораздо эффективнее в плане использования памяти. Обратим внимание, что отличие от объявления основного шаблона заключается в наличии списка параметров после имени класса. Оставшиеся свободные параметры так же указываются в угловых скобках после слова template. Кстати, в стандартной библиотеке vector<bool> также частично специализирован и реализован похожим образом.

Отметим, что частичная специализация функций не используется, так как различное поведение в зависимости от различных типов аргументов у них реализуется просто перегрузкой.

Вариативные шаблоны

Вы заметили, что в первой версии наш вектор имел удобный конструктор, который позволял создать вектор из компонентов. В версии вектора произвольной длины такой конструктор должен принимать N аргументов. Тут на помощь нам приходят вариативные шаблоны.

template <typename T, int N>
class Vector {
private:
    T elements[N];

    template <int I, typename... Args>
    unwind(T x, Args... args) {
        static_assert(I < N, "Too many arguments");
        this->elements[I] = x;
        unwind<I + 1>(args...);
    }

    template <int I>
    unwind() {
        static_assert(I == N, "Too few arguments");
    }

public:
    template <typename... Args>
    Vector(Args... args) {
        this->unwind<0>(args...);
    }

    // ...
};

Давайте разберёмся по порядку, что тут происходит.

template <typename... Args>
Vector(Args... args) {
    this->unwind<0>(args...);
}

Это объявление шаблонной функции (конструктора) с вариативным параметром, то есть принимающим произвольное число аргументов (начиная с нуля включительно). Вариативный параметр определяется тремя точками. Конструктор принимает произвольное число аргументов и вызывает перегруженную шаблонную функцию unwind. Разматывание (unwinding) — это единственный возможный способ работать с вариативными параметрами, так как C++ не позволяет просто пройтись по аргументам как по массиву. Рассмотрим, как работает разматывание.

Мы создаём две функции unwind:

Первая функция — основная. Она принимает индекс аргумента в качестве целочисленного шаблонного параметра, значение этого параметра в качестве первого аргумента, и все последующие аргументы в виде вариативного параметра.

template <int I, typename... Args>
unwind(T x, Args... args) {
    static_assert(I < N, "Too many arguments");
    this->elements[I] = x;
    unwind<I + 1>();
}
  • static_assert(I < N, "Too many arguments");

    Сперва мы проверяем (см. static_assert), что индекс аргумента не выходит за границу массива (т.е. что число аргументов не больше, чем размер вектора). Если аргументов больше, то произойдёт ошибка компиляции с сообщением Too many arguments.

  • this->elements[I] = x;

    Тут мы просто записываем очередной аргумент в вектор в нужную позицию.

  • unwind<I + 1>();

    И, наконец, снова рекурсивно вызываем функцию unwind со следующим индексом, передавая ей оставшиеся аргументы. Если число оставшихся аргументов 1 и больше, то вызывается снова эта же версия функции, которая будет откусывать аргументы до тех пор, пока они не закончатся. Если список аргументов пуст, то вызовется вторая версия функции unwind, которую мы сейчас рассмотрим.

Вторая функция unwind — завершающая. Она не принимает аргументов, и вызывается, когда вариативные аргументы закончились.

template <int I>
unwind() {
    static_assert(I == N, "Too few arguments");
}
  • static_assert(I == N, "Too few arguments");

    Единственное, что делает эта функция — проверяет, что индекс совпадает с размером массива, что означает, что число аргументов в точности равно размеру вектора. Если аргументов меньше, то на этом этапе произойдёт ошибка компиляции с сообщением Too few arguments.

Таким образом мы получили конструктор, который принимает произвольное число аргументов, и выдаёт ошибку компиляции, если число аргументов не соответствует размеру вектора.

Вариативные шаблоны могут быть также применены к классам, например следующим образом мы можем объявить класс тензора с произвольной размерностью:

template <typename T, int... Dims>
class Tensor {
    // ...
};

И создать объекты:

Tensor<double, 2> vector2;
Tensor<double, 3, 4> matrix3x4;
Tensor<double, 3, 2, 4> tensor3x2x4;

Псевдонимы шаблонов

Можно создавать псевдонимы шаблонов так же как и псевдонимы типов, используя ключевое слово using. Например:

template <typename T>
using Vector4 = Vector<T, 4>;

Тут мы создали псевдоним Vector4, зафиксировав второй аргумент шаблонного класса Vector.

Заключение

Шаблоны — очень полезный инструмент в компилируемых языках. В C++ возможности шаблонного программирования чрезвычайно обширны, и позволяют создавать произвольные программы, которые будут выполняться и производить необходимые вычисления и проверки во время компиляции.

За бортом этого обзора остались многие инструменты обобщённого программирования на C++, такие как type traits стандартной библиотеки, механизм SFINAE, constexpr-функции и так далее. Вы можете ознакомиться с ними самостоятельно.

Ссылки