CodeGym /Kursy /C++ SELF /Szablon klasy na prostym przykładzie

Szablon klasy na prostym przykładzie

C++ SELF
Poziom 55, Lekcja 4
Dostępny

1. Szablon klasy

Jeśli do tej pory żyłeś spokojnie, tworząc Task, User, std::vector<Task> i kilka funkcji wokół — gratulacje, już jesteś programistą: nauczyłeś się powtarzać to samo na różne sposoby. Teraz nauczymy się powtarzać to samo jednym sposobem. Szablon klasy przydaje się, gdy masz wrażenie: „Piszę tę samą klasę, tylko wewnątrz zmienia się typ”.

Wyobraźmy sobie, że rozwijamy naszą konsolową aplikację TaskPilot (mini‑planner zadań). Mamy zadania, projekty, notatki, nawet „przypomnienia o piciu wody” — i u wszystkich podobne operacje: przechowywać listę, dodawać, wyszukiwać, wypisywać.

Jeśli nie jesteś jeszcze pewien, czy tego potrzebujesz — to normalne. Ważna umiejętność: rozumieć nie tylko „jak napisać szablon”, ale i „kiedy on naprawdę ma sens”. Szablon klasy daje moc, ale w zamian wymaga staranności.

Szablon klasy jako szkic

Bardzo łatwo popełnić błąd myślowy i uznać, że Box (lub Repository) — to już gotowy typ. W rzeczywistości Box bez <...> — to jak słowo „kubek” bez doprecyzowania: ceramiczny? papierowy? z kotkiem? Kompilator potrzebuje szczegółów. Szablon klasy — to szkic, a konkretny typ pojawia się dopiero po podstawieniu argumentu szablonu.

Ustalmy terminologię, ale bez przesadnego formalizmu.

Termin Jak wygląda Co to znaczy prostymi słowy
Parametr szablonu
template <typename T>
„Wewnątrz jest nieznany typ T, potem podstawimy”
Argument szablonu
Box<int>
„Podstawiliśmy konkretny typ int zamiast T”
Instancjonowanie
użycie Box<int>
„Kompilator utworzył konkretną wersję klasy dla int”

Mała schematyczna ilustracja, żeby umysł się nie przegrzał:

flowchart TD
    A["template <typename T> class Box { ... }"] --> B["Box<int>"]
    A --> C["Box<std::string>"]
    A --> D["Box<double>"]
    B --> E["Obiekt: Box<int> x;"]
    C --> F["Obiekt: Box<std::string> s;"]

Box<int> i Box<std::string> — to różne typy, tak jak int i std::string. Podobne z wyglądu, ale niekompatybilne.

2. Przykład: Box<T> i Setting<T>

Box<T>: „pudełko na wartość”

Żeby nie zaczynać od razu od skomplikowanej architektury, zaczniemy od zabawkowego, lecz użytecznego przykładu — Box<T>. To klasa, która przechowuje jedną wartość typu T. Przykład wydaje się „zbyt prosty”, ale świetnie pokazuje mechanikę: gdzie pisać template, gdzie pisać <T>, co to są const-metody.

Załóżmy, że w TaskPilot chcemy tymczasowo przechowywać „aktualnie wybrany projekt” (string) i „aktualny filtr priorytetu” (liczba). Możemy zrobić pudełko dla dowolnego typu.

#include <string>

template <typename T>
class Box {
public:
    Box() = default;

private:
    T value_{};
};

Na razie to prawie bezsensowne: wartość jest ukryta, brak metod. Ale klucz już widać: T value_{}; — pole, którego typ zależy od T.

Dodajmy minimalny interfejs: włożyć i pobrać. I od razu zastosujmy wiedzę z poprzednich dni: do odczytu często wygodnie użyć const T&, aby nie kopiować dużych obiektów.

template <typename T>
class Box {
public:
    explicit Box(T value) : value_(value) {}

    const T& get() const { return value_; }
    void set(T value) { value_ = value; }

private:
    T value_{};
};

Teraz w main() wygląda to tak:

#include <iostream>
#include <string>

int main() {
    Box<int> priority{2};
    Box<std::string> project{"Study"};

    std::cout << priority.get() << '\n'; // 2
    std::cout << project.get() << '\n';  // Study
}

Zwróć uwagę: typ podaje się jawnie — Box<int>, Box<std::string>. W przeciwieństwie do funkcji, gdzie kompilator często potrafi wywnioskować T, dla klas w podstawowym wariancie zazwyczaj trzeba napisać typ (o automatycznym wnioskowaniu typu klasy będzie później, ale nie dziś).

Setting<T>: mniej kopiowania i jednolity kontrakt zachowania

Gdy ludzie po raz pierwszy widzą Box<T>, często myślą: „Po co to, skoro można po prostu int priority; i std::string project;?” To uczciwe pytanie.

Korzyść ujawnia się, gdy szablonowa klasa ma wspólne zachowanie, takie samo dla różnych typów. Na przykład chcemy w TaskPilot przechowywać „wartość ustawienia” i wypisywać ją w jednolitym formacie: klucz = wartość.

Zrobimy Setting<T> — pojedyncze ustawienie.

#include <string>
#include <iostream>

template <typename T>
class Setting {
public:
    Setting(std::string name, T value) : name_(std::move(name)), value_(value) {}

    void print() const {
        std::cout << name_ << " = " << value_ << '\n'; // wymaga operator<< dla T
    }

private:
    std::string name_;
    T value_;
};

Użycie:

int main() {
    Setting<int> maxTasks{"max_tasks", 50};
    Setting<std::string> username{"user", "Alice"};

    maxTasks.print();  // max_tasks = 50
    username.print();  // user = Alice
}

Tutaj pojawia się „kontrakt operacji”, podobnie jak w szablonach funkcji: skoro robimy std::cout << value_, to typ T musi być wypisywalny do strumienia.

Szablon klasy dał nam jednolity kod: zamiast dwóch klas IntSetting i StringSetting — mamy jedną Setting<T>.

4. Praktyczny przykład: Repository<T>

Zabawki zabawkami, ale zróbmy coś, co przypomina prawdziwy kod. W TaskPilot mamy encje: zadania, projekty, notatki. Zazwyczaj żyją one w std::vector, i wykonujemy operacje: dodać, znaleźć po id, wypisać.

Jeśli napisać oddzielnie TaskRepository, ProjectRepository, NoteRepository, to w pewnym momencie zauważysz, że masz trzy klasy, które różnią się… praktycznie niczym. I tu szablon jest jak znalazł.

Najpierw ustalmy prosty model zadania (na którym już opieraliśmy się wcześniej):

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Teraz szablon‑repozytorium. Przechowuje std::vector<T>. Przyjmujemy, że T ma pole id typu int. To ważny punkt: szablon nie jest „magiczny dla wszystkich typów”, jest „uniwersalny dla typów zgodnych z kontraktem”.

#include <vector>

template <typename T>
class Repository {
public:
    void add(T item) {
        items_.push_back(std::move(item));
    }

    const T* findById(int id) const {
        for (const auto& x : items_) {
            if (x.id == id) return &x;
        }
        return nullptr;
    }

private:
    std::vector<T> items_;
};

Użycie z Task:

#include <iostream>

int main() {
    Repository<Task> tasks;
    tasks.add(Task{1, "Learn templates", false});
    tasks.add(Task{2, "Drink water", true});

    const Task* t = tasks.findById(2);
    if (t) std::cout << t->title << '\n'; // Drink water
}

Osiągamy bardzo ważny efekt: jeden kod przechowywania i wyszukiwania działa dla różnych encji. Jeśli jutro pojawi się:

struct Project {
    int id{};
    std::string name;
};

to Repository<Project> zadziała od razu — bez duplikowania klasy.

Praktyczny fragment: wypisanie znalezionej encji w TaskPilot

Aby temat nie pozostał „w próżni”, dodajmy do repozytorium małą funkcję wypisującą zadanie. Wypisywanie to nie szablony, ale pomaga zobaczyć, jak szablonowa klasa obsługuje aplikację.

Zrobimy zwykłą funkcję wypisującą Task:

#include <iostream>

void printTask(const Task& t) {
    std::cout << "#" << t.id << " " << t.title;
    std::cout << (t.done ? " [done]\n" : " [todo]\n");
}

Teraz użyjemy wyniku findById:

int main() {
    Repository<Task> tasks;
    tasks.add(Task{1, "Learn templates", false});

    if (const Task* t = tasks.findById(1)) {
        printTask(*t); // #1 Learn templates [todo]
    }
}

Szablonowa klasa zamyka „typową mechanikę przechowywania”, a kod biznesowy (logika zadań) pozostaje zwykły i czytelny.

5. Co komplikuje szablon klasy

Szablon klasy jest jak scyzoryk szwajcarski. Bardzo użyteczny, dopóki nie zdecydujesz się otworzyć puszki trzymając ostrze do siebie. Utrudnienia pojawiają się nie w filozofii, lecz w konkretnych miejscach: składni, niezgodności typów, komunikatów kompilatora.

Różne specjalizacje — różne typy

To wydaje się oczywiste, ale mózg regularnie próbuje „oszczędzić energię” i uznać je za takie same.

Repository<Task> tasks;
Repository<Project> projects;

// tasks = projects; // błąd: różne typy

To nie błąd, to cecha: kompilator chroni cię przed logicznymi pomyłkami.

Nazwa szablonu bez <...> — to nie typ

Początkujący piszą tak:

Repository repo; // błąd: T nie określony

Kompilator nie potrafi odgadnąć, co przechowywać. Trzeba:

Repository<Task> repo;

Tak, to rozwlekłe. Ale uczciwe.

Wewnątrz szablonu niejawnie formułujesz wymagania wobec T

W Repository<T>::findById napisaliśmy x.id. To znaczy, że typ T musi mieć pole .id. Jeśli spróbujesz użyć:

Repository<int> bad;

to kompilator „wybuchnie” przy próbie skompilowania metody, gdzie wymagane jest id. To normalny model: szablon jest kompilowany „w kontekście użycia”, a nie „w próżni”.

Dobrą praktyką jest, gdy piszesz szablon, mentalnie określić „co musi umieć T”.

Metody poza klasą: składnia się rozrasta

Jeśli postanowisz wydzielić definicje metod poza ciałem klasy (tak jak to robisz dla zwykłych klas), zapis staje się bardziej „formalny”. Pokażę minimalny przykład na małej klasie, żeby nie tonąć w kodzie.

template <typename T>
class Box {
public:
    void set(T value);
private:
    T value_{};
};

template <typename T>
void Box<T>::set(T value) {
    value_ = value;
}

Tu pojawiają się od razu dwa „dodatkowe utrudnienia”: trzeba powtórzyć template <typename T>, i trzeba pisać Box<T>::. W małych przykładach to bywa irytujące. W większych — dyscyplinuje.

6. Typowe błędy przy pracy z klasami‑szablonami

Błąd nr 1: traktować szablon jak gotowy typ.
Często pisze się Box b; lub Repository repo; i dziwi, że kompilator narzeka. Szablon to szkic. Typ powstaje dopiero po podstawieniu argumentu: Box<int>, Repository<Task>.

Błąd nr 2: oczekiwać, że Box<int> jest zgodny z Box<double>, bo „oba to Box”.
Dla każdego argumentu szablonu powstaje osobny typ. To nie „jeden klasa z konfiguracją”, a „rodzina klas”. Dlatego przypisywanie między różnymi specjalizacjami jest zabronione i chroni przed mieszaniem logiki.

Błąd nr 3: przypadkowo dodać w szablonie operację, której T nie wspiera.
Na przykład w Setting<T>::print() używamy operator<<. Dla int i std::string to OK, ale dla twojego własnego typu — nie, chyba że zapewnisz odpowiedni operator. Kod szablonowy zaczyna się kompilować dopiero przy użyciu, więc błąd może pojawić się „niespodziewanie” — nie w miejscu deklaracji, lecz w miejscu zastosowania.

Błąd nr 4: zwrócić wskaźnik/referencję do elementu kontenera i potem przechowywać go długo.
W przykładzie findById zwróciliśmy const T* do elementu wewnątrz std::vector. To wygodne, ale niebezpieczne, jeśli zachowasz wskaźnik, a potem wywołasz add() (wektor może się realokować). Bezpieczna praktyka dla początkującego: używać znalezionego wskaźnika od razu, „tu i teraz”, a nie przechowywać go jako długotrwałą zmienną.

Błąd nr 5: panikować z powodu długich komunikatów kompilatora.
Gdy szablonowy typ „nie pasuje” do danego typu, błędy często wyglądają jak ściana tekstu. To normalna cena generycznego kodu. Dobrze jest szukać pierwszej istotnej linii: jakiej operacji kompilator nie potrafi wykonać dla twojego T.

1
Zadanie
C++ SELF, poziom 55, lekcja 4
Niedostępne
Dwa pudełka
Dwa pudełka
1
Zadanie
C++ SELF, poziom 55, lekcja 4
Niedostępne
Ustawienia profilu
Ustawienia profilu
1
Zadanie
C++ SELF, poziom 55, lekcja 4
Niedostępne
Katalog według id
Katalog według id
1
Zadanie
C++ SELF, poziom 55, lekcja 4
Niedostępne
Skład minimaks
Skład minimaks
1
Ankieta/quiz
error_code i polityka błędów, poziom 55, lekcja 4
Niedostępny
error_code i polityka błędów
error_code i polityka błędów
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION