Шаблони в C++ та перевантаження операторів потоків¶
Введення¶
На попередній лекції ми створили int_vector — контейнер, який працює тільки з типом int. Він має все необхідне: конструктор, деструктор, методи push_back, at, перевантаження operator[].
Але що робити, якщо нам потрібен вектор для double? Або для char? Чи для власної структури point?
Ми мали б дублювати весь код — змінювати лише тип з int на double:
struct double_vector {
double* data;
int size;
int capacity;
double_vector(int initial_capacity = 4) {
data = new double[initial_capacity];
size = 0;
capacity = initial_capacity;
}
// ... remaining methods, but with 'double' instead of 'int'
};
Це досить незручно. Шаблони вирішують цю проблему.
Шаблонні функції¶
Проблема: код дублюється¶
Розглянемо просту функцію, що знаходить максимум:
int my_max(int a, int b) {
if (a > b)
return a;
return b;
}
int main() {
std::cout << my_max(3, 7) << std::endl; // 7
return 0;
}
Вона працює тільки для int. Якщо нам потрібен максимум для double, довелось би писати:
Логіка абсолютно однакова, змінився тільки тип.
Рішення: шаблонна функція¶
Замість того щоб писати функцію для кожного типу, напишемо шаблон:
Ключове слово template<typename T> каже компілятору: "Це схема функції. Замінюй T на будь-який конкретний тип, коли потрібно".
Важливо: коли компілятор бачить виклик шаблонної функції, наприклад my_max(3, 7), він генерує новий код для конкретного типу. Тобто для int компілятор створить функцію int my_max(int a, int b), для double — функцію double my_max(double a, double b) тощо. Це означає, що шаблон — це лише схема, а реальний код генерується під час компіляції.
Синтаксис шаблону функції¶
Формальне визначення синтаксису шаблонної функції:
template<typename T, typename U, ...>
ReturnType function_name(T param1, U param2, ...) {
// function body
}
де:
- template<typename T> — декларація шаблону з одним або більше параметрами типу
- T, U тощо — параметри типу (можна використовувати будь-які імена, але T — найпоширеніший)
- ReturnType — тип повернення (може містити параметри типу: T, std::vector<T> тощо)
- param1, param2 — параметри функції (можуть мати типи, що залежать від параметрів шаблону)
Як це працює під капотом
Коли компілятор зустрічає виклик шаблонної функції, наприклад my_max(3, 7), він:
1. Визначає тип параметрів: T = int
2. Генерує нову функцію з цим типом:
Для кожного унікального типу, для якого використовується шаблон, компілятор створює окремий код. Це називається конкретизацією (instantiation).
Тепер одна функція працює для всіх типів:
int main() {
std::cout << my_max(3, 7) << std::endl; // 7 (T = int)
std::cout << my_max(2.5, 9.1) << std::endl; // 9.1 (T = double)
std::cout << my_max('a', 'z') << std::endl; // z (T = char)
return 0;
}
Компілятор автоматично виводить тип T з аргументів функції.
Явне задання типу¶
Якщо хочемо, можемо явно сказати компілятору, які тип використовувати:
Обмеження шаблонів¶
Не всі типи працюють з усіма шаблонами. Наприклад, в my_max використовується оператор >. Якщо тип його не підтримує, компіляція завершиться з помилкою.
Спробуємо:
struct Point { int x, y; };
Point p1{1, 2};
Point p2{3, 4};
my_max(p1, p2); // Error: operator> not defined for Point
Для власних типів потрібно визначити оператор >.
Перевантаження функцій¶
Перед тим як переходити до шаблонних структур, розглянемо перевантаження функцій — це основа для розуміння того, як компілятор обирає яку функцію викликати.
Поняття перевантаження¶
У C++ можна мати кілька функцій з одним ім'ям, але різними параметрами:
void print(int x) {
std::cout << "int: " << x << std::endl;
}
void print(double x) {
std::cout << "double: " << x << std::endl;
}
void print(const char* str) {
std::cout << "text: " << str << std::endl;
}
int main() {
print(42); // Calls print(int)
print(3.14); // Calls print(double)
print("hello"); // Calls print(const char*)
return 0;
}
Компілятор обирає правильну функцію на основі типу аргументу.
Порядок вибору (overload resolution)¶
- Точний збіг типів — якщо є функція з точно таким типом параметра
- Стандартні перетворення — якщо
intможна автоматично перетворити наdouble - Шаблонна функція — якщо немає точного збігу для звичайної функції
Приклад:
void process(int x) {
std::cout << "int version" << std::endl;
}
template<typename T>
void process(T x) {
std::cout << "template version" << std::endl;
}
int main() {
process(5); // "int version" (exact match)
process(3.14); // "template version" (template)
return 0;
}
Шаблонні структури¶
Узагальнення int_vector¶
На попередній лекції ми написали int_vector. Тепер зробимо його узагальненим для будь-якого типу.
Беремо старий код і замінюємо int на параметр типу T:
#include <iostream>
#include <cassert>
template<typename T>
struct vector {
T* data;
int size;
int capacity;
vector(int initial_capacity = 4) {
data = new T[initial_capacity];
size = 0;
capacity = initial_capacity;
}
~vector() {
delete[] data;
}
void resize(int new_capacity) {
T* new_data = new T[new_capacity];
int to_copy = (size < new_capacity) ? size : new_capacity;
for (int i = 0; i < to_copy; ++i) {
new_data[i] = data[i];
}
delete[] data;
data = new_data;
capacity = new_capacity;
size = to_copy;
}
void push_back(T elem) {
if (size == capacity) {
resize(capacity * 2);
}
data[size] = elem;
++size;
}
T& at(int index) {
assert(index >= 0 && index < size);
return data[index];
}
T& operator[](int index) {
return at(index);
}
};
Використання¶
Тепер можна створювати вектори для будь-якого типу:
int main() {
// vector for int
vector<int> v_int;
v_int.push_back(10);
v_int.push_back(20);
v_int.push_back(30);
std::cout << "v_int[1] = " << v_int[1] << std::endl; // 20
// vector for double
vector<double> v_double;
v_double.push_back(3.14);
v_double.push_back(2.71);
std::cout << "v_double[0] = " << v_double[0] << std::endl; // 3.14
// vector for char
vector<char> v_char;
v_char.push_back('A');
v_char.push_back('B');
v_char.push_back('C');
std::cout << "v_char[2] = " << v_char[2] << std::endl; // C
return 0;
}
Запис vector<int> означає: "Створи варіант структури vector, де T замінено на int". Аналогічно vector<double>, vector<char> тощо.
Під час компіляції компілятор генерує окремий код для кожного типу: один набір методів для vector<int>, інший — для vector<double> тощо. Це дозволяє шаблонам залишатися типобезпечними й ефективними.
Списки ініціалізації для vector¶
Часто хочемо створити вектор і одразу заповнити його значеннями. Можемо додати конструктор, який приймає список:
template<typename T>
struct vector {
// ... previous members ...
// Constructor with initializer list
vector(std::initializer_list<T> init) {
capacity = init.size() * 2; // allocate extra space for future growth
size = init.size();
data = new T[capacity];
// init.begin() returns const T* — a plain pointer to the first element
const T* ptr = init.begin();
for (int j = 0; j < (int)init.size(); ++j) {
data[j] = ptr[j];
}
}
// ... remaining methods ...
};
Тепер можна писати:
vector<int> v = {10, 20, 30, 40};
vector<double> d = {1.5, 2.5, 3.5};
vector<char> c = {'x', 'y', 'z'};
std::cout << v[2] << std::endl; // 30
std::cout << d[1] << std::endl; // 2.5
std::cout << c[0] << std::endl; // x
Це значно зручніше, ніж викликати push_back кілька разів.
Якщо у вас буде два конструктори — з initializer_list і звичайний — компілятор обиратиме правильний:
vector<int> v1(10); // Regular constructor: capacity = 10
vector<int> v2 = {10, 20, 30}; // Initializer list constructor: size = 3
Власні типи та оператор << (виведення)¶
Проблема: як вивести власний тип?¶
Створимо структуру point:
struct point {
int x;
int y;
};
int main() {
point p{10, 20};
std::cout << p << std::endl; // Compilation error!
return 0;
}
Компілятор не знає, як виводити об'єкт типу point. Потрібно визначити оператор <<.
Визначення оператора виведення¶
Оператор << — це функція, яка приймає:
- потік виводу (std::ostream&)
- об'єкт для виведення (const посилання на об'єкт)
Повертає посилання на потік, щоб можна було продовжувати ланцюжок операцій.
std::ostream& operator<<(std::ostream& out, const point& p) {
out << "(" << p.x << ", " << p.y << ")";
return out;
}
Тепер код компілюється:
Як це працює?¶
Вираз std::cout << p еквівалентний:
Функція виводить вміст p у потік std::cout і повертає сам потік. Це дозволяє писати ланцюжки:
Що еквівалентно:
Повна робоча програма¶
#include <iostream>
struct point {
int x;
int y;
};
std::ostream& operator<<(std::ostream& out, const point& p) {
out << "(" << p.x << ", " << p.y << ")";
return out;
}
int main() {
point p1{5, 10};
point p2{15, 20};
std::cout << "p1 = " << p1 << std::endl;
std::cout << "p2 = " << p2 << std::endl;
std::cout << "Both: " << p1 << " and " << p2 << std::endl;
return 0;
}
Вивід:
Оператор >> (введення)¶
Аналогічно можемо визначити оператор для введення:
Зверніть увагу: на відміну від operator<<, тут немає const, бо ми змінюємо об'єкт під час введення.
Приклад використання¶
int main() {
point p;
std::cout << "Введіть координати (x y): ";
std::cin >> p;
std::cout << "Ви ввели точку: " << p << std::endl;
return 0;
}
Сеанс:
Повна програма: vector¶
Тепер об'єднаємо все разом. Маємо:
- Шаблонний вектор — працює з будь-яким типом
- Власний тип
point— з операторами виведення та введення
Це означає, що можемо створити vector<point>:
#include <iostream>
#include <cassert>
#include <initializer_list>
template<typename T>
struct vector {
T* data;
int size;
int capacity;
// Regular constructor
vector(int initial_capacity = 4) {
data = new T[initial_capacity];
size = 0;
capacity = initial_capacity;
}
// Constructor with initializer list
vector(std::initializer_list<T> init) {
capacity = init.size() * 2;
if (capacity < 4) capacity = 4;
size = init.size();
data = new T[capacity];
// init.begin() returns const T* — a plain pointer to the first element
const T* ptr = init.begin();
for (int j = 0; j < (int)init.size(); ++j) {
data[j] = ptr[j];
}
}
~vector() {
delete[] data;
}
void resize(int new_capacity) {
T* new_data = new T[new_capacity];
int to_copy = (size < new_capacity) ? size : new_capacity;
for (int i = 0; i < to_copy; ++i) {
new_data[i] = data[i];
}
delete[] data;
data = new_data;
capacity = new_capacity;
size = to_copy;
}
void push_back(T elem) {
if (size == capacity) {
resize(capacity * 2);
}
data[size] = elem;
++size;
}
T& at(int index) {
assert(index >= 0 && index < size);
return data[index];
}
T& operator[](int index) {
return at(index);
}
};
struct point {
int x;
int y;
};
std::ostream& operator<<(std::ostream& out, const point& p) {
out << "(" << p.x << ", " << p.y << ")";
return out;
}
std::istream& operator>>(std::istream& in, point& p) {
in >> p.x >> p.y;
return in;
}
int main() {
// Vector of points, initialized from a list
vector<point> points = {
{1, 2},
{10, 20},
{5, 5}
};
std::cout << "Points in vector:" << std::endl;
for (int i = 0; i < points.size; ++i) {
std::cout << " points[" << i << "] = " << points[i] << std::endl;
}
// Add a new point
point new_point;
std::cout << "\nEnter a new point (x y): ";
std::cin >> new_point;
points.push_back(new_point);
// Display all points again
std::cout << "\nAll points:" << std::endl;
for (int i = 0; i < points.size; ++i) {
std::cout << " points[" << i << "] = " << points[i] << std::endl;
}
return 0;
}
Приклад виводу:
Points in vector:
points[0] = (1, 2)
points[1] = (10, 20)
points[2] = (5, 5)
Enter a new point (x y): 100 200
All points:
points[0] = (1, 2)
points[1] = (10, 20)
points[2] = (5, 5)
points[3] = (100, 200)
Важливі моменти¶
Типові помилки студентів¶
Чому потрібно писати vector<int>, а не просто vector?
Тому що vector — це шаблон, а не конкретний тип. vector<int> — це конкретний тип, який компілятор створює на основі шаблону.
Чому operator<< повертає std::ostream&?
Щоб можна було писати ланцюжки на кшталт std::cout << p << std::endl. Без повернення потоку був би синтаксис operator<<(std::cout, p); operator<<(std::cout, std::endl); — незручно.
Чому в operator>> немає const?
Тому що під час введення ми змінюємо об'єкт. У operator<< немає змін, тому параметр const.
Чому шаблон не працює з усіма типами однаково?
Тому що всередині шаблону використовуються певні операції. Для my_max потрібен оператор >. Якщо тип його не має, компіляція завершиться з помилкою.
Кілька параметрів шаблону¶
Можна мати кілька параметрів типу:
Але на цій лекції це не обов'язково.
Спеціалізація шаблонів¶
Іноді для конкретного типу потрібна інша реалізація. Це називається спеціалізацією. Наприклад:
// General template
template<typename T>
void print(T x) {
std::cout << x << std::endl;
}
// Specialization for bool
template<>
void print<bool>(bool x) {
std::cout << (x ? "true" : "false") << std::endl;
}
Але це розширена тема, на цій лекції її опускаємо.
Підсумок¶
| Концепція | Синтаксис | Приклад |
|---|---|---|
| Шаблонна функція | template<typename T> T func(T x) { ... } |
template<typename T> T my_max(T a, T b) |
| Шаблонна структура | template<typename T> struct Name { T field; ... } |
template<typename T> struct vector |
| Список ініціалізації | vector<int> v = {10, 20, 30}; |
Конструктор з std::initializer_list<T> |
| Оператор виведення | std::ostream& operator<<(std::ostream&, const T&) |
operator<< для point |
| Оператор введення | std::istream& operator>>(std::istream&, T&) |
operator>> для point |
Шаблони дозволяють писати один раз і використовувати для всіх типів. Перевантаження операторів дозволяють власним типам працювати з потоками вводу/виводу, як вбудовані типи.
Попередня: Динамічний масив | Наступна: Рекурсія