1. Як працює шаблон: креслення, а не функція
До цього етапу курсу ми жили за доволі затишним правилом: «оголошення — у заголовок, реалізація — у .cpp». І це правило справді зручне: менше перекомпіляцій, менше сміття в інтерфейсі, менше шансів створити «комбайн» із #include <everything>.
Але шаблони — як той колега, який приходить до офісу й каже: «А давайте все по-іншому, але ж це для вашого блага». Смішно, але факт: у C++ є цілком реальні складнощі, повʼязані з видимістю шаблонних функцій та ефектами, що виникають під час пошуку імен.
Щоб не занурюватися в теорію просто зараз (ми ще до неї повернемося), візьмемо лише одне практичне правило і навчимося з ним жити.
Шаблон (template) — це не «ще одна функція». Це радше креслення функції, за яким компілятор може створити конкретну версію для потрібного типу. Тобто шаблон — це інструкція для компілятора: «Коли натрапиш на використання, підстав тип і згенеруй тіло».
Важливо вловити саме цю думку: код шаблону компілятор «дозбирає» там, де шаблон використовують. Тому шаблон дуже погано переносить ситуацію, коли реалізація лежить «десь окремо», але в місці використання її не видно.
Найпростіший приклад шаблону (і так, це той самий «квадрат», який пережив не одне покоління студентів):
// square.hpp
#pragma once
template <typename T>
T square(T x) {
return x * x;
}
І використання:
#include <iostream>
#include "square.hpp"
int main() {
std::cout << square(5) << '\n'; // 25
std::cout << square(2.5) << '\n'; // 6.25
}
На вигляд це функція, але насправді — радше «фабрика функцій».
2. Правило: тіло шаблону видно в місці використання
Зараз буде формулювання, яке варто виписати на папірець і покласти поруч із клавіатурою — десь біля наліпки «не забудь ;».
Правило: визначення (тіло) шаблону має бути видимим у місці, де цей шаблон використовується.
Чому так? Тому що в момент використання компілятор може вперше зрозуміти, які саме типи треба підставити. А якщо підстановка відбувається саме тут, то й тіло теж має бути саме тут.
Корисний образ для запамʼятовування:
flowchart TD
A["main.cpp побачив square(5)"] --> B[треба зібрати square
]
B --> C{чи видно тіло шаблону?}
C -- так --> D[компілятор генерує код]
C -- ні --> E[нема з чого генерувати: помилка]
Це вся лекція на одній блок-схемі. Далі ми просто застосуємо її до структури файлів.
4. Де зберігати реалізацію: .hpp і .tpp/.inl
Антиприклад: оголосили в .hpp, визначили в .cpp
Зараз ми розглянемо антиприклад не тому, що любимо страждати, а тому, що ви майже напевно хоча б раз так зробите. І краще побачити це один раз у спокійній обстановці, ніж уночі перед дедлайном.
Уявімо, що в нас є навчальний консольний застосунок TaskTracker (у ньому ми ведемо список завдань). Нам потрібна маленька утиліта: перевірити, чи потрапляє значення в діапазон. Наприклад, чи лежить пункт меню в межах від 1 до 5.
Ми створюємо заголовок з оголошенням:
// range.hpp
#pragma once
template <typename T>
bool in_range(T x, T lo, T hi); // тільки оголошення
А реалізацію — у .cpp:
// range.cpp
#include "range.hpp"
template <typename T>
bool in_range(T x, T lo, T hi) {
return lo <= x && x <= hi;
}
І використовуємо в main.cpp:
#include <iostream>
#include "range.hpp"
int main() {
std::cout << in_range(3, 1, 5) << '\n'; // хочемо 1 (true)
}
На рівні людської логіки все виглядає ідеально: «ну ось же .cpp, ось же реалізація». Але компілятор мислить інакше: він компілює main.cpp окремо і саме в цей момент має зібрати in_range<int>. А тіла немає: воно лежить в іншому .cpp, який компілюється окремо, а «телепатичний звʼязок» компіляторові, на жаль, не завезли.
Результат — помилка під час збирання. Вона може виглядати по-різному залежно від компілятора чи IDE, але суть одна: для шаблону неможливо згенерувати конкретну версію, бо там, де його намагаються використати, немає визначення.
Правильний варіант: шаблон повністю в заголовку
Тепер робимо так, як вимагає правило: розміщуємо визначення шаблону там, де воно буде видимим під час #include. Тобто — у .hpp.
Ось робочий варіант:
// range.hpp
#pragma once
namespace app::util {
template <typename T>
bool in_range(T x, T lo, T hi) {
return lo <= x && x <= hi;
}
} // namespace app::util
І використання в нашому main.cpp:
#include <iostream>
#include "range.hpp"
int main() {
const int choice = 3;
std::cout << app::util::in_range(choice, 1, 5) << '\n'; // 1
}
Тут компілятор задоволений: коли він компілює main.cpp, то бачить і оголошення, і тіло in_range, тому може «зібрати» версію in_range<int>.
Самодостатність заголовка
Для шаблонів вимога самодостатності заголовка стає ще суворішою. Якщо тіло шаблону використовує якийсь тип або функцію, потрібний #include має бути у цьому ж заголовку, а не десь «раніше вже підключеним».
Наприклад, якби in_range працював із рядками і ви використовували std::string, то <string> треба було б підключити в range.hpp.
Як не перетворювати .hpp на простирадло: .tpp / .inl
Коли проєкт зростає, заголовок із тілами шаблонів може стати надто довгим. Тоді виникає бажання «винести реалізацію» — і це цілком природно. Просто виносити її треба не в .cpp, а у файл, який все одно підключається із заголовка.
Поширений варіант організації:
- range.hpp — інтерфейс + підключення реалізації
- range.tpp (або range.inl) — тіла шаблонів
Приклад range.hpp:
// range.hpp
#pragma once
namespace app::util {
template <typename T>
bool in_range(T x, T lo, T hi);
}
#include "range.tpp" // важливо: підключаємо реалізацію
Приклад range.tpp:
// range.tpp
#pragma once
namespace app::util {
template <typename T>
bool in_range(T x, T lo, T hi) {
return lo <= x && x <= hi;
}
} // namespace app::util
Сенс простий: фізично реалізація лежить в окремому файлі, але логічно вона все одно лишається «видимою» через #include. Правила дня дотримано.
5. Практика: перевірка меню в TaskTracker
Тепер зробимо невеликий крок у бік практики, не перетворюючи лекцію на «домашнє завдання». Нехай у нашому застосунку є меню: 1 — додати завдання, 2 — показати список, 3 — видалити, 4 — вихід. Ми хочемо перевіряти введення і не дозволяти, скажімо, вибрати пункт 42.
Невеликий фрагмент коду (ідея зрозуміла навіть без повної програми):
#include <iostream>
#include "range.hpp"
int main() {
int choice = 0;
std::cin >> choice;
if (!app::util::in_range(choice, 1, 4)) {
std::cout << "Немає такого пункту меню\n";
}
}
Зверніть увагу: у main.cpp ми не робимо нічого «шаблонного». Уся шаблонність — усередині in_range. Це добрий стиль: main має бути простим, а утиліти — придатними до повторного використання.
Якщо хочеться ще однієї аналогії, то шаблон — це як рецепт страви, у якому інгредієнт «мʼясо» не уточнено. Поки ви не прийшли на кухню й не сказали: «готуємо з куркою», рецепт лишається загальним.
Місце використання — це момент, коли ви кажете: «Гаразд, сьогодні курка». І саме тут кухар, тобто компілятор, має мати під рукою повний рецепт. Якщо в нього є лише заголовок «Зроби щось смачне», а сам рецепт лежить в іншому місці, тобто в .cpp, то вечеря буде… скажімо так, концептуальною.
6. Типові помилки
Помилка № 1: спроба сховати тіло шаблону в .cpp «як звичайну функцію».
Це найпоширеніша пастка: звичка зі світу нешаблонних функцій. Для звичайної функції компілятору достатньо оголошення, а реалізацію можна покласти в .cpp. Для шаблону — ні: компілятор має бачити тіло там, де генерує конкретну версію. Якщо про це забути, проєкт починає «ламатися не одразу», а саме в той момент, коли шаблон уперше справді використовується.
Помилка № 2: шаблонний заголовок не самодостатній і «випадково компілюється».
Із шаблонами неявні залежності особливо підступні. Сьогодні у вас десь раніше підключили <string> — і все працює. Завтра ви змінили порядок #include (або інший файл підключив ваш заголовок першим) — і раптом «std::string не знайдено». Лікується це дисципліною: якщо тип або функція потрібні у визначенні, підключайте їх у цьому ж .hpp.
Помилка № 3: using namespace ...; у заголовку із шаблонами.
Заголовок підключають багато файлів. Якщо він раптом вносить імена в глобальний простір, наслідки будуть дивними, а іноді й дуже смішними — але сміятиметеся не ви, а компілятор. У шаблонних заголовках це ще небезпечніше, бо їх часто підключають «скрізь».
Помилка № 4: надто «розумний» шаблон як частина базового утилітарного шару.
Часто зʼявляється спокуса зробити надто універсальну конструкцію: шаблон на шаблоні, плюс decltype(auto), плюс хитрі прийоми. На цьому етапі курсу це майже завжди сильніше знижує читабельність, ніж приносить користь. Сьогоднішній сенс шаблонів — не в потужності, а в розумінні правила розміщення коду. Усе інше ми свідомо залишаємо на майбутнє.
Помилка № 5: забули, що шаблон компілюється «в місці використання», і отримали помилки там, де не очікували.
Це психологічно неприємний момент: ви правите утиліту в range.hpp, а помилки зʼявляються в пʼяти різних .cpp. Це нормально: саме ці .cpp інстанціюють ваш шаблон. Тому шаблонні заголовки особливо потребують акуратності та мінімальних залежностей.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ