1. Когда копипаста начинает мстить
Представьте, что вы написали полезную функцию: например, находить минимальное из двух значений. Сначала вы делаете это для int. Потом — для double. Потом внезапно выясняется, что такое же нужно для std::string (лексикографически). И вот вы уже живёте в мире, где одна и та же логика размножается как кролики, а баги в ней — как тараканы: их трудно выловить всех сразу.
Начнём с честной копипасты — чтобы почувствовать боль.
#include <iostream>
int min_int(int a, int b) {
return (b < a) ? b : a;
}
double min_double(double a, double b) {
return (b < a) ? b : a;
}
int main() {
std::cout << min_int(10, 3) << '\n'; // 3
std::cout << min_double(2.5, 7.1) << '\n';// 2.5
}
Логика одна и та же. Отличается только тип. А теперь вопрос: если вы завтра захотите поменять правило (например, «если равны — возвращать второй»), вы точно не забудете поменять это во всех копиях?
Шаблоны решают именно эту проблему: одна реализация — много типов.
2. Как работают шаблоны функций
Шаблон — это заготовка на этапе компиляции
Слово “template” переводится примерно как «шаблон/трафарет». Это хорошая метафора: вы описываете форму, а конкретный объект получается, когда вы «наливаете» в эту форму конкретный тип. Важно, что в C++ шаблоны — часть механики компиляции, а не выполнения: во время работы программы «подстановки типов» уже не происходят.
В стандарте C++ шаблоны — настолько базовый механизм, что даже в редакторских отчётах и changelog’ах вы регулярно увидите ссылки на разделы вроде [temp] (то есть “templates”). Это не «библиотечная фича», а фундамент языка.
Самая короткая формула сегодняшней лекции такая:
template <typename T> говорит компилятору: «Сделай мне версию этого кода для того типа, который будет вместо T».
Синтаксис template <typename T>
Выглядит это так: сначала идёт заголовок шаблона, а потом обычная функция, только с “пустым местом” под тип.
Обратите внимание: typename и class в этом месте — почти синонимы, но мы будем писать typename, потому что так принято в современном C++ и так понятнее новичкам (слово прямо намекает: «это имя типа»).
#include <iostream>
template <typename T>
T my_min(T a, T b) {
return (b < a) ? b : a; // здесь мы используем operator<
}
int main() {
std::cout << my_min(10, 3) << '\n'; // 3
std::cout << my_min(2.5, 7.1) << '\n'; // 2.5
}
На этом моменте важно поймать правильную мысль: my_min — это ещё не «готовая» функция. Это описание семейства функций. А конкретные версии появятся, когда вы её вызовете.
Инстанцирование: от my_min<T> к my_min<int>
С точки зрения компилятора происходит примерно такой процесс: вы написали “чертёж” функции, а потом где-то в main() вызвали её для int и double. Компилятор такой: «О, мне нужна версия my_min<int> и версия my_min<double>. Сейчас соберу».
Это удобно представлять так (псевдо-блок-схема):
flowchart TD
A["Вы написали шаблон my_min<T>"] --> B["Вызвали my_min(10, 3)"]
B --> C["Компилятор выводит: T = int"]
C --> D["Генерируется my_min<int>(int,int)"]
A --> E["Вызвали my_min(2.5, 7.1)"]
E --> F["Компилятор выводит: T = double"]
F --> G["Генерируется my_min<double>(double,double)"]
И вот здесь часто случается первое «вау»: у вас в исходнике одна функция, а в результате компиляции — несколько версий.
Почему это важно понимать? Потому что ошибки в шаблонах часто выглядят странно: компилятор ругается так, будто у вас «внезапно появилась функция my_min<какой-то_тип>». Но это не мистика — он реально её сгенерировал.
«Контракт по операциям»: что обязан уметь тип T
Сейчас будет ключевой практический момент, который экономит часы жизни.
Шаблонная функция не обязана работать для любого типа вообще. Она работает только для тех типов, где доступны операции, которые вы используете внутри.
В my_min вы используете <. Значит, тип T обязан поддерживать сравнение через operator<.
Это и есть «контракт по операциям»: не формальный (пока мы не используем concepts), но очень реальный. И в C++ это нормально: вы пишете код, а компилятор проверяет его корректность для конкретного T, когда вы пытаетесь этот код применить.
Посмотрим на мини-антипример.
struct NoLess {
int x{};
};
template <typename T>
T my_min(T a, T b) {
return (b < a) ? b : a; // нужен operator< для T
}
int main() {
NoLess a{1};
NoLess b{2};
// my_min(a, b); // не скомпилируется: NoLess не умеет сравниваться через <
}
Шаблон написан «правильно». Но тип NoLess не подходит. И это не ошибка «шаблонов», это ошибка «выбранного типа для данного шаблона».
3. Передача параметров в шаблонах
На шаблоны иногда смотрят как на «магическую штуку, которая решит всё». Но правила передачи параметров остаются абсолютно обычными. Вы всё ещё выбираете: копировать аргумент или дать доступ к нему.
Здесь полезно иметь маленькую табличку (и держать её в голове, как таблицу умножения — да, скучно, но спасает жизнь):
| Как принимаем параметр | Что это значит | Когда уместно |
|---|---|---|
|
копия | когда тип маленький (int, double) или вам нужна копия |
|
«читаю без копии» | почти всегда по умолчанию для больших типов (std::string, std::vector) |
|
«могу менять аргумент» | когда функция реально изменяет переданный объект |
Сделаем три маленьких шаблонных функции под эти три сценария.
const T&: печатаем значение без лишних копий
Начнём с самого дружелюбного и практичного: функция, которая печатает любой тип, который можно вывести в std::cout.
#include <iostream>
#include <string>
template <typename T>
void print_twice(const T& value) {
std::cout << value << " | " << value << '\n';
}
int main() {
print_twice(42); // 42 | 42
print_twice(std::string("Hi")); // Hi | Hi
}
Опять же: здесь появляется требование к типу — он должен поддерживать вывод в поток (operator<<).
T&: меняем местами два значения
Шаблон swap — классический пример, потому что он буквально показывает: «тип не важен, важны операции копирования/присваивания».
#include <iostream>
template <typename T>
void my_swap(T& a, T& b) {
T tmp = a; // требуется, чтобы T копировался
a = b; // и присваивался
b = tmp;
}
int main() {
int x = 1;
int y = 2;
my_swap(x, y);
std::cout << x << ' ' << y << '\n'; // 2 1
}
И да: my_swap(1, 2) не сработает, потому что 1 и 2 — временные значения, а T& требует «реальный объект, который можно менять».
T по значению: простая арифметика, когда копия дешева
Когда тип маленький и копировать его недорого, можно передавать по значению.
#include <iostream>
template <typename T>
T add(T a, T b) {
return a + b; // требуется operator+
}
int main() {
std::cout << add(10, 20) << '\n'; // 30
std::cout << add(1.5, 2.5) << '\n'; // 4
}
4. Мини-проект: «Мини-магазин» и утилиты
Чтобы это не выглядело как набор разрозненных фокусов, соберём маленькое консольное приложение. Пусть это будет «мини-магазин»: список товаров, печать списка, поиск минимальной цены из двух, и демонстрация перестановки.
Мы специально сделаем так, чтобы шаблоны играли роль утилит, а не «центра вселенной»: это реалистичный стиль современного C++ — шаблоны помогают убрать дублирование и сделать код аккуратнее, но не превращают программу в лабиринт.
Модель товара и печать товара
Начнём с простой структуры Item и научим её печататься. Да, это оператор — но вы уже проходили перегрузку операторов, и нам это здесь нужно как «входной билет» для универсальной печати.
#include <iostream>
#include <string>
struct Item {
std::string name;
double price{};
};
std::ostream& operator<<(std::ostream& out, const Item& item) {
out << item.name << " ($" << item.price << ")";
return out;
}
Шаблонная печать вектора
Теперь пишем шаблонную функцию, которая печатает std::vector<T>. Она будет работать для T = int, T = std::string, T = Item… но только если T умеет печататься в поток.
#include <iostream>
#include <vector>
template <typename T>
void print_list(const std::vector<T>& items) {
for (const T& x : items) {
std::cout << "- " << x << '\n';
}
}
Обратите внимание: мы выбрали const T& в range-for, чтобы не копировать элементы. Это не «шаблонная магия», это обычная аккуратность.
Используем всё это в main()
Соберём маленький сценарий: есть товары, печатаем их, печатаем строки (например, теги), и где-то используем my_min и my_swap.
#include <iostream>
#include <string>
#include <vector>
// Представьте, что здесь уже есть Item, operator<<, my_min, my_swap, print_list
template <typename T>
T my_min(T a, T b) {
return (b < a) ? b : a;
}
template <typename T>
void my_swap(T& a, T& b) {
T tmp = a;
a = b;
b = tmp;
}
int main() {
std::vector<Item> shop{{"Tea", 3.50}, {"Coffee", 5.20}};
print_list(shop);
// - Tea ($3.5)
// - Coffee ($5.2)
std::vector<std::string> tags{"hot", "new"};
print_list(tags);
// - hot
// - new
std::cout << my_min(10, 3) << '\n'; // 3
std::cout << my_min(std::string("b"), std::string("a")) << '\n'; // a
int x = 1, y = 2;
my_swap(x, y);
std::cout << x << ' ' << y << '\n'; // 2 1
}
Да, мы использовали my_min для std::string. Это хороший момент, чтобы проговорить: минимальность строк здесь определяется тем, как работает < для строк (лексикографически). Тип один, логика одна, а «смысл» сравнения определяется тем, что значит < для выбранного типа.
5. Типичные ошибки при первом знакомстве с template <typename T>
Ошибка №1: воспринимать шаблон как «одну функцию, которая умеет всё».
Обычно это приводит к удивлению: «Почему мой шаблон не работает с моим типом?» А он и не обязан. Шаблон — это заготовка, а корректность проверяется для конкретного T. Если внутри есть <, + или <<, значит, тип обязан поддерживать эти операции — иначе компилятор честно откажется собирать.
Ошибка №2: забыть, что T& требует реальный изменяемый объект.
После появления шаблонов некоторые начинают писать my_swap(1, 2) и удивляться. Но это всё та же история, что и без шаблонов: ссылка T& не привязывается к временному значению, потому что функция обещает «могу менять». Если менять нечего (литерал), компилятор вас останавливает раньше, чем вы успеете «успешно сломать реальность».
Ошибка №3: лишние копии из-за T по значению там, где нужен const T&.
Новички часто пишут void print_twice(T value) — и вроде всё работает. А потом вместо int вы передаёте std::string или std::vector, и каждое обращение к функции делает копию. Это не «ошибка шаблонов», это ошибка выбора сигнатуры. Если вы функцию делаете «универсальной», относитесь к типу T как к потенциально тяжёлому и выбирайте const T&, когда вы не собираетесь менять аргумент.
Ошибка №4: удивляться «страшным» сообщениям компилятора.
Как только появляются шаблоны, ошибки начинают выглядеть как небольшая глава из романа. Причина в том, что компилятор показывает цепочку: какой шаблон, с каким T инстанцировался, где именно не подошла операция. Полезная привычка: искать строчку, где сказано, какая операция невозможна (например, no match for operator<), и сопоставлять её с телом шаблона — это почти всегда и есть настоящая причина.
Ошибка №5: пытаться «добавить шаблонность» везде подряд.
Иногда после первой удачной шаблонной функции хочется сделать шаблонным вообще всё: каждую переменную, каждый метод, каждую запятую. На практике это ухудшает читаемость. Шаблоны хороши там, где реально есть повторяемая логика для разных типов. Если у вас логика специфична для одного типа, оставьте её обычной — читатель (в том числе вы через две недели) будет благодарен.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ