Зачастую при реализации какой-либо структуры данных или алгоритма хочется, чтобы один и тот же код работал для разных типов элементов и в целом не зависел от их типа. С примерами вы уже сталкивались ранее — это контейнеры стандартной библиотеки такие как 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
-функции и так далее. Вы можете ознакомиться с ними самостоятельно.