1. Виведення типу: що це і навіщо воно потрібне
Коли ви дивитеся на шаблони очима новачка, виникає цілком природна думка: «я ж не для того вчив template, щоб тепер усюди вручну писати <int>». І ви маєте рацію: у більшості випадків компілятор справді вміє сам виводити тип T з аргументів виклику.
Але трапляються й ситуації, коли компілятор чесно розводить руками: «не розумію, який саме T ви хочете». І саме тут починається типова проблема: шаблон начебто простий, а повідомлення про помилку виглядає як роман на 10 сторінок.
Технічно це називається template argument deduction — виведення аргументів шаблону. У звітах робочої групи зі стандарту регулярно зʼявляються теми на кшталт «class template deduction» і «deduction failure», тобто це настільки важливе питання, що його роками шліфують навіть на рівні стандарту.
Сформуймо зрозумілу модель, щоб ви могли:
- заздалегідь передбачати, чи виведеться T, чи ні;
- швидко виправляти ситуації, коли «не вивелося», за допомогою явного <T>;
- розуміти, чому T& — це не просто «посилання», а строгий контракт.
2. Базова модель: звідки компілятор бере T
Уявіть, що компілятор — це детектив, якому ви дали шаблон і виклик. Його завдання — підібрати такий T, щоб параметри функції «збіглися» з аргументами. Він не намагається «вгадати сенс», а просто зіставляє форми типів. Якщо зіставлення однозначне — чудово. Якщо можна вивести кілька варіантів — виникає конфлікт. Якщо ж виводити взагалі немає звідки, компілятор попросить вас явно вказати тип.
Почнімо з найпростішого випадку: один параметр — один аргумент, усе очевидно.
#include <iostream>
template <typename T>
T twice(T x) {
return x + x;
}
int main() {
std::cout << twice(21) << '\n'; // 42
std::cout << twice(1.5) << '\n'; // 3
}
Тут компілятор бачить: twice(21) — отже T = int, а twice(1.5) — отже T = double.
Важливо: виведення відбувається за аргументами виклику, а не за тим, куди ви потім присвоюєте результат. Це поширена пастка: «я присвоюю в double, отже T має стати double». Але так це не працює.
3. Один T, але аргументи різних типів
Тепер переходимо до найпоширенішої ситуації «не вивелося». Проблема виглядає так: один T використовується у двох параметрах, але ви передали різні типи. Компілятор намагається розвʼязати рівняння:
- з першого аргументу виходить T = int
- з другого аргументу виходить T = double
- а T має бути один
Спойлер: компілятор не вміє бути дипломатом.
#include <iostream>
template <typename T>
T add_same(T a, T b) {
return a + b;
}
int main() {
std::cout << add_same(1, 2) << '\n'; // 3
// std::cout << add_same(1, 2.5) << '\n';
// помилка: не вдалося вивести T (один аргумент int, інший double)
}
Це не «дурість компілятора», а захист від неоднозначності. Якби він «просто вибрав double», одразу постало б питання: а чому не long double? А чому не «тип побільше»? А чому не «тип першого аргументу»? У C++ такі речі не залишають на здогадки.
Як це виправити? Є два чесні варіанти: або самостійно привести аргументи до одного типу, або явно вказати, який T ви хочете.
#include <iostream>
template <typename T>
T add_same(T a, T b) {
return a + b;
}
int main() {
std::cout << add_same<double>(1, 2.5) << '\n'; // 3.5
}
Тут ми прямо кажемо: «рахуй у double». І тепер компілятор уже має право привести 1 до double, бо T задано.
4. Коли T немає звідки вивести
Досить неприємний випадок: ви написали шаблонну функцію, але її параметри не містять T. Тоді компілятор дивиться на виклик і думає: «аргументів немає, зіставляти нічого». Тобто виведення типу неможливе за означенням.
Найпоказовіший приклад:
#include <iostream>
template <typename T>
T make_zero() {
return T{};
}
int main() {
std::cout << make_zero<int>() << '\n'; // 0
std::cout << make_zero<double>() << '\n'; // 0
}
Якщо ви спробуєте написати make_zero() без <int>, компілятор не зможе здогадатися: ви хочете int, double, std::string чи, може, std::vector<int> (у якого теж є {})?
Є і схожа пастка: коли T є лише в типі повернення, але не в параметрах.
template <typename T>
T read_value(); // припустімо, хочемо читати з cin
int main() {
// auto x = read_value(); // помилка: T не виводиться (виклик без підказок)
}
Запамʼятайте практичне правило: тип шаблону виводиться з аргументів виклику. Немає аргументів — немає і виведення. Тому в таких функціях <T> зазвичай обовʼязковий.
5. Посилання як контракт: чому T& не приймає тимчасові значення
Досі все зводилося до питання «який T вибрати». Тепер буде інша категорія проблем: T начебто можна вивести, але аргумент не можна привʼязати до параметра, бо ви вибрали форму параметра T&.
Тут важливо не думати «посилання — це складно», а побачити просту ідею. T& означає: «я беру обʼєкт, який можна змінювати, і буду з ним щось робити». А тимчасове значення (наприклад, 1 або a + b) — це не «обʼєкт, який можна змінювати», а результат обчислення, що живе дуже недовго.
#include <iostream>
template <typename T>
void increment(T& x) {
++x;
}
int main() {
int a = 10;
increment(a);
std::cout << a << '\n'; // 11
// increment(1);
// помилка: не можна привʼязати тимчасове значення 1 до T&
}
І ось тут багато хто починає «ритуальний танець»: змінює все підряд, аж поки код не скомпілюється. Але краще міркувати так: якщо increment змінює аргумент, то аргумент обовʼязково має бути змінною.
Якщо ж ви не хочете змінювати аргумент, майже завжди краще використовувати параметр const T&. Він приймає і змінні, і тимчасові значення.
#include <iostream>
template <typename T>
void print_twice(const T& value) {
std::cout << value << " | " << value << '\n';
}
int main() {
print_twice(42); // 42 | 42
}
Це здається дрібницею, але на практиці саме вибір між T& і const T& розвʼязує половину шаблонних ситуацій із «не компілюється».
6. Підступні аргументи та швидка діагностика виведення
Далі — блок, де помилки особливо люблять зʼявлятися зненацька. Гарна новина: у таких випадках проблема не у вас, а в тому, що деякі аргументи в C++ поводяться по-особливому.
Спискова ініціалізація {...}
Фігурні дужки {1, 2, 3} — це не завжди «обʼєкт типу vector». Іноді це просто список ініціалізації, у якого немає одного очевидного типу, доки ви не скажете, куди саме його покласти.
#include <iostream>
template <typename T>
void print_one(const T& x) {
std::cout << x << '\n';
}
int main() {
// print_one({1, 2, 3});
// помилка: компілятор не розуміє, що таке T для {1,2,3}
}
Висновок: {...} часто потребує явного контексту (наприклад, std::vector<int>{1,2,3}), інакше T просто «нема за що зачепити».
nullptr і шаблони з вказівниками
Ви вже знаєте, що nullptr можна присвоїти будь-якому вказівнику. Але виведення шаблонного параметра — це не «присвоювання»: тут правила суворіші.
#include <iostream>
template <typename T>
void reset_ptr(T* p) {
p = nullptr; // локальна копія вказівника, зовні нічого не зміниться
}
int main() {
int* p = nullptr;
reset_ptr(p); // T = int, усе гаразд
// reset_ptr(nullptr);
// помилка: T не можна вивести з nullptr (це std::nullptr_t, а не T*)
}
Щоб викликати з nullptr, потрібно або дати змінну-вказівник, або явно вказати тип:
reset_ptr<int>(nullptr); // тепер T задано, можна перетворити nullptr -> int*
Рядкові літерали "hello": масив чи вказівник
Це саме той момент, коли C++ показує свою спадщину з минулого. Рядковий літерал "hi" — це не std::string, і навіть не const char* як «початковий тип». Формально це масив символів. А далі вступають у гру правила перетворення масиву на вказівник (decay), які ви вже бачили в темі про масиви.
Через це один і той самий аргумент може по-різному виводити T залежно від форми параметра.
#include <iostream>
template <typename T>
void show_type_by_value(T x) {
(void)x;
std::cout << "за значенням\n"; // за значенням
}
template <typename T>
void show_type_by_ref(T& x) {
(void)x;
std::cout << "за посиланням\n"; // за посиланням
}
int main() {
show_type_by_value("hi"); // T виведеться так, що x стане вказівником (decay)
// show_type_by_ref("hi"); // помилка: літерал не можна привʼязати до НЕ-const T&
}
Якщо зробити const T&, то посилання зможе привʼязатися і до літерала, і T буде ближчим до «справжньої форми» аргументу, аж до масиву. Це не те, що вам потрібно зазубрювати, але корисно знати: літерали й масиви — аргументи із сюрпризами.
Явне зазначення T: коли це нормально
Багато новачків сприймають func<int>(...) як «поразку»: мовляв, раз компілятор не вивів тип, отже шаблон поганий. На практиці це звичайний інструмент дизайну. Іноді тип справді неможливо коректно вивести, а іноді ви просто хочете керувати тим, до якого типу будуть приведені аргументи.
Наприклад, функція «прочитати значення з консолі» цілком логічно запитує: «який тип читаємо?». І це нормально виражати через <T>.
#include <iostream>
#include <string_view>
template <typename T>
T read_from_stdin(std::string_view prompt) {
std::cout << prompt;
T value{};
std::cin >> value;
return value;
}
int main() {
int age = read_from_stdin<int>("Вік? "); // введення: 20
double price = read_from_stdin<double>("Ціна? "); // введення: 19.99
std::cout << age << '\n'; // 20
std::cout << price << '\n'; // 19.99
}
Зверніть увагу на важливий момент: у функції є параметр prompt, але T з нього вивести не можна, бо там немає T. Тому <int> і <double> тут — не «милиця», а частина інтерфейсу: ви обираєте тип результату.
Швидкий чекліст: як зрозуміти «не вивелося»
Коли ви бачите помилку компіляції на кшталт «no matching function» або «couldn’t deduce template parameter», корисно не панікувати, а швидко пройтися коротким чеклістом. Він звучить майже як блок-схема: «звідки береться T і чому його там може не бути».
flowchart TD
A[Виклик шаблонної функції] --> B{Є аргументи, де трапляється T?}
B -- Ні --> C["Не можна вивести T: потрібне func<T>(...) або слід змінити сигнатуру"]
B -- Так --> D{Аргументи дають один і той самий T?}
D -- Ні --> E[Конфлікт: різні типи -> явний <T> або привести аргументи]
D -- Так --> F{Параметр T& вимагає змінюваний обʼєкт?}
F -- Так, а передали тимчасове --> G[Зробити const T& або передати змінну]
F -- Усе гаразд --> H[Виведення пройшло, а проблема, якщо вона є, — уже в тілі шаблону]
7. Приклад: шаблонні утиліти в «Expense Tracker»
Щоб шаблони не лишалися абстрактною математикою «про T», давайте акуратно застосуємо це в маленькому консольному застосунку. Нехай у нас буде найпростіший трекер витрат: додаємо покупки й виводимо дані.
Зробімо мінімальну модель і додамо один запис (без складних меню — ми тут заради шаблонів, а не UX).
#include <iostream>
#include <string>
#include <vector>
struct Expense {
std::string title;
double amount{};
};
int main() {
std::vector<Expense> expenses;
expenses.push_back(Expense{"Кава", 3.50});
std::cout << expenses[0].title << ": " << expenses[0].amount << '\n'; // Кава: 3.5
}
Тепер додамо дві шаблонні утиліти, дуже типові для реального коду.
Перша — read_from_stdin<T>(): вона демонструє випадок, коли T не виводиться, і це нормально.
#include <iostream>
#include <string_view>
template <typename T>
T read_from_stdin(std::string_view prompt) {
std::cout << prompt;
T value{};
std::cin >> value;
return value;
}
Друга — clamp_value: вона демонструє випадок, коли T не виводиться зі змішаних типів, і це теж нормально.
template <typename T>
T clamp_value(T x, T lo, T hi) {
if (x < lo) return lo;
if (hi < x) return hi;
return x;
}
А тепер використаймо це в main так, щоб одразу побачити обидві категорії проблем і способи їх розвʼязання.
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
struct Expense {
std::string title;
double amount{};
};
template <typename T>
T read_from_stdin(std::string_view prompt) {
std::cout << prompt;
T value{};
std::cin >> value;
return value;
}
template <typename T>
T clamp_value(T x, T lo, T hi) {
if (x < lo) return lo;
if (hi < x) return hi;
return x;
}
int main() {
std::vector<Expense> expenses;
std::string title;
std::cout << "Назва? ";
std::cin >> title;
double amount = read_from_stdin<double>("Сума? "); // введення: 1000
amount = clamp_value(amount, 0.0, 100000.0);
expenses.push_back(Expense{title, amount});
std::cout << "Додано: " << expenses.back().title << '\n'; // Додано: <title>
}
Тут корисно помітити, що read_from_stdin<double> нам доводиться писати з <double>, бо інакше T немає звідки вивести: у параметрах немає T, там лише std::string_view.
А от clamp_value виводить T з аргументів, бо T прямо стоїть у параметрах функції.
Якби ми випадково написали clamp_value(amount, 0, 100000.0), то отримали б конфлікт: один аргумент має тип int, а інші — double. І це не баг, а саме той випадок, коли «один T не може бути двома типами водночас».
8. Типові помилки
Помилка №1: очікувати, що компілятор «виведе T за типом змінної ліворуч».
Часто це здається логічним: якщо я пишу double x = add_same(1, 2.5);, то T має стати double. Але виведення типу працює за аргументами виклику, а не за місцем присвоювання. Це виправляється або приведенням аргументів, або явним add_same<double>(...).
Помилка №2: використовувати T& «за звичкою», а потім дивуватися, що не приймаються літерали й тимчасові значення.
T& — це контракт «я буду змінювати обʼєкт», тому туди не можна передавати 1, a + b або рядковий літерал "hi". Якщо функція лише читає, майже завжди правильніше використовувати const T&: він приймає і змінні, і тимчасові значення та загалом поводиться дружніше.
Помилка №3: намагатися викликати шаблон без аргументів і без <T>, а потім довго дивитися на помилку “couldn’t deduce template parameter”.
Якщо у функції немає параметрів або T трапляється лише в типі повернення, компілятор не вміє вгадувати T. Це не «складність шаблонів», а логіка виведення: виводити просто немає звідки. Отже, потрібно писати make_zero<int>(), read_from_stdin<double>(...) і так далі.
Помилка №4: змішувати типи аргументів у функції, де один T використовується кілька разів, і сподіватися на автоматичне приведення.
Виклик add_same(1, 2.5) виглядає природно по-людськи («ну склади числа»), але з погляду виведення шаблону це суперечність: T має бути один. Розвʼязання — привести аргументи до одного типу або явно вказати T, щоб приведення стало допустимим.
Помилка №5: передавати nullptr у шаблон із T* і дивуватися, що T не виводиться.
nullptr можна привести до будь-якого вказівника, але «вивести T» з нього не можна, бо це не вказівник на конкретний T. Якщо потрібен саме виклик із nullptr, зазвичай або передають змінну-вказівник, або пишуть явне func<int>(nullptr).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ