Паттерны проектирования в C++: когда и зачем
Вы когда-нибудь переписывали код по десятому кругу, потому что новый функционал ломал старую архитектуру? Или тратили часы на дебаг, пытаясь понять, кто и зачем изменил глобальную переменную? Знакомо? Тогда добро пожаловать в мир паттернов проектирования.
Паттерны — это не волшебная таблетка. Это проверенные бойцами решения типовых проблем. Они не сделают ваш код идеальным, но помогут говорить на одном языке с другими разработчиками и избежать граблей, на которые уже наступили тысячи программистов до вас.
В этой статье мы разберём три основные группы паттернов в C++: порождающие, структурные и поведенческие. Узнаем, когда их стоит применять, а когда лучше пройти мимо. Поехали!
Порождающие паттерны: контроль над созданием объектов
Порождающие паттерны отвечают за гибкое создание объектов. Они скрывают логику инстанцирования и делают систему независимой от того, как именно создаются её компоненты.
Singleton (Одиночка)
Классика. Гарантирует, что у класса есть только один экземпляр, и предоставляет глобальную точку доступа к нему. Звучит заманчиво, но используйте его с умом — Singleton часто становится антипаттерном, если раздувается до размеров «бога-объекта».
Когда использовать: логгер, менеджер конфигурации, пул потоков (thread pool), работа с аппаратным обеспечением.
Пример в C++ (thread-safe, C++11 и новее):
class Logger {
public:
static Logger& getInstance() {
static Logger instance; // Инициализируется один раз, даже в многопоточке
return instance;
}
void log(const std::string& msg) {
std::cout << "[LOG]: " << msg << std::endl;
}
// Запрещаем копирование и присваивание
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
private:
Logger() = default; // Приватный конструктор
~Logger() = default;
};
// Использование
Logger::getInstance().log("Система запущена");
Factory Method (Фабричный метод)
Определяет интерфейс для создания объекта, но позволяет подклассам изменять тип создаваемого объекта. Отлично подходит, когда вы заранее не знаете, какой именно класс понадобится.
Когда использовать: парсинг разных форматов (JSON, XML, YAML), создание UI-элементов под разные ОС, работа с разными базами данных.
Пример:
// Абстрактный продукт
class Document {
public:
virtual void open() = 0;
virtual ~Document() = default;
};
// Конкретные продукты
class PDFDocument : public Document {
void open() override { std::cout << "Открываем PDF" << std::endl; }
};
class WordDocument : public Document {
void open() override { std::cout << "Открываем Word" << std::endl; }
};
// Фабрика
class Application {
public:
virtual Document* createDocument() = 0; // Фабричный метод
virtual ~Application() = default;
void newDocument() {
Document* doc = createDocument();
doc->open();
delete doc; // В реальном коде — умные указатели
}
};
class PDFApp : public Application {
Document* createDocument() override { return new PDFDocument(); }
};
// Использование
PDFApp app;
app.newDocument(); // Открываем PDF
Структурные паттерны: собираем конструктор
Эти паттерны помогают организовать классы и объекты в более крупные структуры, сохраняя гибкость и эффективность.
Adapter (Адаптер)
Позволяет объектам с несовместимыми интерфейсами работать вместе. Представьте, что у вас есть старая библиотека для работы с XML, а новый модуль ждёт JSON. Адаптер — ваш спаситель.
Когда использовать: интеграция легаси-кода, подключение сторонних библиотек с неудобным интерфейсом.
Пример:
// Старый интерфейс
class OldXMLParser {
public:
std::string parseXML(const std::string& data) {
return "XML: " + data;
}
};
// Новый интерфейс, который мы хотим использовать
class NewJSONParser {
public:
virtual std::string parseJSON(const std::string& data) = 0;
virtual ~NewJSONParser() = default;
};
// Адаптер
class XMLtoJSONAdapter : public NewJSONParser {
private:
OldXMLParser* adaptee;
public:
XMLtoJSONAdapter(OldXMLParser* parser) : adaptee(parser) {}
std::string parseJSON(const std::string& data) override {
// Переводим JSON в XML (упрощённо) и вызываем старый парсер
std::string xmlData = "<data>" + data + "</data>";
return adaptee->parseXML(xmlData);
}
};
// Использование
OldXMLParser legacyParser;
XMLtoJSONAdapter adapter(&legacyParser);
std::cout << adapter.parseJSON("{key:value}"); // XML: <data>{key:value}</data>
Composite (Компоновщик)
Позволяет работать с отдельными объектами и их группами одинаково. Дерево файловой системы, графические сцены, иерархия меню — классические примеры.
Когда использовать: любая древовидная структура, где нужно единообразно обрабатывать листья и узлы.
#include <vector>
#include <memory>
class Graphic {
public:
virtual void draw() const = 0;
virtual ~Graphic() = default;
};
class Circle : public Graphic {
public:
void draw() const override { std::cout << "Рисуем круг\n"; }
};
class CompoundGraphic : public Graphic {
private:
std::vector<std::shared_ptr<Graphic>> children;
public:
void add(std::shared_ptr<Graphic> child) { children.push_back(child); }
void draw() const override {
for (const auto& child : children)
child->draw();
}
};
// Использование
CompoundGraphic group;
group.add(std::make_shared<Circle>());
group.add(std::make_shared<Circle>());
group.draw(); // Рисуем круг дважды
Поведенческие паттерны: налаживаем коммуникацию
Эти паттерны решают задачи эффективного взаимодействия между объектами, распределяя обязанности и алгоритмы.
Observer (Наблюдатель)
Создаёт механизм подписки, позволяющий одним объектам следить за изменениями в других. Основа событийно-ориентированного программирования.
Когда использовать: обновление UI при изменении данных, системы уведомлений, обработка событий.
Пример:
#include <vector>
#include <algorithm>
class Observer {
public:
virtual void update(const std::string& message) = 0;
virtual ~Observer() = default;
};
class Subject {
private:
std::vector<Observer*> observers;
public: