JavaRush /Курсы /C++ SELF /Класс‑шаблон на простом примере

Класс‑шаблон на простом примере

C++ SELF
55 уровень , 4 лекция
Открыта

1. Шаблон класса

Если до этого момента вы жили спокойно, создавая Task, User, std::vector<Task> и пару функций вокруг — поздравляю, вы уже программист: вы научились повторять одно и то же разными способами. А теперь мы будем учиться повторять одно и то же одним способом. Шаблон класса нужен, когда у вас появляется ощущение: «Я пишу один и тот же класс, только тип внутри меняется».

Представим, что мы развиваем наше консольное приложение TaskPilot (мини‑планировщик задач). У нас есть задачи, проекты, заметки, да хоть «напоминания выпить воды» — и у всех похожие операции: хранить список, добавлять, искать, печатать.

Если вы пока не уверены, что вам это надо — это нормально. Важный навык: понимать не только «как написать шаблон», но и «когда он реально уместен». Класс‑шаблон даёт мощь, но просит взамен аккуратность.

Шаблон класса как чертёж

Очень легко ошибиться в мышлении и считать, что Box (или Repository) — это уже готовый тип. На самом деле Box без <...> — это как слово «кружка» без уточнения: керамическая? бумажная? с котиком? Компилятору нужны детали. Шаблон класса — это заготовка, а конкретный тип появляется только после подстановки аргумента шаблона.

Давайте зафиксируем терминологию, но без занудства.

Термин Как выглядит Что это значит человеческим языком
Параметр шаблона
template <typename T>
«Внутри есть неизвестный тип T, потом подставим»
Аргумент шаблона
Box<int>
«Подставили конкретный тип int вместо T»
Инстанцирование
использование Box<int>
«Компилятор создал конкретную версию класса для int»

Небольшая схемка, чтобы мозг не перегревался:

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

Box<int> и Box<std::string> — это разные типы, как int и std::string. Похожи по форме, но несовместимы.

2. Пример: Box<T> и Setting<T>

Box<T>: «коробка для значения»

Чтобы не начинать сразу со сложной архитектуры, начнём с игрушечного, но полезного примера — Box<T>. Это класс, который хранит одно значение типа T. Пример кажется «слишком простым», но он отлично показывает механику: где писать template, где писать <T>, что такое const-методы.

Допустим, в TaskPilot мы хотим временно хранить «текущий выбранный проект» (строка) и «текущий фильтр приоритета» (число). Мы можем сделать коробку под любой тип.

#include <string>

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

private:
    T value_{};
};

Сейчас это почти бесполезно: значение спрятано, методов нет. Но ключ уже виден: T value_{}; — поле, тип которого зависит от T.

Добавим минимальный интерфейс: положить и достать. И сразу применим знания из прошлых дней: для чтения часто удобно const T&, чтобы не копировать большие объекты.

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_{};
};

Теперь в main() это выглядит так:

#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
}

Обратите внимание: тип указывается явноBox<int>, Box<std::string>. В отличие от функций, где компилятор часто может вывести T, для классов в базовом варианте обычно требуется написать тип (про автоматический вывод типа класса будет позже в курсе, но не сегодня).

Setting<T>: меньше копипасты и единый контракт поведения

Когда люди впервые видят Box<T>, они часто думают: «Ну и зачем это, если можно просто int priority; и std::string project;?» И это честный вопрос.

Польза проявляется, когда у шаблонного класса появляется общее поведение, одинаковое для разных типов. Например, мы хотим в TaskPilot хранить «значение настройки» и печатать её в одинаковом формате: ключ = значение.

Сделаем Setting<T> — одну настройку.

#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'; // требует operator<< для T
    }

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

Использование:

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

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

Вот здесь и появляется «контракт по операциям», как в шаблонах функций: раз мы делаем std::cout << value_, значит тип T должен уметь печататься в поток.

Шаблонный класс дал нам единый код: вместо двух классов IntSetting и StringSetting — один Setting<T>.

4. Практический пример: Repository<T>

Игрушки игрушками, но давайте сделаем то, что реально похоже на живой код. В TaskPilot у нас есть сущности: задачи, проекты, заметки. Часто они живут в std::vector, и мы делаем операции: добавить, найти по id, распечатать.

Если писать отдельно TaskRepository, ProjectRepository, NoteRepository, то в какой-то момент вы обнаружите, что у вас три класса, отличающиеся… примерно ничем. И вот тут шаблон — как раз по делу.

Сначала зафиксируем простую модель задачи (мы на неё уже опирались в предыдущих днях курса):

#include <string>

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

Теперь шаблон‑репозиторий. Он хранит std::vector<T>. Мы будем считать, что T имеет поле id типа int. Это важный момент: шаблон не «волшебный для всех типов», он «универсальный для типов, похожих по контракту».

#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_;
};

Использование с 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
}

Мы получили очень важный эффект: один код хранения и поиска работает для разных сущностей. Если завтра появится:

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

то Repository<Project> заработает сразу — без копирования класса.

Практический пример: печать найденной сущности в TaskPilot

Чтобы тема не осталась «в вакууме», давайте добавим к репозиторию маленькую функцию печати задачи. Печать — это не шаблоны, но это помогает увидеть, как шаблонный класс обслуживает приложение.

Сделаем обычную функцию печати Task:

#include <iostream>

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

Теперь используем результат 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]
    }
}

Шаблонный класс закрывает «типовую механику хранения», а бизнес‑код (логика задач) остаётся обычным и читаемым.

5. Что усложняет шаблонный класс

Шаблонный класс — это как швейцарский нож. Очень полезный, пока вы не решили открыть им консервную банку, держа лезвие к себе. Усложнения почти всегда не «в философии», а в конкретных местах: синтаксис, несовместимость типов, сообщения компилятора.

Разные специализации — разные типы

Это кажется очевидным, но мозг регулярно пытается «сэкономить энергию» и считать их одинаковыми.

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

// tasks = projects; // ошибка: разные типы

Это не баг, это фича: компилятор защищает вас от логических ошибок.

Имя шаблона без <...> — не тип

Новички пишут так:

Repository repo; // ошибка: не указан T

Компилятор не может догадаться, что хранить. Нужно:

Repository<Task> repo;

Да, это многословно. Зато честно.

Внутри шаблона вы неявно формулируете требования к T

В Repository<T>::findById мы написали x.id. Значит, тип T обязан иметь .id. Если вы попытаетесь сделать:

Repository<int> bad;

то компилятор «взорвётся» при попытке скомпилировать метод, где требуется id. Это нормальная модель: шаблон компилируется «по факту использования», а не «в вакууме».

Пока мы не уходим глубоко в чтение ошибок шаблонов, полезно иметь привычку: когда вы пишете шаблон, мысленно проговаривайте «что должен уметь T».

Методы вне класса: синтаксиса становится больше

Если вы решите вынести определения методов из тела класса (как вы делали для обычных классов), запись становится более «официальной». Покажу минимальный пример на маленьком классе, чтобы не утонуть в коде.

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

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

Тут сразу две «добавки сложности»: нужно повторить template <typename T>, и нужно писать Box<T>::. На маленьких примерах это просто раздражает. На больших — дисциплинирует.

6. Типичные ошибки при работе с классами‑шаблонами

Ошибка №1: воспринимать шаблон как готовый тип.
Часто пишут Box b; или Repository repo; и удивляются, что компилятор ругается. Шаблон — это заготовка. Тип появляется только после подстановки аргумента: Box<int>, Repository<Task>.

Ошибка №2: ожидать, что Box<int> совместим с Box<double>, потому что «оба Box».
У шаблона класса каждый аргумент создаёт отдельный тип. Это не «один класс с настройкой», а «семейство классов». Поэтому присваивание между разными специализациями запрещено, и это защищает от смешивания логики.

Ошибка №3: случайно добавить в шаблон операцию, которую T не поддерживает.
Например, в Setting<T>::print() мы используем operator<<. Для int и std::string это нормально, а для вашего собственного типа — нет, пока вы не обеспечили печать. Шаблонный код начинает компилироваться только когда вы его используете, поэтому ошибка может «вылезти» неожиданно — не в месте объявления, а в месте применения.

Ошибка №4: возвращать указатель/ссылку на элемент контейнера и потом хранить его где-то надолго.
В примере findById мы вернули const T* на элемент внутри std::vector. Это удобно, но опасно, если вы сохраните указатель и потом сделаете add() (вектор может перераспределить память). Безопасная привычка для новичка: использовать найденный указатель сразу, «здесь и сейчас», а не хранить его как долгоживущую переменную.

Ошибка №5: паниковать из-за длинных сообщений компилятора.
Когда шаблонный класс «не подходит» для типа, ошибки часто выглядят как простыня текста. Это обычная цена обобщённого кода. Правильный подход — искать первую содержательную строчку: какую операцию компилятор не смог выполнить для вашего T.

1
Задача
C++ SELF, 55 уровень, 4 лекция
Недоступна
Две коробки
Две коробки
1
Задача
C++ SELF, 55 уровень, 4 лекция
Недоступна
Настройка профиля
Настройка профиля
1
Задача
C++ SELF, 55 уровень, 4 лекция
Недоступна
Каталог по id
Каталог по id
1
Опрос
error_code и политика ошибок, 55 уровень, 4 лекция
Недоступен
error_code и политика ошибок
error_code и политика ошибок
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ