1. Jak działa szablon: plan, a nie funkcja
Do tej pory kursu żyliśmy według dość wygodnej zasady: „deklaracja — w nagłówku, implementacja — w .cpp”. I ta zasada jest naprawdę dobra: mniej rekompilacji, mniej śmieci w interfejsie, mniejsze szanse stworzenia „kombajnu” z #include <everything>.
Ale szablony — to jak ten kolega, który przychodzi do biura i mówi: „Zróbmy wszystko inaczej, ale to dla waszego dobra”. Śmieszne, ale fakt: w C++ są realne trudności związane z widocznością function templates i efektami, które pojawiają się przy wyszukiwaniu nazw.
Aby nie zagłębiać się w teorię (to będzie później), przyjmijmy dokładnie jedną praktyczną zasadę i nauczmy się żyć zgodnie z nią.
Szablon (template) — to nie „jeszcze jedna funkcja”. To raczej plan funkcji, według którego kompilator może utworzyć konkretną wersję dla potrzebnego typu. Innymi słowy szablon — to instrukcja dla kompilatora: „Gdy spotkasz użycie, podstaw typ i zbuduj ciało”.
Ważne jest uchwycenie tej myśli: kod szablonu kompilator „dozbiera” tam, gdzie szablon jest używany. Dlatego szablon bardzo źle znosi sytuację, gdy „gdzieś osobno” leży implementacja, ale w miejscu użycia jej nie widać.
Pierwszy, najmniejszy przykład szablonu (i tak, to ten sam „kwadrat”, który przeżył niejedno pokolenie studentów):
// square.hpp
#pragma once
template <typename T>
T square(T x) {
return x * x;
}
I użycie:
#include <iostream>
#include "square.hpp"
int main() {
std::cout << square(5) << '\n'; // 25
std::cout << square(2.5) << '\n'; // 6.25
}
Wygląda jak funkcja, ale w rzeczywistości to „fabryka funkcji”.
2. Zasada: ciało szablonu musi być widoczne w miejscu użycia
Teraz sformułowanie, które warto zapisać na karteczce i położyć obok klawiatury (obok naklejki „nie zapomnij ;”):
Zasada: definicja (ciało) szablonu musi być widoczna w miejscu, w którym ten szablon jest używany.
Dlaczego? Ponieważ w momencie użycia kompilator może po raz pierwszy zrozumieć, jakie dokładnie typy podstawiać. A skoro podstawienie odbywa się tutaj, to i ciało musi być tutaj.
Przydatny obraz pamięciowy:
flowchart TD
A["main.cpp zobaczył square(5)"] --> B[trzeba zbudować square
]
B --> C{czy ciało szablonu jest widoczne?}
C -- tak --> D[kompilator generuje kod]
C -- nie --> E[nie ma czego generować: błąd]
To — cały wykład w jednej blok‑schemacie. Dalej po prostu „oprzemy” ją na plikach.
4. Gdzie przechowywać implementację: .hpp oraz .tpp/.inl
Antyprzykład: zadeklarowano w .hpp, zdefiniowano w .cpp
Teraz zrobimy antyprzykład nie dlatego, że lubimy cierpieć, lecz dlatego, że na pewno zrobicie to przynajmniej raz w życiu. I lepiej zobaczyć to raz w spokojnych warunkach niż w nocy przed deadlinem.
Wyobraźmy sobie, że mamy edukacyjną aplikację konsolową TaskTracker (prowadzimy listę zadań). W niej chcemy małą pomoc: sprawdzić, czy wartość mieści się w zakresie (np. punkt menu od 1 do 5).
Tworzymy nagłówek z deklaracją:
// range.hpp
#pragma once
template <typename T>
bool in_range(T x, T lo, T hi); // tylko deklaracja
A implementację — w .cpp:
// range.cpp
#include "range.hpp"
template <typename T>
bool in_range(T x, T lo, T hi) {
return lo <= x && x <= hi;
}
I używamy w main.cpp:
#include <iostream>
#include "range.hpp"
int main() {
std::cout << in_range(3, 1, 5) << '\n'; // oczekujemy 1 (true)
}
Na poziomie ludzkiej logiki wszystko wygląda idealnie: „oto .cpp, oto implementacja”. Ale kompilator myśli inaczej: kompiluje main.cpp oddzielnie i w tym momencie musi zbudować in_range<int>. A ciała nie ma — jest w innym .cpp, który kompilowany jest oddzielnie, i „telepatycznej łączności” kompilatorowi nie dostarczono.
Skutek: błąd linkowania/kompilacji (może wyglądać różnie w zależności od kompilatora/IDE), ale sedno jest jedno: szablon nie może zostać zinstancjonowany, bo nie ma definicji tam, gdzie próbujemy go użyć.
Prawidłowy wariant: szablon w całości w nagłówku
Teraz robimy tak, jak wymaga zasada: umieszczamy definicję szablonu tam, gdzie będzie widoczna przy #include. Czyli — w .hpp.
Oto działający wariant:
// 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
I użycie w naszym main.cpp:
#include <iostream>
#include "range.hpp"
int main() {
const int choice = 3;
std::cout << app::util::in_range(choice, 1, 5) << '\n'; // 1
}
Tutaj kompilator jest zadowolony: gdy kompiluje main.cpp, widzi zarówno deklarację, jak i ciało in_range, więc może „zbudować” wersję in_range<int>.
Samowystarczalność nagłówka
W przypadku szablonów samowystarczalność nagłówka staje się surowsza. Jeśli ciało szablonu używa jakiegoś typu/funkcji, wymagany #include powinien znajdować się w tym samym nagłówku, a nie „ktoś wcześniej go włączył”.
Na przykład, gdyby in_range pracował ze stringami i używał std::string, to <string> powinno być dołączone w range.hpp.
Jak nie zrobić z .hpp ściany tekstu: .tpp / .inl
Gdy projekt rośnie, nagłówek z ciałami szablonów może stać się zbyt długi. Pojawia się pokusa „wyciągnięcia implementacji” — i to jest normalne. Trzeba ją jednak przenieść nie do .cpp, lecz do pliku, który i tak będzie dołączony z nagłówka.
Typowy wzorzec (czysto organizacyjny):
- range.hpp — interfejs + dołączenie implementacji
- range.tpp (lub range.inl) — ciała szablonów
Przykład range.hpp:
// range.hpp
#pragma once
namespace app::util {
template <typename T>
bool in_range(T x, T lo, T hi);
}
#include "range.tpp" // ważne: dołączamy implementację
Przykład 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
Sens prosty: fizycznie implementacja znajduje się w osobnym pliku, ale logicznie pozostaje „widoczna” przez #include. Zasada dnia zostaje zachowana.
5. Praktyka: sprawdzanie menu w TaskTracker
Teraz zrobimy krótki krok w stronę praktyki (bez przemieniania wykładu w „zadanie domowe”). Załóżmy, że w naszej aplikacji jest menu: 1 — dodaj zadanie, 2 — pokaż listę, 3 — usuń, 4 — wyjście. Chcemy sprawdzać wpis i nie pozwolić na przykład na opcję 42.
Mini fragment kodu (pomysł zrozumiały nawet bez całego programu):
#include <iostream>
#include "range.hpp"
int main() {
int choice = 0;
std::cin >> choice;
if (!app::util::in_range(choice, 1, 4)) {
std::cout << "Brak takiego elementu menu\n";
}
}
Zauważcie, że nie robimy nic „szablonowego” w main.cpp. Szablonowość — wewnątrz in_range. To dobry styl: main powinien być prosty, a narzędzia — wielokrotnego użytku.
Jeśli lubicie analogie: szablon to jak przepis kulinarny, gdzie składnik „mięso” nie jest określony. Dopóki nie staniecie w kuchni i nie powiecie „dziś kurczak”, przepis pozostaje ogólny.
Miejsce użycia — to moment, kiedy mówicie: „Dobra, dziś kurczak”. I tu kucharz (kompilator) musi mieć pełny przepis. Jeśli ma tylko nagłówek „Zrób coś smacznego”, a sam przepis jest w innym mieście (w .cpp), to obiad będzie... powiedzmy, koncepcyjny.
6. Typowe błędy
Błąd nr 1: próba ukrycia ciała szablonu w .cpp „jak zwykłej funkcji”.
To najczęstsza pułapka: nawyk z świata funkcji bez szablonów. Dla zwykłej funkcji kompilatorowi wystarcza deklaracja, a implementację można umieścić w .cpp. Dla szablonu — nie: kompilator musi widzieć ciało tam, gdzie generuje konkretną wersję. Jeśli o tym zapomnieć, projekt zacznie „psuć się nie od razu”, ale w chwili, gdy szablon faktycznie zostanie użyty.
Błąd nr 2: nagłówek szablonowy nie jest samowystarczalny i „przypadkowo kompiluje się”.
W przypadku szablonów zależności tranzytywne są szczególnie podstępne. Dziś gdzieś wcześniej dołączono <string> — i wszystko działa. Jutro zmieniasz kolejność #include (albo inny plik dołączył twój nagłówek pierwszy) — i nagle „std::string nie znaleziony”. Rozwiązanie to dyscyplina: jeśli typ/funkcja potrzebna jest w definicji, dołącz ją w tym samym .hpp.
Błąd nr 3: using namespace ...; w nagłówku z szablonami.
Nagłówek dołączają wiele plików, i jeśli nagle „wstrzyknie” on nazwy do przestrzeni globalnej, skutki będą dziwne i czasem bardzo zabawne (ale śmiać się nie będziecie wy, a kompilator). W nagłówkach szablonowych jest to jeszcze groźniejsze, bo są one często dołączane „wszędzie”.
Błąd nr 4: zbyt „sprytny” szablon jako część podstawowej warstwy narzędziowej.
Pojawia się pokusa, by zrobić „uniwersalny mega-obiekt”: szablon na szablonie, plus decltype(auto), plus sprytne tricki. Na tym etapie kursu to prawie zawsze obniża czytelność bardziej niż daje korzyści. Obecny sens szablonów to nie siła, lecz zrozumienie zasady umieszczania kodu. Resztę zostawiamy świadomie na później.
Błąd nr 5: zapomnienie, że szablon kompiluje się „w miejscu użycia”, i otrzymanie błędów tam, gdzie ich nie oczekujecie.
To psychologicznie nieprzyjemny moment: poprawiacie narzędzie w range.hpp, a błędy wyskakują w pięciu różnych .cpp. To normalne: te .cpp instancjują wasz szablon. Dlatego nagłówki szablonowe wymagają szczególnej ostrożności i minimalnych zależności.
GO TO FULL VERSION