19.1 – Шаблоны классов
В предыдущей главе мы рассмотрели шаблоны функций (8.13 – Шаблоны функций), которые позволяют нам обобщить функции для работы с множеством различных типов данных. Хотя это отличное начало на пути к обобщенному программированию, они не решают всех наших проблем. Давайте рассмотрим пример одной из таких проблем и посмотрим, что шаблоны могут в дальнейшем сделать для нас.
Шаблоны и контейнерные классы
На уроке «16.6 – Контейнерные классы» вы узнали, как использовать композицию для реализации классов, содержащих несколько экземпляров других классов. В качестве одного из примеров такого контейнера мы рассмотрели класс IntArray
. Вот упрощенный пример этого класса:
#ifndef INTARRAY_H
#define INTARRAY_H
#include <cassert>
class IntArray
{
private:
int m_length{};
int *m_data{};
public:
IntArray(int length)
{
assert(length > 0);
m_data = new int[length]{};
m_length = length;
}
// Мы не хотим позволять создавать копии IntArray.
IntArray(const IntArray&) = delete;
IntArray& operator=(const IntArray&) = delete;
~IntArray()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// Нам нужно убедиться, что мы установили здесь m_data равным 0,
// иначе указатель будет указывать на освобожденную память!
m_data = nullptr;
m_length = 0;
}
int& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
int getLength() const { return m_length; }
};
#endif
Хотя этот класс предоставляет простой способ создания массивов чисел int
, что, если мы хотим создать массив чисел double
? Используя традиционные методы программирования, нам пришлось бы полностью создать новый класс! Вот пример DoubleArray
, класса массива, используемого для хранения чисел типа double
.
#ifndef DOUBLEARRAY_H
#define DOUBLEARRAY_H
#include <cassert>
class DoubleArray
{
private:
int m_length{};
double *m_data{};
public:
DoubleArray(int length)
{
assert(length > 0);
m_data = new double[length]{};
m_length = length;
}
DoubleArray(const DoubleArray&) = delete;
DoubleArray& operator=(const DoubleArray&) = delete;
~DoubleArray()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// Нам нужно убедиться, что мы установили здесь m_data равным 0,
// иначе указатель будет указывать на освобожденную память!
m_data = nullptr;
m_length = 0;
}
double& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
int getLength() const { return m_length; }
};
#endif
Несмотря на то, что эти листинги кода довольно длинные, вы можете заметить, что эти два класса почти идентичны! Фактически, единственное существенное отличие между ними – это хранимый тип данных (int
или double
). Как вы, наверное, догадались, это еще одна область, где шаблоны могут принести пользу, чтобы избавить нас от необходимости создавать классы, привязанные к одному конкретному типу данных.
Создание шаблонов классов работает почти так же, как создание шаблонов функций, поэтому мы рассмотрим его на примере. Вот шаблонная версия нашего класса массива:
Array.h:
#ifndef ARRAY_H
#define ARRAY_H
#include <cassert>
template <class T>
class Array
{
private:
int m_length{};
T *m_data{};
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{};
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// Нам нужно убедиться, что мы установили здесь m_data равным 0,
// иначе указатель будет указывать на освобожденную память!
m_data = nullptr;
m_length = 0;
}
T& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
// шаблонная функция getLength() определена ниже
int getLength() const;
};
// функции-члены, определенные вне класса, нуждаются в собственном объявлении шаблона
template <class T>
int Array<T>::getLength() const // обратите внимание, имя класса Array<T>, а не Array
{
return m_length;
}
#endif
Как видите, эта версия почти идентична версии IntArray
, за исключением того, что мы добавили объявление шаблона и изменили содержащийся тип данных с int
на T
.
Обратите внимание, что мы также определили функцию getLength()
вне объявления класса. В этом нет необходимости, но начинающие программисты обычно из-за синтаксиса спотыкаются при первой попытке выполнить это, поэтому этот пример будет нагляден. Для каждой шаблонной функции-члена, определенной вне объявления класса, требуется собственное объявление шаблона. Также обратите внимание, что имя шаблонного класса массива – Array<T>
, а не Array
– Array
будет относиться к нешаблонной версии класса с именем Array
, если только Array
не используется внутри класса. Например, конструктор копирования и копирующий оператор присваивания использовали Array
, а не Array<T>
. Когда имя класса используется внутри класса без аргументов шаблона, аргументы будут такими же, как у текущего экземпляра.
Вот короткий пример использования показанного выше шаблонного класса массива:
#include <iostream>
#include "Array.h"
int main()
{
Array<int> intArray(12);
Array<double> doubleArray(12);
for (int count{ 0 }; count < intArray.getLength(); ++count)
{
intArray[count] = count;
doubleArray[count] = count + 0.5;
}
for (int count{ intArray.getLength() - 1 }; count >= 0; --count)
std::cout << intArray[count] << '\t' << doubleArray[count] << '\n';
return 0;
}
В этом примере печатается следующее:
11 11.5
10 10.5
9 9.5
8 8.5
7 7.5
6 6.5
5 5.5
4 4.5
3 3.5
2 2.5
1 1.5
0 0.5
Экземпляры шаблонов классов создаются таким же образом, как и шаблоны функций: компилятор создает копию по требованию (при этом параметр шаблона заменяется фактическим типом данных, который нужен пользователю), а затем компилирует эту копию. Если вы вообще не используете шаблонный класс, компилятор даже не скомпилирует его.
Шаблоны классов идеально подходят для реализации классов контейнеров, потому что очень желательно, чтобы контейнеры работали с самыми разными типами данных, а шаблоны позволяют делать это без дублирования кода. Хотя синтаксис шаблонов достаточно уродлив, а сообщения об ошибках могут быть весьма загадочными, шаблоны классов действительно являются одной из лучших и наиболее полезных функций C++.
Шаблоны классов в стандартной библиотеке
Теперь, когда мы рассмотрели шаблоны классов, вы теперь должны понять, что означает std::vector<int>
– std::vector
на самом деле является шаблоном класса, а int
– параметром типа для этого шаблона! Стандартная библиотека полна предопределенных шаблонов классов, доступных вам для использования. Мы рассмотрим их в следующих главах.
Разделение шаблонов классов
Шаблон – это не класс или функция, это образец, используемый для создания классов или функций. По сути, он работает не так, как обычные функции или классы. В большинстве случаев это не проблема. Однако есть одна область, которая у разработчиков часто вызывает проблемы.
С классами, не являющимися шаблонами, обобщенная процедура работы состоит в том, чтобы поместить определение класса в заголовочный файл, а определения функций-членов – в файл исходного кода с аналогичным именем. Таким образом, исходный код класса компилируется как отдельный файл проекта. Однако с шаблонами это не работает. Рассмотрим следующее:
Array.h:
#ifndef ARRAY_H
#define ARRAY_H
#include <cassert>
template <class T>
class Array
{
private:
int m_length{};
T* m_data{};
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{};
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
m_data = nullptr;
m_length = 0;
}
T& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
int getLength() const;
};
#endif
Array.cpp:
#include "Array.h"
template <class T>
int Array<T>::getLength() const // обратите внимание, что имя класса - Array<T>, а не Array
{
return m_length;
}
main.cpp:
#include "Array.h"
int main()
{
Array<int> intArray(12);
Array<double> doubleArray(12);
for (int count{ 0 }; count < intArray.getLength(); ++count)
{
intArray[count] = count;
doubleArray[count] = count + 0.5;
}
for (int count{ intArray.getLength() - 1 }; count >= 0; --count)
std::cout << intArray[count] << '\t' << doubleArray[count] << '\n';
return 0;
}
Показанная выше программа будет компилироваться, но вызовет ошибку компоновщика:
unresolved external symbol "public: int __thiscall Array::getLength(void)" (?GetLength@?$Array@H@@QAEHXZ)
Чтобы компилятор мог использовать шаблон, он должен видеть и определение шаблона (а не только объявление), и тип шаблона, используемый для создания экземпляра шаблона. Также помните, что C++ компилирует файлы по отдельности. Когда заголовок Array.h включается через #include
в main.cpp, определение шаблона класса копируется в main.cpp. Когда компилятор видит, что нам нужны два экземпляра шаблона, Array<int>
и Array<double>
, он создает их и компилирует как часть main.cpp. Однако когда он дойдет до компиляции Array.cpp отдельно, он забудет, что нам нужны Array<int>
и Array<double>
, поэтому шаблонная функция никогда не создается. Таким образом, мы получаем ошибку компоновщика, потому что компилятор не может найти определение для Array<int>::getLength()
и Array<double>::getLength()
.
Обойти это есть несколько способов.
Самый простой способ – просто поместить весь код вашего шаблона класса в заголовочный файл (в этом случае поместите содержимое Array.cpp в Array.h под классом). Таким образом, когда вы включите заголовок через #include
, весь код шаблона будет в одном месте. Плюс этого решения в том, что оно простое. Обратной стороной здесь является то, что если шаблон класса используется во многих местах, у вас будет много локальных копий шаблона класса, что может увеличить время компиляции и компоновки (компоновщик должен удалить повторяющиеся определения, поэтому он не должен раздуть ваш исполняемый файл). Мы предпочитаем это решение, если только проблемой не становится время компиляции или компоновки.
Если вы чувствуете, что размещение кода Array.cpp в заголовке Array.h делает заголовок слишком длинным/неаккуратным, можно переименовать Array.cpp в Array.inl (.inl означает «inline», встраиваемый), а затем включить Array.inl внизу заголовка Array.h. Это дает тот же результат, что и размещение всего кода в заголовке, но помогает сделать код немного чище.
Другие решения включают в себя включение файлов .cpp через #include
, но мы не рекомендуем их из-за нестандартного использования #include
.
Другой альтернативой является использование трехфайлового подхода. Определение шаблона класса находится в заголовке. Функции-члены шаблона класса помещаются в файл исходного кода. А затем вы добавляете третий файл, содержащий все необходимые вам экземпляры классов:
templates.cpp:
// Убедимся, что можно увидеть полное определение шаблона Array
#include "Array.h"
#include "Array.cpp" // здесь мы нарушаем лучшие практики, но только в одном этом месте
// включите сюда другие файлы .h и .cpp нужных вам определений шаблонов
template class Array<int>; // Явное создание экземпляра шаблона Array<int>
template class Array<double>; // Явное создание экземпляра шаблона Array<double>
// здесь создайте экземпляры других шаблонов
Команда template class
заставляет компилятор явно создать экземпляр шаблонного класса. В приведенном выше случае компилятор создаст в templates.cpp и Array<int>
, и Array<double>
. Поскольку templates.cpp находится внутри нашего проекта, он будет скомпилирован. Затем эти функции можно связать с вызовами из любых других файлов.
Этот метод более эффективен, но требует поддержки файла templates.cpp для каждой программы.