JavaRush /Курси /C++ SELF /Шаблони та заголовки

Шаблони та заголовки

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

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 інстанціюють ваш шаблон. Тому шаблонні заголовки особливо потребують акуратності та мінімальних залежностей.

1
Опитування
Header hygiene, рівень 27, лекція 4
Недоступний
Header hygiene
Header hygiene
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ