Паттерны проектирования — инструмент, а не цель
Паттерн проектирования — это названное решение повторяющейся задачи структурирования кода. Он не библиотека и не алгоритм: алгоритм решает вычислительную задачу, паттерн описывает отношения между классами. Ценность паттерна двойная — он даёт проверенную структуру и общий словарь: сказать «здесь Observer» быстрее, чем рисовать диаграмму.
В C++ выбор паттерна острее, чем в Java или Python, потому что у вас есть два механизма полиморфизма. Одну и ту же идею — «подставляемое поведение» — можно выразить через виртуальные функции (выбор в runtime, цена — косвенный вызов и запрет инлайнинга) или через шаблоны (выбор в compile-time, цена — раздувание кода и потеря ABI-гибкости). Поэтому в C++ мало знать схему паттерна — нужно знать, во что он обходится.
Главная ловушка — применять паттерн как цель. Лишний слой абстракции ухудшает читаемость и производительность. Начинайте с простейшего работающего кода и вводите паттерн только тогда, когда задача, которую он решает, действительно появилась. Интервьюер проверяет именно это суждение, а не способность перечислить 23 паттерна GoF.
Категории паттернов
Каталог «банды четырёх» (GoF) делит 23 паттерна на три категории по типу решаемой задачи:
- Порождающие (creational) — управляют созданием объектов, отделяя клиента от конкретных типов: Singleton, Factory Method, Abstract Factory, Builder, Prototype.
- Структурные (structural) — собирают объекты и классы в более крупные структуры: Adapter, Decorator, Facade, Flyweight, Proxy, а из идиом C++ — PIMPL.
- Поведенческие (behavioral) — описывают взаимодействие и распределение обязанностей между объектами: Strategy, Observer, State, Command, Chain of Responsibility, Mediator, Visitor, Iterator, Template Method.
Категория — это подсказка о намерении. Proxy и Decorator структурно похожи (оба оборачивают объект), но решают разные задачи: Proxy контролирует доступ к тому же интерфейсу, Decorator добавляет новое поведение. Запоминать нужно не имена, а задачи.
Принципы проектирования
Паттерны — это частные решения; принципы — общие правила, по которым их оценивают. Пять принципов SOLID:
- S — Single Responsibility. У класса одна причина для изменения. Но если две обязанности всегда меняются вместе — они принадлежат одному классу; не дробите ради дробления.
- O — Open/Closed. Модуль открыт для расширения, закрыт для модификации. Принцип работает на границе интерфейса — рефакторинг внутренностей класса его не нарушает.
- L — Liskov Substitution. Объект производного класса подставляется вместо базового без нарушения контракта. Классический контрпример —
Square : Rectangle: переопределённыйsetWidthломает ожиданиеsetWidthбазовогоRectangle. - I — Interface Segregation. Клиент не должен зависеть от методов, которые не использует. Толстый интерфейс дробят на узкие ролевые.
- D — Dependency Inversion. Модули зависят от абстракций, а не от конкретных типов. Но «абстракция» — это точка реальной вариативности, а не каждая зависимость подряд.
// DIP: высокоуровневый код зависит от абстракции, не от конкретного логгера
struct ILogger {
virtual void write(std::string_view msg) = 0;
virtual ~ILogger() = default;
};
class OrderService {
ILogger& log_; // зависимость инвертирована
public:
explicit OrderService(ILogger& log) : log_(log) {}
void place() { log_.write("order placed"); }
};
Помимо SOLID — три эвристики простоты: DRY (Don't Repeat Yourself — устраняйте дублирование одного и того же знания), KISS (Keep It Simple — убирайте случайную сложность, не необходимую) и YAGNI (You Aren't Gonna Need It — не стройте функциональность «на будущее»).
Ловушка переусердствования
Принципы тоже можно применить во вред. Самая частая ошибка senior-уровня — трактовать DIP как «каждая зависимость обязана быть интерфейсом». Появляется интерфейс с единственной реализацией: лишний виртуальный вызов, лишний файл, навигация по коду через пустую абстракцию. OCP при этом ничего такого не требовал — он говорит о границах модулей, а не об абстракции там, где второй реализации нет и не предвидится. Если у интерфейса один наследник и фейк для теста не добавляет ценности — встройте интерфейс обратно в класс. DRY, применённый агрессивно, тоже вредит: случайное сходство двух фрагментов — не повод их объединять, иначе вы свяжете несвязанные концепции.
Композиция против наследования
Наследование выражает отношение is-a и жёстко связывает потомка с базой: раскладкой памяти, vtable, защищённым интерфейсом. Композиция выражает has-a: объект хранит другой объект и делегирует ему работу.
// Наследование ради переиспользования кода — антипаттерн
class Stack : public std::vector<int> { /* ... */ }; // Stack «является» vector? нет
// Композиция + делегирование — Stack ИМЕЕТ хранилище
class Stack {
std::vector<int> data_;
public:
void push(int x) { data_.push_back(x); }
void pop() { data_.pop_back(); }
int top() const { return data_.back(); }
};
Правило «предпочитайте композицию наследованию» означает именно «предпочитайте», а не «всегда». Переиспользование кода — невалидный повод для наследования: для этого есть композиция. Но полиморфные иерархии, где нужен runtime-полиморфизм через базовый указатель, наследование требуют по существу. Отдельный запах — private наследование «чтобы получить доступ к внутренностям»: почти всегда это композиция, написанная неправильно.
Порождающие паттерны
Singleton
Гарантирует единственный экземпляр и даёт к нему глобальную точку доступа. Канонично в современном C++ — статик Мейерса: локальная static-переменная функции.
class Config {
public:
static Config& instance() {
static Config inst; // инициализируется один раз, потокобезопасно с C++11
return inst;
}
Config(const Config&) = delete; // запрет копирования —
Config& operator=(const Config&) = delete; // иначе появится второй экземпляр
private:
Config() = default;
};
Механика: с C++11 инициализация локальной static потокобезопасна — компилятор оборачивает её в «magic static» (флаг + барьер), повторная инициализация исключена. Возвращайте ссылку, а не указатель: указатель клиент может случайно delete или обнулить. Обязательно = delete для копирующих операций.
Минусы Singleton делают его подозрительным паттерном. Это глобальное состояние: зависимости становятся скрытыми (не видны в сигнатуре конструктора), код труднее тестировать и распараллеливать, появляется проблема порядка уничтожения — деструктор одного синглтона, обращающийся к уже уничтоженному другому, это UB. Если вам просто нужен один экземпляр на запуск приложения — передавайте его явно через конструктор (это и есть dependency injection), а не прячьте в синглтон.
Factory Method и Abstract Factory
Оба отделяют клиента от конкретных создаваемых типов, но по-разному:
- Factory Method — виртуальный метод, который подкласс переопределяет, чтобы решить, какой объект создать. Механизм — наследование. Метод не может быть
static: статика не диспетчеризуется виртуально, и весь смысл паттерна теряется. - Abstract Factory — объект, чей интерфейс создаёт семейство связанных продуктов. Механизм — композиция: клиент содержит ссылку на фабрику.
struct Button { virtual void paint() = 0; virtual ~Button() = default; };
struct GuiFactory { // Abstract Factory
virtual std::unique_ptr<Button> makeButton() = 0;
virtual ~GuiFactory() = default;
};
Простая свободная функция makeWidget() — это фабричная функция, а не паттерн GoF: паттерн подразумевает полиморфизм. Если варьируется только один продукт — Abstract Factory избыточен, достаточно Factory Method.
Builder
Пошагово конструирует сложный объект, заменяя конструктор с десятком аргументов. Каждый setter возвращает Builder& — по ссылке, иначе каждый вызов копирует билдер. Перекрёстные инварианты проверяйте в build(), иначе наружу утекут невалидные объекты.
class HttpRequest { /* ... */ };
class RequestBuilder {
std::string url_, method_ = "GET";
public:
RequestBuilder& url(std::string u) { url_ = std::move(u); return *this; }
RequestBuilder& method(std::string m) { method_ = std::move(m); return *this; }
HttpRequest build() const {
if (url_.empty()) throw std::logic_error("url required"); // инвариант
return HttpRequest{/* ... */};
}
};
Не вводите Builder для структуры из двух полей — это переусложнение.
Prototype
Создаёт объект копированием существующего через виртуальный clone(). Возвращайте unique_ptr, а не сырой указатель, и переопределяйте clone() в каждом производном классе — иначе при копировании произойдёт срезка объекта.
struct Shape {
virtual std::unique_ptr<Shape> clone() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
std::unique_ptr<Shape> clone() const override {
return std::make_unique<Circle>(*this); // копия точного типа
}
};
Структурные паттерны
Adapter и Decorator
Оба оборачивают объект, но с разными целями:
- Adapter меняет интерфейс: оборачивает объект так, чтобы он подошёл под интерфейс, который ждёт клиент. Поведение то же — поверхность другая.
- Decorator меняет поведение: оборачивает объект тем же интерфейсом, добавляя функциональность вокруг.
// Decorator: тот же интерфейс Stream, добавлено сжатие
struct Stream { virtual void write(std::string_view) = 0; virtual ~Stream() = default; };
class CompressingStream : public Stream {
Stream& inner_; // оборачиваемый объект
public:
explicit CompressingStream(Stream& s) : inner_(s) {}
void write(std::string_view data) override {
inner_.write(compress(data)); // добавили поведение, интерфейс тот же
}
};
У Decorator базовый Component обязан иметь виртуальный деструктор — иначе удаление через указатель на базу не вызовет деструкторы обёрток. Adapter бывает объектным (композиция — хранит ссылку на адаптируемый объект) и классовым (private наследование от него); объектный гибче. Публично наследоваться от адаптируемого объекта нельзя — наружу торчит весь его интерфейс, и контракт адаптера ломается.
Flyweight
Экономит память, разделяя между объектами неизменяемое (intrinsic) состояние через общий пул. Классический пример — глифы шрифта: тысячи символов на экране ссылаются на десятки разделяемых описаний глифа. Разделять можно только иммутабельное состояние — общее мутабельное даст гонки и алиасинг-баги. Пул flyweight-ов обязан пережить все ссылающиеся на него объекты.
PIMPL — идиома C++
PIMPL (Pointer to IMPLementation) прячет приватные члены класса за указателем на неполный тип Impl. Это убирает зависимость заголовка от деталей реализации: меняете Impl — перекомпилируется только один .cpp, а не все его пользователи. Это же даёт ABI-стабильность: размер видимого класса не меняется.
// widget.h — заголовок не знает устройства Impl
class Widget {
struct Impl;
std::unique_ptr<Impl> pimpl_;
public:
Widget();
~Widget(); // ОБЪЯВЛЕН в .h, ОПРЕДЕЛЁН в .cpp
};
Ключевая ловушка: деструктор нужно объявить в заголовке и определить в .cpp, где Impl уже полный. Если положиться на неявный деструктор, компилятор сгенерирует его в заголовке — там, где Impl неполный, — и ~unique_ptr<Impl>() упрётся в static_assert о неполном типе. Копирование тоже не бесплатно: unique_ptr некопируем, и copy-конструктор придётся писать вручную с глубоким копированием Impl. Для маленьких value-типов PIMPL не нужен — аллокация в куче съест выигрыш.
Стоимость стека обёрток
Декораторы и адаптеры собираются в цепочки — и глубокий стек обёрток имеет цену. Каждая обёртка — отдельная аллокация в куче, поэтому цепочка разбросана по памяти и плохо ложится в кэш. Вызов идёт через базовый указатель, и оптимизатор, как правило, не может девиртуализировать цепочку — каждый уровень это косвенный вызов с запретом инлайнинга. Если поведение фиксировано на этапе компиляции — не платите за runtime-индирекцию, выразите его шаблоном.
Поведенческие паттерны
Strategy
Выносит подставляемый алгоритм за интерфейс, чтобы менять его независимо от хоста. В современном C++ — два воплощения:
// Runtime Strategy — выбор алгоритма меняется во время работы
class Sorter {
std::function<bool(int, int)> cmp_;
public:
explicit Sorter(std::function<bool(int, int)> c) : cmp_(std::move(c)) {}
void sort(std::vector<int>& v) { std::sort(v.begin(), v.end(), cmp_); }
};
// Compile-time Strategy — стратегия задана типом-параметром
template <class Compare>
class StaticSorter {
Compare cmp_;
public:
void sort(std::vector<int>& v) { std::sort(v.begin(), v.end(), cmp_); }
};
Compile-time против runtime
Это типичный senior-вопрос. Runtime Strategy (через std::function или виртуальный интерфейс) позволяет менять алгоритм на лету и держать выбор за ABI-границей — но платит косвенным вызовом и теряет инлайнинг. Compile-time Strategy (тип-параметр) инлайнится и оптимизируется насквозь — но каждый набор параметров порождает свою инстанциацию (раздувание кода), и выбор нельзя поменять, не перекомпилировав пользователей. Не верьте, что оптимизатор сам проинлайнит виртуальный вызов: девиртуализация требует, чтобы конкретный тип был известен в точке вызова.
Observer
Уведомляет множество подписчиков об изменении субъекта — связь «один ко многим». Внешне простой паттерн, но в C++ это источник тонких багов.
class Subject {
std::vector<std::weak_ptr<Observer>> observers_; // weak_ptr, не сырой указатель
public:
void notify() {
for (auto snapshot = observers_; auto& w : snapshot) // итерируем КОПИЮ
if (auto o = w.lock()) o->update();
}
};
Observer как ловушка времени жизни и многопоточности
Наивный GoF Observer хранит сырые указатели на наблюдателей. В реальном коде наблюдатель часто уничтожается раньше субъекта — и в списке остаётся висячий указатель, по которому notify() обращается в UB. Решение — weak_ptr или обязательный явный unsubscribe(). Вторая ловушка — итерирование живого контейнера: если наблюдатель внутри своего колбэка вызывает unsubscribe(), он инвалидирует итератор цикла; итерируйте копию-снимок. Третья — многопоточность: если держать мьютекс субъекта на весь цикл notify(), а колбэк наблюдателя вызовет метод субъекта обратно — самодедлок. Добавление и удаление наблюдателей из разных потоков тоже требует синхронизации списка.
State
Объект меняет поведение при смене внутреннего состояния — это конечный автомат. Ключевая идея: логику переходов держат классы состояний, а не контекст. Если переходы свалить в контекст, он превращается в гигантский switch.
Отсюда частая senior-задача — инкрементально отрефакторить god-object со switch в паттерн State так, чтобы каждый шаг был отгружаемым и ревьюабельным. Порядок: сначала написать характеризационные тесты (иначе регрессия проскользнёт молча), затем извлекать case-ы в классы-состояния по одному, и только когда все случаи извлечены — переносить логику переходов в состояния. Смешивать два рефакторинга нельзя — каждый шаг должен проверяться отдельно. Сами состояния часто не имеют данных — используйте разделяемые экземпляры (статический flyweight), чтобы не аллоцировать состояние на каждый переход.
Остальные поведенческие паттерны
- Command — инкапсулирует запрос как объект: очереди, отмена операций, логирование. Для
undoхраните достаточно состояния, иначе откат будет неполным. Не захватывайте ссылки вstd::function-командах — к моменту запуска очереди они повиснут. - Chain of Responsibility — запрос идёт по цепочке обработчиков, пока кто-то его не примет. В конце цепочки нужен fallback, иначе запрос молча потеряется.
- Mediator — посредник оркеструет взаимодействие N компонентов «многие ко многим», убирая прямые связи между ними. Отличие от Observer: Observer — однонаправленное уведомление «один ко многим», Mediator — двунаправленная оркестрация. Посредник оркеструет, а не реализует бизнес-логику компонентов.
- Visitor — добавляет операции к иерархии типов через двойную диспетчеризацию, не меняя сами типы. Оправдан, когда операций (N) много, а типов (M) мало и они стабильны. Если иерархия типов меняется чаще операций — каждый новый тип заставляет править всех визиторов; тогда лучше виртуальная диспетчеризация.
- Iterator — даёт единый способ обхода контейнера. В C++ паттерн реализован самой STL:
begin()/end(), категории итераторов, совместимость с range-based for и алгоритмами. Контейнер без итераторов несовместим сfor (auto x : c)и<algorithm>. - Template Method — задаёт скелет алгоритма в базовом классе, оставляя шаги переопределяемыми. Сам шаблонный метод невиртуальный — виртуальны только шаги. Не вызывайте виртуальные шаги из конструктора базового класса: vtable ещё указывает на базу, и диспетчеризация не дойдёт до производной реализации.
Управление зависимостями
Внедрение зависимостей (Dependency Injection, DI) — это передача объекту его зависимостей извне, а не создание их внутри. В простейшей форме DI — это параметр конструктора; никакой фреймворк для этого не нужен, и «DI» не равно «DI-фреймворк».
// Без DI — зависимость создаётся внутри, спрятана, не подменяется
class ReportServiceBad {
FileLogger log_; // ❌ жёстко прибито к FileLogger
};
// С DI — зависимость внедрена через конструктор, видна и подменяема
class ReportService {
ILogger& log_;
public:
explicit ReportService(ILogger& log) : log_(log) {} // ✅ это и есть DI
};
Внедряйте абстракцию — интерфейс или параметр-шаблон, — а не конкретный тип: иначе зависимость нельзя подменить, и смысл паттерна теряется. Антипаттерн-сосед — service locator: вместо явного параметра объект сам тянет зависимость из глобального реестра. Это снова прячет зависимости (их не видно в сигнатуре конструктора), тогда как DI делает их явными.
Граница плагина
Плагин-система — крайний случай управления зависимостями: модуль подгружается динамически во время работы. Граница между хостом и плагином в C++ хрупкая, и три правила её удерживают:
- Экспортируйте
extern "C"-фабрику, а не классы напрямую. C++ name mangling различается между компиляторами и даже их версиями — символ класса, собранный одним компилятором, не найдётся в бинарнике, собранном другим. Стабильна только C-граница:extern "C"-функция, возвращающая объект через абстрактный интерфейс. - Выгружайте плагины в порядке, обратном загрузке. Если плагин B зависит от плагина A и A выгрузить первым, vtable объектов B будет указывать на уже выгруженный из памяти код.
- Не передавайте STL-контейнеры через границу. Раскладка
std::stringиstd::vectorзависит от компилятора, версии рантайма и флагов сборки. На границе используйте C-типы или указатели.
Идиома PIMPL (см. «Структурные паттерны») — это та же развязка зависимостей, но на уровне трансляции: заголовок перестаёт зависеть от деталей реализации.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Паттерн ради паттерна, лишний слой абстракции | Хуже читаемость и производительность; код сложнее, чем задача |
| Singleton там, где нужен просто один экземпляр на запуск | Скрытые зависимости, нетестируемость; вместо этого — DI |
Возврат указателя вместо ссылки из instance() | Клиент может delete или обнулить — второй экземпляр или краш |
| Виртуальный вызов из конструктора (Template Method) | vtable не достроена — шаг не диспетчеризуется в производный класс |
factory-метод объявлен static | Нет виртуальной диспетчеризации — Factory Method не работает |
PIMPL: деструктор не определён в .cpp с полным Impl | static_assert об уничтожении unique_ptr неполного типа |
| Observer хранит сырые указатели на наблюдателей | Висячий указатель в списке → UB при notify() |
unsubscribe() внутри колбэка при итерировании живого списка | Инвалидация итератора цикла notify() |
Мьютекс субъекта удерживается на весь notify() | Самодедлок, если колбэк вызывает субъект обратно |
| Логика переходов State свалена в контекст | Контекст превращается в гигантский switch |
| DIP трактуется как «каждая зависимость — интерфейс» | Интерфейсы с одной реализацией: лишний вызов и навигация впустую |
| Наследование ради переиспользования кода | Жёсткая связанность с базой; нужна была композиция |
Значение для собеседований
Паттерны проектирования — одна из самых частых тем на интервью уровня middle и выше. Но проверяют не знание каталога GoF наизусть, а инженерное суждение: понимаете ли вы, какую задачу решает паттерн и во что он обходится в C++.
Что проверяет интервьюер:
- Распознавание задачи, а не заучивание имён 23 паттернов
- Различение похожих паттернов: Adapter vs Decorator, Factory Method vs Abstract Factory, Mediator vs Observer, Proxy vs Decorator
- Цену паттерна в C++: виртуальный вызов, аллокация в куче, раздувание кода, ABI-граница
- Compile-time против runtime воплощения (Strategy) и осознанный выбор между ними
- Ловушки времени жизни и многопоточности — особенно в Observer
- Когда паттерн лишний: переусердствование с SOLID, Singleton вместо DI, абстракция без второй реализации
Типичные вопросы:
- Чем Adapter отличается от Decorator? А Factory Method от Abstract Factory?
- Как реализовать потокобезопасный Singleton и почему статик Мейерса лучше указателя с мьютексом?
- Почему наивный Observer становится ловушкой времени жизни и дедлоков?
- ABI- и инлайн-компромиссы compile-time против runtime Strategy?
- Как инкрементально отрефакторить god-object
switchв паттерн State? - Какие ловушки у PIMPL и почему деструктор обязан быть в
.cpp?
Типичная ошибка: отвечать схемой паттерна из учебника, не называя цену и контекст. Сильный кандидат показывает, что знает, когда паттерн вредит — это и отличает инженерное суждение от пересказа каталога GoF.