Паттерны проектирования в 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. |
| Управление памятью | Ручное или через умные указатели. Нужно думать, кто владеет объектом. | Автоматический сборщик мусора. Можно не думать |