JavaRush /Курси /C++ SELF /Виведення типу та випадки, коли воно не спрацьовує

Виведення типу та випадки, коли воно не спрацьовує

C++ SELF
Рівень 55 , Лекція 3
Відкрита

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).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ