Лекция 9:

к оглавлению
оглавление

Шаблоны (параметризованные типы)

Сегодня мы поговорим об очень полезном инструменте, реализованном в С++. Имя этому инструменту – шаблон (template). В чем же его полезность?

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

В прошлом мы дублировали и размножали части программ, используя простое, но эффективное средство – текстовый редактор. Сегодня С++ предлагает нам более совершенный способ дублирования, и имя ему – "шаблоны".

Наиболее распространенным поводом для дублирования фрагментов программ является необходимость реализовать некий новый объем кода, аналогичный уже написанному, но изменив типы данных (например, целые на целые длинные). Чаще всего для подобной операции с помощью программы-редактора повторяли текст программы еще раз и меняли типы данных. Программируя на С++, вы могли бы воспользоваться средствами переопределения и дать обеим функциям одно и тоже имя. Переопределение делает текст программы более наглядным, но не избавляет нас от необходимости повторять один и тот же алгоритм в нескольких местах.

Один из вариантов решения этой проблемы средствами языка С состоит в использовании макроподстановок. Хотя макроопределения и дает возможность выписывать текст программы только один раз, у этого метода имеются несомненные недостатки. Во-первых, это целесообразно только для очень простых функций. Во-вторых, макроподстановки не обеспечивают контроля типов, и тем самым, не дают возможности воспользоваться одним из важнейших преимуществ С++. В-третьих, макроподстановки – это не обращения к функциям, а прямая подстановка тела во все места, где они используются, а это может сильно увеличить размер исполняемой программы.

Механизм шаблонов предполагает более совершенное решение, позволяющее отделить общий алгоритм от его реализации применительно к конкретным типам данных. Вы можете составить текст программы подпрограммы сейчас, а используемые типы уточнять позднее. Это возможно, так как используемый тип данных является в этом случае параметром. Шаблоны сочетают в себе преимущества однократной подготовки фрагментов программы (аналогично макрокомандам) и контроль типов, присущий переопределяемым функциям.

В языке С++ имеются два типа шаблонов – шаблоны функций и шаблоны классов.

[к началу]

Шаблоны функций

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

// Описание шаблона функции
template <class X>
X min (X a, X b)
{
 return a<b ? a : b;
}

Ключевое слово class в описании шаблона означает тип, идентификатор в списке параметров шаблона X означает имя любого типа.

В описании заголовка функции этот же идентификатор означает тип возвращаемого функцией значения и типы параметров функции.

...
// Использование шаблона функции
int m = min (1, 2);
...

Экземпляр шаблона функции породит, сгенерированный компилятором

int min (int a, int b)
{
 return a<b ? a : b;
}

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

Приведем описание шаблона функции

template <class T>
T toPower (T base, int exponent)
{
 T result = base;
 if (exponent==0) return (T)1;
 if (exponent<0) return (T)0;
 while (--exponent) result *= base;
 return result;
}

Переменная result имеет тип Т, так что, когда передаваемое в программу значение есть 1 или 0, то оно сначала приводится к типу Т, чтобы соответствовать объявлению шаблона функции.

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

int i = toPower (10, 3);
long l = toPower (1000L, 4);
double d = toPower (1e5, 5);

В первом примере Т становится типом int, во втором – long. Наконец, в третьем примере Т становится типом double. Следующий пример приведет к ошибке компиляции, так как в нем используются разные типы данных:
int i = toPower (1000L, 4);

[к началу]

Требования к фактическим параметрам шаблона

Шаблон функции toPower() может быть использован почти для любого типа данных. Предостережение "почти" проистекает из характера операций, выполняемых над параметром base и переменной result в теле функции toPower(). Какой бы тип мы не использовали в функции toPower(), эти операции для нее должны быть определены. В противном случае компилятор не будет знать, что ему делать. Вот список действий, выполняемых в функции toPower() с переменными base и result:

1. T result = base;
2. return (T)1;
3. return (T)0;
4. result *= base;
5. return result;

Все эти действия определены для встроенных типов. Однако если вы создадите функцию toPower() для какого-либо классового типа, то в этом случае такой класс должен будет включать общедоступные принадлежащие функции, которые обеспечивают следующие возможности:

действие 1 инициализирует объект типа Т таким образом, что класс Т должен содержать конструктор копирования,
- действия 2 и 3 преобразуют значения типа int в объект типа Т, поэтому класс Т должен содержать конструктор с параметром типа int, поскольку именно таким способом в классах реализуется преобразование к классовым типам,
- действие 4 использует операцию *= над типом Т, поэтому класс должен содержать собственную функцию–operator *=().
- действие 5 предполагает, что в типе T предусмотрена возможность построения безопасной копии возвращаемого объекта (см. конструктор копирования).

Схема такого класса выглядит следующим образом:

class T
{
 public:
  T (const T &base); // конструктор копирования
  T (int i); //приведение int к Т
  operator *= (T base);
  // ... прочие методы
}

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

[к началу]

Отождествление типов аргументов

Так как компилятор генерирует экземпляры шаблонов функций согласно типам, заданным при их вызовах, то критическим моментом является передача корректных типов, особенно если шаблон функции имеет два или более параметров. Хорошим примером является классическая функция max():

template <class T>
T max (T a, T b)
{
 return a > b ? a : b;
}

Функция max() будет работать правильно, если оба ее аргумента имеют один и тот же тип:

int i = max (1, 2);
double d = max (1.2, 3.4);

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

Один из возможных способов для разрешения неоднозначности состоит в использовании приведения типов, чтобы прояснить наши намерения:

int i = max ((int)'a', 100);

Вторая возможность – это явно объявить версию экземпляра шаблона функции перед ее вызовом:

int max (int, int);
int j = max ('a', 100);

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

template <class T1, class T2>
T1 max (T1 a, T2 b)
{
 return a > (T1)b ? a : (T1)b;
}

Использование этой новой версии max() не приведет к неоднозначности в случае использования двух различных типов. Например, если написать
max ('a', 100);
то компилятор будет использовать два заданных (посредством аргументов типа) и построит версию функции max() с заголовком
char max (char, int);

Далее компилятор перед выполнением сравнение приведет тип второго аргумента к типу первого аргумента. Такой способ допустим, однако использование двух типовых параметров в шаблоне функции, которая должна была бы работать только с одним типом, часто лишь затрудняет жизнь. Довольно тяжело помнить, что
max ('a', 100)
дает значение типа char, в то время как
max (100, 'a')
передает в вызывающую программу int.

[к началу]

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

Вы можете создавать шаблоны и для классов, что позволяет столь же красиво работать с любыми типами данных. Классическим примером являются контейнерные классы (т.е. классы, содержащие типы), например множества. Используя шаблон, можно создавать родовой класс для множеств, после чего порождать конкретные множества – цветов, строк и т.д.

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

template <class T>
class Pair
{
  T a, b;
 public:
  Pair (T t1, T t2);
  T Max();
  T Min ();
  int isEqual ();
};

Пока все выглядит также изящно, как и для шаблонов функций. Единственная разница состоит в том, что вместо описания функции используется объявление класса. Шаблоны классов становятся все более сложными, когда вы описываете принадлежащие функции класса. Вот, например, описание принадлежащей функции Min() класса Pair:

template <class T>
T Pair <T>::Min()
{
 return a < b ? a : b;
}

Чтобы понять эту запись, давайте вернемся немного назад. Если бы Pair был обычным классом (а не шаблоном класса) и T был бы некоторым конкретным типом, то функция Min класса Pair была бы описана таким образом:

T Pair::Min()
{
 return a < b ? a : b;
}

Для случая шаблонной версии нам необходимо, во-первых, добавить заголовок шаблона template <class T>

Затем нужно дать имя классу. Помните, что на самом деле мы описываем множество классов – семейство Pair. Повторяя синтаксис префикса (заголовка) шаблона, экземпляр класса Pair для целых типов, можно назвать Pair<int>, экземпляр для типа doublePair<double>, для типа VectorPair<Vector>. Однако в описании принадлежащей функции нам необходимо использовать имя класса Pair<T>. Это имеет смысл, так как заголовок шаблона говорит, что Т означает имя любого типа.

Приведем текст остальных методов класса Pair. Описания методов помещаются в заголовочный файл, так как они должна быть видимы везде, где используется класс Pair.

// конструктор
template <class T>
Pair <T>::Pair (T t1, T t2) : a(t1), b(t2)
{}

// метод Max template <class T>
T Pair <T>::Max()
{
return a>b ? a : b;
}

// метод isEqual template <class T>
int Pair <T>::isEqual()
{
if (a==b) return 1;
return 0;
}

Ранее уже отмечалось, что шаблоны функций могут работать только для тех (встроенных) типов данных или классов, которые поддерживают необходимые операции. То же самое справедливо и для шаблонов классов. Чтобы создать экземпляр класса Pair для некоторого классового типа, например для класса X, этот класс должен содержать следующие общедоступные функции

X (X &); // конструктор копирования
int operator == (X)
int operator < (X);

Три указанные функции необходимы, так как они реализуют операции, выполняемые над объектами типа T в метода шаблона класса Pair.

Если вы собираетесь использовать некоторый шаблон класса, как узнать какие операции требуются? Если шаблон класса снабжен документацией, то эти требования должны быть в ней указаны. В противном случае придется читать первичную документацию – исходный текст шаблона. При этом учитывайте, что встроенные типы данных поддерживают все стандартные операции.

[к началу]

Шаблоны классов: не только для типов

Параметризовать некоторый класс так, чтобы он работал для любого типа данных – это только половина того, что шаблоны обеспечивают для классов. Другой аспект состоит в том, чтобы дать возможность задания числовых параметров. Это позволяет Вам, например, создавать объекты типов "Вектор из 20 целых", "Вектор из 1000 целых" или "Вектор из 10 переменных типа double".

Основная идея проста, хотя используемый синтаксис может показаться сложным. Давайте в качестве примера рассмотрим некоторый обобщенный класс Vector. Как и класс Pair, класс Vector содержит функции Min(), Max(), isEqual(). Однако в нем может быть любое количество участников, а не два. В класс Pair число участников фиксировано и задаются они в качестве аргументов конструктора. В шаблоне Vector вместо этого используется второй параметр заголовка шаблона:

template <class T, int n> class Vector
{
 public:
  Vector();
  ~Vector() {delete[] coord;}
  void newCoord (T x);
  T Max ();
  T Min();
  int isEqual();
 private:
  T *coord;
  int current;
};

Значение n, заданное в заголовке шаблона не используется в описании класса, но применяется в описании его методов. Конструктор Vector, использующий значение n для задания размера массива, выглядит так:

// конструктор
template <class T, int n>
Vector <T, n>::Vector():
{
 coord = new T[n];
 current = 0;
}

// метод Max
template <class T, int n>
T Vector <T, n>::Max():
{
 T result (coord[0]); // *
 for (int i=0; i<n; i++)
  if (result < coord[i]) // **
 result = coord[i]; // ***
}

В конструкторе задается список инициализаций, устанавливающих начальные значения для двух элементов класса. Элемент coord инициализируется адресом динамически размещенного массива размером n и состоящего из элементов типа Т, а элемент current инициализируется значением 0.

Опять, заметим, что в качестве Т может выступать почти любой тип. Однако и в этом случае успешная реализация возможна лишь при определенных условиях – для объектов, чей тип передается в шаблон в качестве параметра, должны быть определены следующие операции

1. конструктор копирования (*),
2. оператор < (**), и > для метода Max(),
3. оператор = (***).

Имеется несколько вариантов использования шаблонов с параметрами-значениями для динамического размещения массивов различных размеров. Например, можно передать размер массива конструктору. Указание размеров объекта во время конструирования или путем обращения к некоторому методу действительно обеспечивает задание размера, однако такой способ не позволяет создать отдельный тип для каждого отдельного размера. Подход с использованием шаблона гарантирует, что размер становится частью типа. Так, Vector вектор с пятью элементами типа double является типом, отличным от Vector с четырьмя элементами типа double.

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

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

[к началу]

Наследование в шаблонах классов

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

Рассмотрим совершенно тривиальный пример, на котором продемонстрируем, каким образом можно создать шаблон класса, производный из нашего шаблона класса Pair. Пусть это будет класс Trio, в котором к паре элементов a и b из Pair, добавим еще один c.

template <class T>
class Trio: public Pair <T>
{
 T c;
 public:
 Trio (T t1, T t2, T t3);
 ...
};

template <class T>
Trio<T>::Trio (T t1, T t2, T t3): Pair <T> (t1, t2), c(t3)
// Заметьте, что вызов родительского конструктора
// также сопровождается передачей типа Т в качестве параметра.
{}

[к началу]
назад КОНЕЦ ДЕВЯТОЙ СЕРИИ вперед