⚙️ C++

Паттерны проектирования в C++: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты строишь дом. У тебя есть два пути:

  • Путь первый (как новичок): Ты начинаешь с нуля — сам замешиваешь бетон, сам пилишь доски, сам придумываешь, как соединять стены. Каждый новый дом ты строишь заново, каждый раз изобретая велосипед. Если в первом доме ты сделал кривую крышу, во втором — повторишь ту же ошибку.
  • Путь второй (как профессионал): Ты используешь проверенные чертежи. Для фундамента — типовой проект «Ленточный фундамент». Для крыши — «Двускатная крыша с опорными балками». Ты не придумываешь их заново, а просто применяешь готовые решения, которые уже доказали свою надёжность.

Паттерны проектирования (Design Patterns) — это и есть такие «типовые чертежи» для программиста. Это не готовый код, а проверенные схемы решений для часто встречающихся проблем. Они родились не вчера — их собирали и описывали лучшие программисты мира на протяжении десятилетий.

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

Зачем они нужны школьнику или новичку? Представь, что ты пишешь игру. У тебя есть герой, монстры, оружие. Если ты просто начнёшь писать код «как пойдёт», то через неделю ты запутаешься: где создаётся монстр? Как герой получает урон? Почему при добавлении нового вида оружия всё ломается? Паттерны дают тебе готовую «архитектуру» — ты знаешь, что монстров создаёт Фабрика, а герой получает урон через Наблюдателя. Код становится понятным, как инструкция к Lego.

Теория: как это работает изнутри

В мире программирования существует 23 классических паттерна, описанных в книге «Банды четырёх» (Gang of Four). Они делятся на три группы:

Группа Что делает Примеры
Порождающие Решают, как создавать объекты. Скрывают сложность создания. Одиночка, Фабрика, Строитель
Структурные Решают, как собирать объекты в большие структуры. Адаптер, Декоратор, Компоновщик
Поведенческие Решают, как объекты общаются друг с другом. Стратегия, Наблюдатель, Команда

Как это работает изнутри? Паттерн — это всегда про связи между классами. Вместо того чтобы жёстко привязывать один класс к другому (например, класс Игрок напрямую создаёт класс Меч), мы вводим прослойку. Эта прослойка (интерфейс, абстрактный класс) позволяет заменять части системы, не ломая остальное.

Например, паттерн Стратегия говорит: «Вынеси изменяющееся поведение в отдельный класс и передавай его как параметр». Внутри это выглядит так: есть интерфейс СпособАтаки, у него есть реализации АтакаМечом и АтакаЛуком. Игрок просто хранит ссылку на СпособАтаки и вызывает метод атаковать(). Хочешь сменить оружие? Просто подставь другую стратегию.

Синтаксис в C++: все формы и варианты

В C++ паттерны реализуются через стандартные механизмы языка: классы, наследование, виртуальные функции, шаблоны и умные указатели. Рассмотрим ключевые элементы, которые понадобятся для любого паттерна.

1. Абстрактный класс (интерфейс)

Это «скелет» будущего объекта. В C++ нет ключевого слова interface, как в Java, поэтому роль интерфейса играет класс, в котором все методы — чисто виртуальные (= 0).

// Абстрактный класс — нельзя создать его объект, можно только унаследоваться
class Оружие {
public:
    // Чисто виртуальная функция — потомок обязан её реализовать
    virtual void атаковать() = 0;
    // Виртуальный деструктор — обязателен для правильного удаления через указатель на базовый класс
    virtual ~Оружие() = default;
};

2. Наследование и переопределение

Классы-наследники обязаны реализовать все чисто виртуальные методы.

class Меч : public Оружие {
public:
    void атаковать() override {  // override — проверяет, что мы действительно переопределяем метод
        std::cout << "Взмах мечом!" << std::endl;
    }
};

class Лук : public Оружие {
public:
    void атаковать() override {
        std::cout << "Выстрел из лука!" << std::endl;
    }
};

3. Полиморфизм через указатели

Главная сила паттернов — мы работаем с базовым типом, а реальный объект определяется в рантайме.

Оружие* моеОружие = new Меч();
моеОружие->атаковать();  // Выведет: "Взмах мечом!"

// Меняем поведение на лету
моеОружие = new Лук();
моеОружие->атаковать();  // Выведет: "Выстрел из лука!"

delete моеОружие;  // Не забываем удалять!

4. Умные указатели (Smart Pointers)

В современном C++ (C++11 и новее) мы избегаем ручного new/delete. Используем std::unique_ptr и std::shared_ptr. Это автоматически управляет памятью.

#include <memory>

std::unique_ptr<Оружие> создатьОружие(const std::string& тип) {
    if (тип == "меч") {
        return std::make_unique<Меч>();
    } else if (тип == "лук") {
        return std::make_unique<Лук>();
    }
    return nullptr;
}

// Использование
auto ор = создатьОружие("меч");
ор->атаковать();  // Память освободится автоматически при выходе из области видимости

5. Шаблоны (Templates)

Позволяют писать паттерны, работающие с любыми типами данных. Например, паттерн Одиночка можно сделать шаблонным.

template <typename T>
class Одиночка {
public:
    static T& получитьЭкземпляр() {
        static T экземпляр;  // Создаётся один раз при первом вызове
        return экземпляр;
    }
    // Запрещаем копирование
    Одиночка(const Одиночка&) = delete;
    Одиночка& operator=(const Одиночка&) = delete;
protected:
    Одиночка() = default;  // Конструктор защищён — только наследники могут создать
};

Примеры кода — от простого к сложному

Пример 1: Стратегия (Strategy) — простой калькулятор

Позволяет менять алгоритм вычислений на лету.

#include <iostream>
#include <memory>

// 1. Интерфейс стратегии
class Операция {
public:
    virtual int выполнить(int a, int b) = 0;
    virtual ~Операция() = default;
};

// 2. Конкретные стратегии
class Сложение : public Операция {
public:
    int выполнить(int a, int b) override {
        return a + b;
    }
};

class Вычитание : public Операция {
public:
    int выполнить(int a, int b) override {
        return a - b;
    }
};

class Умножение : public Операция {
public:
    int выполнить(int a, int b) override {
        return a * b;
    }
};

// 3. Контекст — использует стратегию
class Калькулятор {
private:
    std::unique_ptr<Операция> операция_;  // Храним текущую стратегию
public:
    void установитьОперацию(std::unique_ptr<Операция> оп) {
        операция_ = std::move(оп);
    }
    int вычислить(int a, int b) {
        if (!операция_) {
            throw std::runtime_error("Операция не установлена!");
        }
        return операция_->выполнить(a, b);
    }
};

int main() {
    Калькулятор кальк;
    
    // Используем сложение
    кальк.установитьОперацию(std::make_unique<Сложение>());
    std::cout << "5 + 3 = " << кальк.вычислить(5, 3) << std::endl;
    
    // Меняем на умножение
    кальк.установитьОперацию(std::make_unique<Умножение>());
    std::cout << "5 * 3 = " << кальк.вычислить(5, 3) << std::endl;
    
    return 0;
}

Объяснение: Мы отделили алгоритмы (сложение, вычитание) от класса, который их использует. Теперь можно добавлять новые операции (например, деление), не меняя класс Калькулятор.

Пример 2: Наблюдатель (Observer) — уведомления

Один объект (издатель) оповещает множество подписчиков об изменениях.

#include <iostream>
#include <vector>
#include <memory>
#include <algorithm>

// 1. Интерфейс наблюдателя
class Наблюдатель {
public:
    virtual void обновить(const std::string& сообщение) = 0;
    virtual ~Наблюдатель() = default;
};

// 2. Конкретные наблюдатели
class Пользователь : public Наблюдатель {
private:
    std::string имя_;
public:
    Пользователь(const std::string& имя) : имя_(имя) {}
    void обновить(const std::string& сообщение) override {
        std::cout << "[" << имя_ << "] Получено: " << сообщение << std::endl;
    }
};

// 3. Издатель (субъект)
class НовостнаяЛента {
private:
    std::vector<std::shared_ptr<Наблюдатель>> подписчики_;
public:
    void подписаться(std::shared_ptr<Наблюдатель> н) {
        подписчики_.push_back(н);
    }
    void отписаться(std::shared_ptr<Наблюдатель> н) {
        // Удаляем наблюдателя из списка
        подписчики_.erase(
            std::remove(подписчики_.begin(), подписчики_.end(), н),
            подписчики_.end()
        );
    }
    void опубликовать(const std::string& новость) {
        for (auto& подписчик : подписчики_) {
            подписчик->обновить(новость);
        }
    }
};

int main() {
    НовостнаяЛента лента;
    
    auto анна = std::make_shared<Пользователь>("Анна");
    auto борис = std::make_shared<Пользователь>("Борис");
    
    лента.подписаться(анна);
    лента.подписаться(борис);
    
    лента.опубликовать("Вышла новая версия C++23!");
    // [Анна] Получено: Вышла новая версия C++23!
    // [Борис] Получено: Вышла новая версия C++23!
    
    лента.отписаться(анна);
    лента.опубликовать("Обновление компилятора");
    // [Борис] Получено: Обновление компилятора
    
    return 0;
}

Объяснение: Издатель не знает, кто именно на него подписан. Он просто шлёт сообщения всем, кто есть в списке. Это основа для событий, чатов, игровых движков.

Пример 3: Одиночка (Singleton) — глобальный доступ

Гарантирует, что у класса есть только один экземпляр.

#include <iostream>
#include <mutex>

class Конфигурация {
private:
    // Приватный конструктор — никто снаружи не может создать объект
    Конфигурация() {
        // Загружаем настройки из файла
        уровеньГромкости_ = 75;
        разрешениеЭкрана_ = "1920x1080";
    }
    
    // Запрещаем копирование
    Конфигурация(const Конфигурация&) = delete;
    Конфигурация& operator=(const Конфигурация&) = delete;
    
    int уровеньГромкости_;
    std::string разрешениеЭкрана_;
    static std::mutex мьютекс_;  // Для потокобезопасности
    
public:
    // Статический метод для доступа к единственному экземпляру
    static Конфигурация& получитьЭкземпляр() {
        static Конфигурация экземпляр;  // Магия C++11 — создаётся один раз, даже в многопоточке
        return экземпляр;
    }
    
    void показатьНастройки() const {
        std::lock_guard<std::mutex> замок(мьютекс_);
        std::cout << "Громкость: " << уровеньГромкости_ << std::endl;
        std::cout << "Разрешение: " << разрешениеЭкрана_ << std::endl;
    }
    
    void установитьГромкость(int громкость) {
        std::lock_guard<std::mutex> замок(мьютекс_);
        уровеньГромкости_ = громкость;
    }
};

std::mutex Конфигурация::мьютекс_;

int main() {
    // Получаем доступ к настройкам из любой точки программы
    Конфигурация::получитьЭкземпляр().показатьНастройки();
    
    // Меняем громкость
    Конфигурация::получитьЭкземпляр().установитьГромкость(50);
    
    // Проверяем, что изменения видны везде
    Конфигурация::получитьЭкземпляр().показатьНастройки();
    
    return 0;
}

Объяснение: static Конфигурация экземпляр внутри функции — это магическая статика (Meyer's Singleton). Она гарантирует, что объект создастся только один раз, даже если несколько потоков одновременно вызовут эту функцию.

Пример 4: Фабричный метод (Factory Method) — создание объектов

Позволяет создавать объекты, не указывая их конкретный тип.

#include <iostream>
#include <memory>
#include <string>

// Продукты
class Транспорт {
public:
    virtual void доставить() = 0;
    virtual ~Транспорт() = default;
};

class Грузовик : public Транспорт {
public:
    void доставить() override {
        std::cout << "Доставка по суше на грузовике" << std::endl;
    }
};

class Корабль : public Транспорт {
public:
    void доставить() override {
        std::cout << "Доставка по морю на корабле" << std::endl;
    }
};

// Создатель (фабрика)
class Логистика {
public:
    // Фабричный метод — наследники переопределяют его
    virtual std::unique_ptr<Транспорт> создатьТранспорт() = 0;
    
    void запланироватьДоставку() {
        auto транспорт = создатьТранспорт();
        транспорт->доставить();
    }
    
    virtual ~Логистика() = default;
};

class СухопутнаяЛогистика : public Логистика {
public:
    std::unique_ptr<Транспорт> создатьТранспорт() override {
        return std::make_unique<Грузовик>();
    }
};

class МорскаяЛогистика : public Логистика {
public:
    std::unique_ptr<Транспорт> создатьТранспорт() override {
        return std::make_unique<Корабль>();
    }
};

int main() {
    std::unique_ptr<Логистика> логистика;
    
    // Выбираем тип логистики
    логистика = std::make_unique<СухопутнаяЛогистика>();
    логистика->запланироватьДоставку();  // Доставка по суше на грузовике
    
    логистика = std::make_unique<МорскаяЛогистика>();
    логистика->запланироватьДоставку();  // Доставка по морю на корабле
    
    return 0;
}

Объяснение: Клиентский код (функция запланироватьДоставку) не знает, какой именно транспорт будет создан. Он просто вызывает создатьТранспорт(). Это позволяет добавлять новые виды транспорта без изменения существующего кода.

Пример 5: Декоратор (Decorator) — добавление функциональности

Позволяет динамически добавлять новые возможности объекту, оборачивая его в другие объекты.

#include <iostream>
#include <memory>

// Базовый компонент
class Напиток {
public:
    virtual std::string описание() = 0;
    virtual double стоимость() = 0;
    virtual ~Напиток() = default;
};

// Конкретный компонент
class Эспрессо : public Напиток {
public:
    std::string описание() override {
        return "Эспрессо";
    }
    double стоимость() override {
        return 1.50;
    }
};

// Базовый декоратор
class Добавка : public Напиток {
protected:
    std::unique_ptr<Напиток> напиток_;
public:
    Добавка(std::unique_ptr<Напиток> н) : напиток_(std::move(н)) {}
};

// Конкретные декораторы
class Молоко : public Добавка {
public:
    using Добавка::Добавка;  // Наследуем конструктор
    std::string описание() override {
        return напиток_->описание() + " + молоко";
    }
    double стоимость() override {
        return напиток_->стоимость() + 0.50;
    }
};

class Шоколад : public Добавка {
public:
    using Добавка::Добавка;
    std::string описание() override {
        return напиток_->описание() + " + шоколад";
    }
    double стоимость() override {
        return напиток_->стоимость() + 0.75;
    }
};

int main() {
    // Создаём эспрессо
    std::unique_ptr<Напиток> напиток = std::make_unique<Эспрессо>();
    std::cout << напиток->описание() << " = $" << напиток->стоимость() << std::endl;
    
    // Добавляем молоко
    напиток = std::make_unique<Молоко>(std::move(напиток));
    std::cout << напиток->описание() << " = $" << напиток->стоимость() << std::endl;
    
    // Добавляем шоколад
    напиток = std::make_unique<Шоколад>(std::move(напиток));
    std::cout << напиток->описание() << " = $" << напиток->стоимость() << std::endl;
    
    return 0;
}

Объяснение: Мы оборачиваем объект Эспрессо в Молоко, а потом в Шоколад. Каждый декоратор добавляет свою стоимость и описание. Это лучше, чем создавать классы ЭспрессоСМолоком, ЭспрессоСШоколадом, ЭспрессоСМолокомИШоколадом — их было бы слишком много.

Пример 6: Компоновщик (Composite) — дерево объектов

Позволяет работать с отдельными объектами и их группами одинаково.

#include <iostream>
#include <vector>
#include <memory>

// Компонент — общий интерфейс для листьев и контейнеров
class ФайловаяСистема {
public:
    virtual void показать(int отступ = 0) = 0;
    virtual ~ФайловаяСистема() = default;
};

// Лист — отдельный файл
class Файл : public ФайловаяСистема {
private:
    std::string имя_;
public:
    Файл(const std::string& имя) : имя_(имя) {}
    void показать(int отступ = 0) override {
        for (int i = 0; i < отступ; ++i) std::cout << "  ";
        std::cout << "📄 " << имя_ << std::endl;
    }
};

// Контейнер — папка, может содержать файлы и другие папки
class Папка : public ФайловаяСистема {
private:
    std::string имя_;
    std::vector<std::unique_ptr<ФайловаяСистема>> содержимое_;
public:
    Папка(const std::string& имя) : имя_(имя) {}
    
    void добавить(std::unique_ptr<ФайловаяСистема> элемент) {
        содержимое_.push_back(std::move(элемент));
    }
    
    void показать(int отступ = 0) override {
        for (int i = 0; i < отступ; ++i) std::cout << "  ";
        std::cout << "📁 " << имя_ << std::endl;
        for (auto& элемент : содержимое_) {
            элемент->показать(отступ + 1);
        }
    }
};

int main() {
    auto корень = std::make_unique<Папка>("Проект");
    
    auto исходники = std::make_unique<Папка>("src");
    исходники->добавить(std::make_unique<Файл>("main.cpp"));
    исходники->добавить(std::make_unique<Файл>("utils.cpp"));
    
    auto документы = std::make_unique<Папка>("docs");
    документы->добавить(std::make_unique<Файл>("readme.md"));
    
    корень->добавить(std::move(исходники));
    корень->добавить(std::move(документы));
    корень->добавить(std::make_unique<Файл>("CMakeLists.txt"));
    
    корень->показать();
    // 📁 Проект
    //   📁 src
    //     📄 main.cpp
    //     📄 utils.cpp
    //   📁 docs
    //     📄 readme.md
    //   📄 CMakeLists.txt
    
    return 0;
}

Объяснение: И Файл, и Папка реализуют один интерфейс. Папка может содержать другие папки и файлы. Клиентский код вызывает показать() и не заботится о том, файл это или папка.

Пример 7: Команда (Command) — отмена действий

Превращает запрос в объект, что позволяет откладывать его выполнение, ставить в очередь или отменять.

#include <iostream>
#include <vector>
#include <memory>
#include <stack>

// Получатель — знает, как выполнять операции
class ТекстовыйРедактор {
private:
    std::string текст_;
public:
    void вставить(const std::string& подстрока, size_t позиция) {
        текст_.insert(позиция, подстрока);
    }
    void удалить(size_t позиция, size_t длина) {
        текст_.erase(позиция, длина);
    }
    std::string получитьТекст() const {
        return текст_;
    }
};

// Абстрактная команда
class Команда {
public:
    virtual void выполнить() = 0;
    virtual void отменить() = 0;
    virtual ~Команда() = default;
};

// Конкретная команда — вставка текста
class КомандаВставки : public Команда {
private:
    ТекстовыйРедактор* редактор_;
    std::string текст_;
    size_t позиция_;
public:
    КомандаВставки(ТекстовыйРедактор* ред, const std::string& текст, size_t поз)
        : редактор_(ред), текст_(текст), позиция_(поз) {}
    
    void выполнить() override {
        редактор_->вставить(текст_, позиция_);
    }
    void отменить() override {
        редактор_->удалить(позиция_, текст_.length());
    }
};

// Конкретная команда — удаление текста
class КомандаУдаления : public Команда {
private:
    ТекстовыйРедактор* редактор_;
    std::string удаленныйТекст_;
    size_t позиция_;
public:
    КомандаУдаления(ТекстовыйРедактор* ред, size_t поз, size_t длина)
        : редактор_(ред), позиция_(поз) {
        // Сохраняем удаляемый текст для возможности отмены
        удаленныйТекст_ = ред->получитьТекст().substr(поз, длина);
    }
    
    void выполнить() override {
        редактор_->удалить(позиция_, удаленныйТекст_.length());
    }
    void отменить() override {
        редактор_->вставить(удаленныйТекст_, позиция_);
    }
};

// Вызывающий — хранит историю команд
class История {
private:
    std::stack<std::unique_ptr<Команда>> выполненныеКоманды_;
public:
    void выполнитьКоманду(std::unique_ptr<Команда> команда) {
        команда->выполнить();
        выполненныеКоманды_.push(std::move(команда));
    }
    void отменитьПоследнюю() {
        if (!выполненныеКоманды_.empty()) {
            выполненныеКоманды_.top()->отменить();
            выполненныеКоманды_.pop();
        }
    }
};

int main() {
    ТекстовыйРедактор редактор;
    История история;
    
    // Вставляем текст
    история.выполнитьКоманду(
        std::make_unique<КомандаВставки>(&редактор, "Привет, ", 0)
    );
    история.выполнитьКоманду(
        std::make_unique<КомандаВставки>(&редактор, "мир!", 8)
    );
    std::cout << редактор.получитьТекст() << std::endl;  // Привет, мир!
    
    // Отменяем последнее действие
    история.отменитьПоследнюю();
    std::cout << редактор.получитьТекст() << std::endl;  // Привет, 
    
    // Отменяем ещё одно
    история.отменитьПоследнюю();
    std::cout << редактор.получитьТекст() << std::endl;  // (пусто)
    
    return 0;
}

Объяснение: Каждое действие (вставка, удаление) — это объект. Мы сохраняем их в стеке. Чтобы отменить действие, просто вызываем отменить(). Так работают Ctrl+Z в любом редакторе.

Сравнение с Python: чем похоже и чем отличается

Аспект C++ Python
Типизация Статическая. Типы проверяются на этапе компиляции. Нужно явно указывать типы. Динамическая. Типы проверяются во время выполнения. Можно менять тип переменной.
Интерфейсы Через абстрактные классы с чисто виртуальными функциями. Через ABC (Abstract Base Classes) из модуля abc.
Управление памятью Ручное или через умные указатели. Нужно думать, кто владеет объектом. Автоматический сборщик мусора. Можно не думать