Skip to content

ООП Лекция 09. Шаблоны в CPP.

Vladislav Mansurov edited this page May 1, 2022 · 2 revisions

Шаблоны

В языке Си приходилось создавать подобные функции, работающие с разными типами данных. По существу был copy-past кода под разные типы данных. Можно решать эту проблему с помощью макросов с параметрами, но это крайне опасная вещь. На этапе препроцесирования происходит подстановка макроса в код, на этапе компиляции идет проверка подставленного. Если у нас ошибка в макросе, то определить её крайне сложно.

Макрос с параметрами объявляется следующим образом:

имя макроса(список параметров) последовательность_символов

Примеры:

#define print(a) printf("%d \n", a)

#define min(a,b) (a < b ? a : b)

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

Понятие шаблона

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

Параметрами шаблона могут быть:

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

Шаблоны можно определять:

  • функций
  • типов
  • классов
  • методов класса

Синтаксис шаблона в общем виде:

template<[параметры шаблона]>
функция | класс | тип

Про typename и class

Для параметра типа изначально использовалось ключевое слово class, но программисты стали возражать, так как в С++ простые типы изначально не были классами, и разработчик языка добавил еще одно ключевое слово - typename. Разницы между описанием class и typename нет, это сделано для того, чтобы лучше читался код, чтобы было понимание, что это тип.

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

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

Возможно два варианта создания функции по шаблону:

  • Функция принимает параметры шаблона, например, void freeArray(Type* arr);
  • Явное указание параметров функции, например, Type* initArray(int count);

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

Определение функции представляет из себя то же самое, что объявление, только с телом. В коде параметры шаблона мы используем как типы, и при создании функции по шаблону это имя заменяется конкретным типом.

Шаблоны типов

Мы можем определять не только функций, но и шаблонный тип. В языке Си для задания имени типа использовался typedef. Это аналог определения какой-либо переменной, только вместо этого - имя типа.

Например, определим имя для типа, принимающего указатель на функцию, принимающую int и возвращающую void.

typedef void (*Tpf)(int);

В языке С++ шаблон на основе typedef мы определить не можем. Добавляется еще одна конструкция - using, выполняющая ту же функцию, что и typedef. После using записываем имя типа, а далее записывается так называемый абстрактный описатель (определение переменной без ее имени).

Пример:

using Tpf = void (*)(int);

Две вышеприведенные записи с typedef и using эквивалентны, но для typedef мы не можем определить шаблон.

Пример шаблона функции и вызова функции

template<typename Type>
void swap(Type&, Type&);
...
swap<double>(ar[i], ar[j]);

Пример использования шаблонов функций и типа

// Обьявление шаблона функции и имени параметра
template <typename Type>    // Type - имя параметра, который используется в шаблоне 
Type* initArray(int count); // Параметры шаблонной функции заданы явно

template <typename Type>
void freeArray(Type* arr);

template <typename Type>
Type* inputArray(Type* arr, int q);

template <typename Type>
void outputArray(const Type* arr, int q);

// Шаблона типа
template <typename Type> using Tfunc = int(*)(const Type&, const Type&);

// Объявление шаблона функции с использованием шаблона типа
template <typename Type>
void sort(Type* arr, int q, Tfunc<Type> cmp);

int compare(const double& d1, const double& d2) { return d1 - d2; }

int main()
{
	const int N = 10;
	double* arr = initArray<double>(N); // Функция генерится с типом double
	cout << "Enter array: ";
	inputArray(arr, N);
	sort(arr, N, compare);
	cout << "Resulting array: ";
	outputArray(arr, N);
	freeArray(arr);
	return 0;
}

template <typename Type>
Type* initArray(int count) { return new Type[count]; }

template <typename Type>
void freeArray(Type* arr) {	delete[]arr; }

template <typename Type>
Type* inputArray(Type* arr, int q)
{
	for (int i = 0; i < q; i++)
		cin >> arr[i];
	return arr;
}

template <typename Type>
void outputArray(const Type* arr, int q)
{
	for (int i = 0; i < q; i++)
		cout << arr[i] << " ";
	cout << endl;
}

template <typename Type>
void sort(Type* arr, int q, Tfunc<Type> cmp)
{
	for (int i = 0; i < q - 1; i++)
		for (int j = i + 1; j < q; j++)
			if (cmp(arr[i], arr[j]) > 0)
				swap(arr[i], arr[j]);
}

Правило вызова шаблонных функций

Любую функцию можно:

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

Правило вызова.

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

Пример на правило вызова

// Шаблон функции
template <typename Type>
void swap(Type& val1, Type& val2)
{
	Type temp = val1; val1 = val2; val2 = temp;
}

// Полная специализация 
template<> // Показываем, что функция - шаблон
void swap<float>(float& val1, float& val2)
{
	float temp = val1;
    val1 = val2;
    val2 = temp;
}

// Перегрузка
void swap(float& val1, float& val2) 
{
	float temp = val1; val1 = val2; val2 = temp;
}

// Перегрузка
void swap(int& val1, int& val2)
{
	int temp = val1; val1 = val2; val2 = temp;
}

void main()
{
	const int N = 2;
	int a1[N];
	float a2[N];
	double a3[N];

	swap(a1[0], a1[1]);		   // swap(int&, int&) - вызывается перегруженная
    
	swap<int>(a1[0], a1[1]);   // swap<int>(int&, int&) - в этом случае
                               // будет не вызываться перегруженная функция, а
                               // создаваться функция по шаблону - мы указали <int>
	
    swap(a2[0], a2[1]);		   // swap(float&, float&) - вызывается перегруженная
    
	swap<float>(a2[0], a2[1]); // swap<>(float&, float&) - явно указали <float>,
                               // вызывается специализация
    
	swap(a3[0], a3[1]);		   // swap<double>(double&, double&) - четкое создание
    						   // функции по шаблону со значением параметра double
}

Проблема определения возвращаемого типа

На вызов и создание по шаблону конкретной функции влияют только параметры, которые мы передаём. Как определять возвращаемый тип? Возвращаемый тип можно определять по какому-либо выражению.

В заголовке пишем -> decltype(выражение). Происходит приведение к какому-либо типу этого выражения. Вместо конкретного типа ставим модификатор auto.

Автоматический модификатор auto мы так же можем использовать для какой-либо переменной. Единственное правила: мы обязательно должны эту переменную инициализировать. Тип этой переменной определяется типом возвращаемого значения выражения.

В данном случае шаблон принимает два параметра. По шаблону создается функция, принимающая первый параметр типа int и второй параметр типа double. При сложении целого с вещественным происходит приведение к типу double, decltype возвращает тип double, соответственно тип возвращаемого значения тоже будет double, а значит переменная s тоже типа double`.

Пример:

template <typename T, typename U>
auto sum(const T& elem1, const U& elem2) -> decltype(elem1 + elem2)
{
	return elem1 + elem2;
}

int main()
{
	auto s = sum(1, 1.2); // тип auto необходимо инициализировать при объявлении
	cout << "Result: " << s << endl;
}

Механизм очень удобный, и часто его используют чтобы не писать слишком длинные типы.

Шаблоны классов и методов

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

Определение шаблона класса:

template <typename T>
class A
{
    T elem;
public:
	void f();    
};

Мы определили шаблон класса. Это не класс, это шаблон класса. Методы шаблона класса также являются шаблонами. В принципе, у нас может быть нешаблонный класс, но с шаблонными методами, то есть в обычном классе можно определить шаблонный метод.

Методы шаблонного класса

Как определяются методы шаблонного класса?

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

Так же, как и у любой функции, у шаблона метода может быть специализация.

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

// Метод шаблонного класса
template <typename T>
void A<T>::f() {}

// Шаблон со специализацией
template <>
void A<int>::f() {}

Как определить класс?

При определении объектов класса, будет по шаблону создаваться класс, поэтому мы должны указать для класса параметры A<float> obj;. По этому шаблону будет создан класс, в котором T заменится на float.

Кроме параметров типа, у нас могут быть параметры значений (объекты с внешним связыванием, целочисленные константы или тип перечисление).

Пример:

template <const char *name>
class A {...};

const char *S = "name";
A<S> x; // ERROR! Нет внешнего связывания

extern char st[] = "Name";
A<st> y; // OK! Есть внешнее связывание. На этапе компиляции
         // идет статическое распределение памяти под массив

Пример с параметрами типа и параметром значений

В примере два параметра класса: параметр типа и параметр значений.

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

Если у нас разные параметры типов, это можно понять: массив целых чисел, вещественных, комплексных... возникает вопрос, можем ли мы совместно использовать эти типы? Можем ли мы массиву целых значений присвоить массив комплексных или наоборот? Возникает вопрос, как быть с параметрами значений.

Под каждый параметр значений, в данном случае, по шаблону будет создаваться свой класс. Массив из 2ух элементов и из 10и - разные классы. Чаще всего это не нужно. С параметрами значений нужно быть осторожными. Лучше всего сначала проанализировать, нужен ли параметр значений.

template <typename Type, size_t N>
class Array
{
private:
	Type arr[N];
public:
	Array() = default;
	Array(initializer_list<Type> lt);
	Type& operator[](int ind);
	const Type& operator[](int ind) const;
	bool operator ==(const Array<Type, N>& a) const;
	template <typename Type, size_t N>
	friend Array<Type, N> operator+(const Array<Type, N>& a1, const Array<Type, N>& a2);
};

template <typename Type, size_t N>
Array<Type, N>::Array(initializer_list<Type> lt)
{
	int n = N <= lt.size() ? N : lt.size();
	const Type* iter = lt.begin();
	int i;
	for (i = 0; i < n; i++, iter++)
		arr[i] = *iter;
	for (; i < N; i++)
		arr[i] = 0.;
}

template <typename Type, size_t N>
Type& Array<Type, N>::operator[](int ind) { return arr[ind]; }

template <typename Type, size_t N>
const Type& Array<Type, N>::operator[](int ind) const { return arr[ind]; }

template <typename Type, size_t N>
bool Array<Type, N>::operator ==(const Array<Type, N>& a) const
{
	if (this == &a) return true;
	bool Key = true;
	for (int i = 0; Key && i < N; i++)
		Key = this->arr[i] == a.arr[i];
	return Key;
}

template <typename Type, size_t N>
Array<Type, N> operator+(const Array<Type, N>& a1, const Array<Type, N>& a2)
{
	Array<Type, N> res;
	for (int i = 0; i < N; i++)
		res.arr[i] = a1.arr[i] + a2.arr[i];
	return res;
}

template <typename Type, size_t N>
ostream& operator<<(ostream& os, const Array<Type, N>& a)
{
	for (int i = 0; i < N; i++)
		os << a[i] << " ";
	return os;
}

int main()
{
	Array<double, 3> a1{ 1, 2, 3 }, a2{ 1, 2, 3 }, a3{4, 2};
	if (a1 == a2)
		a1 = a2 + a3;
	cout << a1 << endl;
	return 0;
}

Специализация шаблонов класса

Специализация может быть как частичная, так и полная.

Полная специализация

В данном случае описана полная специализация шаблона класса А:

template <typename T>
class A {...};

template <>
class A<float> {...};

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

Пример полной специализации

У нас есть шаблон класса А и специализация этого класса А. В специализации класса интерфейс, отличный от шаблона.

template <typename Type>
class A
{
public:
	A() { cout << "constructor of template A;" << endl; }
	void f() { cout << "metod f of template A;" << endl; }
};

template<>
void A<int>::f() { cout << "specialization of metod f of template A;" << endl;}

template <>
class A<float>
{
public:
	A() { cout << "specialization constructor template A;" << endl; }
	void f() { cout << "metod f specialization template A;" << endl; }
	void g() { cout << "metod g specialization template A;" << endl; }
};

int main()
{
	A<double> obj1;
	obj1.f();
    
	A<float> obj2;
	obj2.f();
	obj2.g();
    
	A<int> obj3;
	obj3.f();
    
	return 0;
}

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

Частичная специализация - когда мы указываем не все значения параметра шаблона. При частичной специализации шаблонов возможна неоднозначность при вызове. Здесь аналогичный подход с функциями: сначала идет выбор специализации, если невозможно создать класс по специализации, создается класс по шаблону.

Пример частичной специализации

У нас есть класс с двумя параметрами шаблонов. У шаблона класса два параметра class A<T, T>, но эти два параметра одного типа template <typename T>. Мы можем частично специализировать этот шаблон.

// Второй параметр - по умолчанию.
// То есть если компилятор генерить класс по шаблону, а в параметры передаётся
// только один параметр, второй будет приниматься за double.
template <typename T1, typename T2 = double> 
class A
{
public:
	A() { cout << "constructor of template A<T1, T2>;" << endl; }
};

// Частичная специализация, когда два параметра равны
template <typename T>
class A<T, T>
{
public:
	A() { cout << "constructor of template A<T, T>;" << endl; }
};

// Частичная специализация, второй параметр - int
template <typename T>
class A<T, int>
{
public:
	A() { cout << "constructor of template A<T, int>;" << endl; }
};


// Частичная специализация, когда два параметра, но они
// имеют конкретный тип, производный от параметров шаблона
template <typename T1, typename T2>
class A<T1*, T2*>
{
public:
	A() { cout << "constructor of template A<T1*, T2*>;" << endl; }
};


int main()
{
	A<int> a0;           // Специализация, когда два параметра равны
	A<int, float> a1;    // Нет соответствующей специализации, класс будет создаваться по шаблону
	A<float, float> a2;  // Специализация, когда два параметра равны
	A<float, int> a3;    // Специализация со значением int
	A<int*, float*> a4;  // Специализация с двумя указателями
    
//	A<int, int> a5;		// Error!!! Неоднозначность. Мы можем создать класс
//	A<int*, int*> a6;	// по двум разным специализациям
//  У этих специализаций нет приоритета
}

Параметры по умолчанию

Так же, как при определении функций, параметры шаблона могут быть по умолчанию. В случае выше сам шаблон имеет один параметр по умолчанию - double. Если мы передаем только один параметр, будет вызываться этот шаблон (а второй параметр по умолчанию типа doule):

template <typename T1, typename T2 = double> 
class A
{
public:
	A() { cout << "constructor of template A<T1, T2>;" << endl; }
};

Шаблоны с переменным числом параметров

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

Когда удобно использовать

Если мы рассматриваем для классов, чтобы в классах были инициализаторы с несколькими параметрами. В принципе, можно использовать initializer_list, если параметры одного типа. А если разного?

И, если говорить о классах, используются так называемые кортежи, когда мы формируем какой-то тип из полей разных типов. В python кортеж - альтернатива и массиву, и структуре. По существу, мы можем рассматривать как массив с элементами разного типа. А зачем это нужно? Мы не всегда знаем то данное, которое нам нужно возвращать, какие параметры могут входить в это данное. Мы можем возвращать список данных, формировать структуру, причем можем, конечно, формировать список данных разного типа. Кортеж - хорошая альтернатива, когда мы объединяем по надобности параметры разного типа в одно данное.

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

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

В данном примере вызываем функцию sum() и передаем в неё 5 параметров. По шаблону, будет вызываться функция с 5 параметрами. Но эта функция с 5 параметрами вызывает функцию sum(), в которую мы передаем 4 параметра. Будет вызываться функция с 4 параметрами, потом с 3, с 2... тут мы должны поставить шаблон/специализацию/перегрузку функций, которые ограничат нам формирование функций по шаблону. В данном случае с одним параметром.

template <typename Type>
Type sum(Type value)
{
	return value;
}

template <typename Type, typename ...Args>
Type sum(Type value, Args... args)
{
	return value + sum(args...);
}

int main()
{
	cout << sum(1, 2, 3, 4, 5) << endl;
	return 0;
}

Обязательно нужно поставить ограничение на создание функций по шаблону! В примере выше это реализовано за счёт шаблона с одним параметром.

Пример шаблона с переменным числом параметров значений

template<size_t...>
struct Sum {};

template<>
struct Sum<>
{
	enum { value = 0 };
};

template<size_t val, size_t... args>
struct Sum<val, args...>
{
	enum { value = val + Sum<args...>::value };
};

int main()
{
	cout << Sum<1, 2, 3, 4>::value << endl;
	return 0;
}

Пример шаблона класса с переменным числом параметров. Рекурсивная реализация кортежа

// Обьявление кортежа (не определение)
template <typename... Types>
class Tuple;

// Специализация со значениями параметров кортежа
template <typename Head, typename... Tail> // Очень интересный синтаксис :)
class Tuple<Head, Tail...>
{
private:
	Head value;
	Tuple<Tail...> tail; // Tail... рассматривается как тип
public:
	Tuple() = default;
	Tuple(const Head& v, const Tuple<Tail...>& t) : value(v), tail(t) {}
	Tuple(const Head& v, const Tail&... tail) : value(v), tail(tail...) {}

	Head& getHead() { return value; }
	const Head& getHead() const { return value; }

	Tuple<Tail...>& getTail() { return tail; }
	const Tuple<Tail...>& getTail() const { return tail; }
};

// Пустая специализация - это ограничитель для рекуррентной реализации кортежа.
template <>
class Tuple<>
{
};

template <size_t N>
struct Get
{
	template <typename Head, typename... Tail>
	static auto apply(const Tuple<Head, Tail...>& t)
	{
		return Get<N - 1>::apply(t.getTail());
	}
};

template <>
struct Get<0>
{
	template <typename Head, typename... Tail>
	static const Head& apply(const Tuple<Head, Tail...>& t)
	{
		return t.getHead();
	}
};

template <size_t N, typename... Types>
auto get(const Tuple<Types...>& t)
{
	return Get<N>::apply(t);
}

size_t count(const Tuple<>&)
{
	return 0;
}

template <typename Head, typename... Tail>
size_t count(const Tuple<Head, Tail...>& t)
{
	return 1 + count(t.getTail());
}

ostream& writeTuple(ostream& os, const Tuple<>&)
{
	return os;
}

template <typename Head, typename... Tail>
ostream& writeTuple(ostream& os, const Tuple<Head, Tail...>& t)
{
	os << t.getHead() << " ";
	return writeTuple(os, t.getTail());
}

template <typename... Types>
ostream& operator<<(ostream& os, const Tuple<Types...>& t)
{
	return writeTuple(os, t);
}

int main()
{
	Tuple<const char*, double, int, char> obj("Pi: ", 3.14, 15, '!');
	cout << get<0>(obj) << get<1>(obj) << get<2>(obj) << get<3>(obj) << endl;
	cout << obj << endl;
	cout << "Count = " << count(obj) << endl;
}

Кортежи используются крайне редко.

С кортежами существуют две проблемы:

  • Рассматривать кортеж как объект нельзя, так как разные типы и определить перегруженные операции над ним невозможно.
  • Чёткое местоположение каждого элемента - нужно помнить, какие типы лежат внутри кортежа.
Clone this wiki locally