Паттерны проектирования: что это, откуда взялись и как читать каталог
Есть два способа прочитать книгу «Design Patterns». Первый — как список из 23 рецептов, которые надо
выучить к собеседованию и потом натыкать в код. Так делает большинство, и результат предсказуем:
AbstractRequestHandlerFactoryProvider на 12 строк полезной логики. Второй — как словарь имён для
решений, которые вы и так изобретаете, вместе с каталогом их последствий. Второй способ полезен всю карьеру.
Этот трек читает каталог вторым способом. Здесь разберём: откуда паттерны пришли (не из программирования), что такое паттерн формально, почему их классифицировали именно так, какова механика любого паттерна, сколько он стоит и какая процедура ведёт от задачи к паттерну, а не наоборот.
Зачем нужны паттерны: две аналогии
Шахматные дебюты. Гроссмейстер не считает первые 15 ходов заново: у него в голове «испанская партия», «сицилианская защита» — именованные конфигурации с известными планами и известными слабостями. Дебют не заменяет расчёт, он экономит силы на изученном и даёт язык для обсуждения («он свернул в вариант дракона»).
Диагнозы. «Боль в правом нижнем квадранте живота, температура 38, симптом Щёткина положительный» — это набор сил. «Острый аппендицит» — имя, за которым стоит протокол и список осложнений. Ошибка новичка одинакова в медицине и в коде: сначала поставить диагноз, потом подгонять под него симптомы.
Общее: паттерн — это сжатие опыта в имя. Эрих Гамма формулировал так: паттерны не изобретают, их открывают — находят в уже работающем коде.
Откуда это взялось: Кристофер Александер, 1977
Кристофер Александер — архитектор (зданий) и математик. В книге «A Pattern Language» он с соавторами описал 253 паттерна: от «Города с населением 10 000» до «Ниши у окна». Его паттерн №159, «Свет с двух сторон в каждой комнате», устроен так: контекст — жилая комната; силы — свет из одного окна даёт резкие тени на лицах и люди подсознательно избегают таких комнат, но второе окно дороже и съедает стену; решение — по возможности делать окна минимум в двух стенах.
Структура тут важнее содержания: не «делай так», а «вот конфликт, вот компромисс, вот когда он оправдан». Именно её — а не строительные решения — Кент Бек и Уорд Каннингем в 1987 году перенесли в разработку ПО докладом «Using Pattern Languages for Object-Oriented Programs». Каннингем потом создал первую в мире вики (wiki.c2.com) буквально ради того, чтобы сообщество совместно писало паттерны.
Ирония: сам Александер на keynote OOPSLA'96 сказал программистам, что они взяли форму, но потеряли цель. Его паттерны служили тому, чтобы людям было хорошо в построенном пространстве; он спросил зал, что является аналогом этого «хорошо» для программ. Вопрос открыт до сих пор — и он же лучшая прививка от механического применения каталога.
Что такое паттерн строго
Паттерн проектирования — это именованное, многократно проверенное решение повторяющейся задачи проектирования в определённом контексте, вместе с явным описанием сил, которые оно уравновешивает, и последствий, которые оно влечёт. Каждое слово нагружено:
| Часть определения | Что означает | Что будет, если её выкинуть |
|---|---|---|
| Именованное | У решения есть общепринятое имя | Ревью тонет в пересказах своими словами |
| Многократно проверенное | «Правило трёх»: найдено независимо минимум в трёх системах | В каталог попадает чья-то идея, а не опыт |
| Задача проектирования | «Как разложить обязанности», а не «как быстро отсортировать» | Смешение с алгоритмами: у алгоритма есть оптимум, у паттерна — только компромисс |
| В контексте | Решение верно при определённых условиях | Появляется «золотой молоток»: паттерн применяют везде |
| Силы | Конфликтующие требования: гибкость vs простота | Непонятно, когда решение не нужно |
| Последствия | Что вы теряете, применив | Ощущение, что паттерны бесплатны. Они не бесплатны |
Канонический шаблон GoF содержит 13 разделов; на практике нужны шесть. Вот они на примере Strategy:
Имя: Strategy (Стратегия)
Задача: Есть семейство взаимозаменяемых алгоритмов; клиент выбирает один из
них, не зная деталей и не разрастаясь ветвлениями.
Контекст: Вариантов ≥ 3, они меняются независимо от клиента, набор пополняется
извне (плагины, тарифы, правила скидок).
Силы: (+) хочется добавлять варианты, не трогая клиента
(−) не хочется плодить классы ради двух if
(−) выбор нужен в рантайме, не на компиляции
Решение: Вынести алгоритм за интерфейс; клиент держит ссылку на интерфейс;
реализацию подставляет тот, кто знает контекст.
Следствия: (+) новый вариант = новый класс, клиент не меняется (OCP)
(+) каждый вариант тестируется изолированно
(−) клиент обязан знать о существовании выбора
(−) +1 косвенность: стек-трейс длиннее, «go to definition» врёт
(−) состояние, общее для вариантов, надо передавать явно
Применения: java.util.Comparator; middleware в Express; retry-политики в Polly
Если про свой «паттерн» вы не можете заполнить «Следствия» с минусами — вы описали не паттерн, а любимый приём.
Механика: любой паттерн — это управляемая точка вариативности
За всеми 23 паттернами GoF стоит одна идея. Вы находите в системе место, которое будет меняться, и вставляете туда шов — стабильный контракт, по разные стороны которого код меняется независимо. Это прямое следствие двух принципов GoF: «программируйте на уровне интерфейса, а не реализации» и «предпочитайте композицию наследованию».
Паттерны отличаются не идеей, а тем, что именно они выносят за шов: Strategy — алгоритм; Factory Method — выбор конкретного класса при создании; Adapter — несовпадение интерфейсов; Observer — список тех, кого надо уведомить; Decorator — необязательную добавку к поведению; Command — сам факт вызова (превращает вызов в объект, который можно хранить, логировать, откатывать); State — правила перехода между режимами.
Посмотрим в коде. Задача: сервис уведомлений, канал доставки выбирается по настройкам пользователя. Наивная версия нормальна, пока вариантов два:
class Notifier:
def send(self, user: "User", text: str) -> None:
# ветвление по типу канала прямо в клиенте
if user.channel == "email":
smtp = smtplib.SMTP(settings.SMTP_HOST)
smtp.sendmail(settings.FROM, user.email, text)
elif user.channel == "sms":
requests.post(SMS_URL, json={"to": user.phone, "body": text[:160]})
elif user.channel == "push":
fcm.send(user.device_token, text)
else:
raise ValueError(f"неизвестный канал: {user.channel}")
Что плохо объективно, а не «не по фэн-шую»: четвёртый канал — правка того же метода (риск задеть
рабочие); тест на SMS требует замокать smtplib и fcm, которые к SMS не относятся; ограничение в
160 символов спрятано внутри ветки и невидимо при чтении класса.
Через Strategy:
from typing import Protocol
class Channel(Protocol):
"""Шов: всё, что клиент знает про доставку."""
def deliver(self, user: "User", text: str) -> None: ...
class SmsChannel:
MAX_LEN = 160 # ограничение видно в классе, а не в глубине ветки
def __init__(self, gateway: "SmsGateway") -> None:
self._gateway = gateway
def deliver(self, user: "User", text: str) -> None:
self._gateway.post(user.phone, text[: self.MAX_LEN])
class Notifier:
def __init__(self, channels: dict[str, Channel]) -> None:
self._channels = channels # связывание снаружи, в composition root
def send(self, user: "User", text: str) -> None:
channel = self._channels.get(user.channel)
if channel is None:
raise ValueError(f"неизвестный канал: {user.channel}")
channel.deliver(user, text) # клиент не знает, что происходит дальше
Честно про цену: строк стало больше (было 12, стало ~30). Выигрыш появляется на третьем-четвёртом
варианте и в тестах, где FakeChannel на пять строк заменяет моки smtplib и fcm.
И сразу важная оговорка, к которой вернёмся в разделе про критику: в Python интерфейс с одним методом — это просто функция, и Strategy сворачивается почти в ничто:
Channel = Callable[[User, str], None] # тип-псевдоним, никаких классов
def make_sms_channel(gateway: SmsGateway) -> Channel:
def deliver(user: User, text: str) -> None:
gateway.post(user.phone, text[:160]) # замыкание держит состояние
return deliver
Паттерн тот же, воплощение легче на порядок. Паттерн — это структура отношений, а не количество классов.
Каталог GoF: два измерения классификации
23 паттерна классифицированы по двум осям одновременно, и вторую редко объясняют.
Ось 1 — назначение: что паттерн делает. Ось 2 — уровень: работает он через наследование (фиксируется на компиляции) или через композицию объектов (меняется в рантайме). Именно вторая ось объясняет, почему Factory Method и Abstract Factory — разные паттерны с почти одинаковым названием: первый про наследование, второй про композицию.
| Назначение | Что варьируется | Разбираем в |
|---|---|---|
| Порождающие | Как объект создаётся и какого он конкретно класса | Порождающие паттерны |
| Структурные | Как объекты соединяются в более крупные структуры | Структурные паттерны |
| Поведенческие | Как объекты распределяют обязанности и общаются | Поведенческие паттерны |
Каталог GoF — не вся вселенная: он вышел в 1994-м, когда мир был однопоточным и объектным. Дальше каталоги расширялись: конкурентность (пул, producer-consumer, future, конвейер — в GoF их нет вовсе, см. Паттерны конкурентности); функциональный мир (функторы, монады, линзы — другая система координат, где половина задач GoF не возникает, см. Функциональные паттерны); уровень приложения (Repository, Unit of Work, Data Mapper из PoEAA); уровень системы (слои, порты и адаптеры, CQRS, Saga — это уже архитектурные паттерны).
Где паттерны стоят на шкале решений
Частая путаница: «паттерн», «идиома», «архитектура» и «принцип» валят в одну кучу. Они на разных этажах, и цена ошибки на этих этажах отличается на порядки.
- Идиома — приём внутри одного языка, обычно в пределах функции: RAII в C++,
deferв Go, контекстный менеджер в Python. В другой язык не переносится. - Паттерн проектирования — несколько классов/модулей внутри одного процесса. Переносится между ОО-языками. Это наш трек.
- Архитектурный паттерн — границы процессов, транзакций, деплоя. Откатывается месяцами.
- Принципы (SOLID, DRY, KISS, «высокая связность — слабое зацепление») — не решения, а критерии оценки решений; см. трек про принципы. Паттерны — типовые способы удовлетворить принципы; принципы — способ понять, зачем вам паттерн.
Отдельно: паттерн ≠ алгоритм. У бинарного поиска есть доказуемый оптимум O(log n), и спорить не о чем (см. трек алгоритмов). У паттерна оптимума нет: он всегда торгует одно качество на другое, и «правильность» зависит от того, что будет меняться в вашей системе.
Что паттерн стоит
Большинство материалов останавливается на «зато гибко». Разберём цену измеримо.
1. Косвенность. Чтобы понять поток через Notifier в наивной версии, нужно открыть 1 файл; в версии
со Strategy — 2–3, плюс место конфигурации, где связывание может быть вообще неявным (DI-контейнер).
Именно поэтому «go to definition» приводит в интерфейс, а не в код.
2. Число сущностей. Ориентировочно на один применённый паттерн:
| Паттерн | Новых типов | Когда окупается |
|---|---|---|
| Strategy | 1 интерфейс + N реализаций | N ≥ 3 или N растёт извне |
| Abstract Factory | 1 интерфейс фабрики + M семейств × K продуктов | ≥ 2 полных семейства продуктов |
| Observer | 1 интерфейс + реестр + отписка | ≥ 2 независимых потребителя события |
| Visitor | 1 интерфейс визитора + accept в каждом узле |
Узлы стабильны, операции добавляются часто |
| Decorator | 1 базовый интерфейс + по классу на добавку | Добавки комбинируются в рантайме |
3. Рантайм. Обычно пренебрежимо, но не всегда: виртуальный вызов мешает инлайну, а Decorator в горячем цикле — это N лишних кадров стека на элемент. JIT в JVM и .NET девиртуализует мономорфные вызовы, но при трёх и более реализациях сайт становится мегаморфным и inline cache перестаёт помогать. Вывод: на горячем пути измеряйте, а не рассуждайте.
4. Неправильная догадка — самое дорогое. Вы вставляете шов, предсказывая ось изменений. Если предсказание неверно, у вас теперь и лишняя абстракция, и изменение, которое режет её поперёк; рефакторить приходится больше, чем если бы абстракции не было. Отсюда правило Бека и Фаулера: сначала сделай просто, паттерн введи рефакторингом, когда изменение уже пришло (об этом целая книга — «Refactoring to Patterns»).
Критика, которую надо знать: «паттерн — это дыра в языке»
В 1996 году Питер Норвиг выступил с докладом «Design Patterns in Dynamic Languages»: 16 из 23 паттернов GoF в динамических языках (Lisp, Dylan) либо невидимы, либо радикально проще, потому что язык даёт нужный механизм напрямую. Отсюда формулировка: паттерн — признак того, что языку чего-то не хватает.
Тезис верен не полностью (Observer и Command не сводятся к фиче языка — это про распределение обязанностей), но полезен как фильтр:
| Паттерн GoF | Чем заменяется возможностью языка |
|---|---|
| Strategy, Command | Функции первого класса, замыкания |
| Abstract Factory | Классы как значения first-class (Python, Ruby, Smalltalk) |
| Iterator | Генераторы, yield, встроенный протокол итерации |
| Singleton | Модуль (Python), object (Scala), init() пакета (Go), enum (Java) |
| Decorator | Функции высшего порядка; @decorator в Python; middleware |
| Visitor | Pattern matching по алгебраическим типам (Rust, Scala, F#, Elixir) |
| Template Method | Функция, принимающая хуки-функции параметрами |
| Prototype | Встроенный copy/clone, прототипное наследование в JS |
| Flyweight | Интернирование строк и значений в рантайме |
Правило чтения этого трека: сначала спросите, есть ли в вашем языке прямой механизм. Наивный перенос Java-формулировок в Python, Go или TypeScript — самая частая причина «переусложнённого кода в стиле энтерпрайза». Как это выглядит в конкретных языках — в треках Go, TypeScript, C# и Elixir.
Вторая линия критики — от самих авторов. В интервью «Design Patterns 15 Years Later» (2009) Эрих Гамма сказал, что выкинул бы из каталога Singleton («по сути глобальная переменная»), а добавил бы Null Object, Type Object и Dependency Injection. Каталог 1994 года — снимок практики, а не священный текст.
Как читать каталог: процедура от задачи, а не от списка
Правильное направление мысли — проблема → силы → паттерн. Неправильное — паттерн → куда бы его применить.
Было уже 2+ раза"} B -- "Нет, первый раз" --> Z["Решить прямо.
Правило трёх: абстракция на третьем случае"] B -- "Да" --> C["Сформулировать ось изменения
и записать силы"] C --> E{"Есть ли в языке прямая
возможность закрыть это?"} E -- "Да: замыкание, generic,
pattern matching, модуль" --> Y["Использовать её.
Паттерн уже реализован языком"] E -- "Нет" --> F{"К какой оси относится
изменение?"} F -- "Создание объектов" --> G["Порождающие:
Factory, Builder, Prototype"] F -- "Соединение структур" --> H["Структурные:
Adapter, Decorator, Facade, Proxy"] F -- "Обязанности и общение" --> I["Поведенческие:
Strategy, Observer, Command, State"] G --> J{"Прочитать Следствия.
Минусы приемлемы?"} H --> J I --> J J -- "Нет" --> Z J -- "Да" --> K["Ввести рефакторингом,
под зелёными тестами"] K --> L["Назвать по домену:
PricingPolicy, а не PriceStrategyImpl"]
Три правила, вытекающие из схемы:
- Правило трёх. Первый случай — пишем прямо. Второй — замечаем дублирование, терпим. Третий — обобщаем. Две точки задают сколько угодно кривых; ось изменения видна только с третьей.
- Читайте «Следствия» раньше «Решения». Если минусы неприемлемы — паттерн не ваш, и знать, как он устроен, вам сейчас не нужно.
- Именуйте по домену.
RetryPolicy,PricingRule,PaymentGateway, а неRetryStrategyImpl. Имя паттерна нужно в разговоре и комментарии, а не в идентификаторе. Исключение — устоявшиеся*Factory,*Builder,*Adapter, где имя паттерна и есть роль в домене.
Типичные ошибки
Patternitis. Симптом: в проекте есть AbstractHandlerFactory, но нет ни одной второй реализации.
Лечение: удалить абстракцию, вернуть, когда появится второй случай. Это дешевле, чем кажется, — в
большинстве IDE это один рефакторинг «Inline».
Cargo cult: скопировали структуру, потеряли силы. Классика — Singleton «чтобы был один экземпляр», который ломает тесты (глобальное состояние течёт между кейсами), мешает конкурентности и прячет зависимости. Обычно нужен не Singleton, а один экземпляр, созданный на старте и переданный явно.
Паттерн вместо структуры данных. Развесистая Chain of Responsibility там, где хватило бы
dict[str, Handler]. Прежде чем брать поведенческий паттерн, спросите, не решается ли задача таблицей;
см. трек структур данных.
Абстракция не по той оси. Вынесли за интерфейс «базу данных», а меняться стал формат отчётов. Признак: при каждом изменении правите и интерфейс, и все реализации сразу — это shotgun surgery, шов стоит не там.
Паттерн как заклинание в ревью. «Тут нужен Observer» без формулировки сил — не аргумент. Аргумент звучит так: «подписчиков будет ≥ 3, их добавляют другие команды, отписка нужна при закрытии сессии — поэтому Observer, а не прямые вызовы».
Подробный разбор промахов — в Антипаттернах, а честный разговор про то, где каталог мешает, — в Паттернах в реальном коде.
Паттерны в проде: где они на самом деле живут
Лучший аргумент в пользу каталога — вы уже пользуетесь им ежедневно, часто не зная имён.
| Паттерн | Реальное применение |
|---|---|
| Decorator | java.io: new BufferedReader(new InputStreamReader(new FileInputStream(f))) — три обёртки, каждая добавляет одно свойство. Middleware в Express/ASP.NET Core — тот же паттерн на функциях |
| Strategy | java.util.Comparator; key= в sorted(); retry-политики в Polly; compression.type в Kafka producer |
| Adapter | Драйверы БД под общий интерфейс: PEP 249 DB-API, database/sql в Go, JDBC |
| Observer | DOM addEventListener; сигналы Django; IObservable в Rx; реактивность во Vue/Svelte |
| Iterator | __iter__ в Python; IEnumerable в C#; range-цикл в Go |
| Facade | boto3.client("s3").upload_file() прячет multipart-загрузку, ретраи и подпись запроса |
| Proxy | Ленивая загрузка в Hibernate и SQLAlchemy; sidecar Envoy — Proxy на уровне сети |
| Command | undo/redo в редакторах; задачи Celery/Sidekiq — команда как объект, поэтому её можно сериализовать и повторить |
| Composite | Дерево DOM; компоненты React — лист и контейнер за одним интерфейсом |
| Builder | strings.Builder в Go; fluent-конфигурация (Effective Java, item 2); query builder в ORM |
| Flyweight | Интернирование строк в JVM/CLR; кэш маленьких int в CPython (−5…256) |
| Template Method | Хуки жизненного цикла: setUp/tearDown в тестовых фреймворках |
Обратите внимание: половина применений — не «классы с интерфейсами», а функции, протоколы и конфигурация. Паттерн проявляется как форма отношений, независимо от синтаксиса.
Главная ценность: словарь
Скажите на ревью: «здесь класс, который хранит ссылку на другой объект того же интерфейса, делегирует ему вызов и добавляет логирование, и таких обёрток может быть несколько» — двадцать слов, и коллега всё равно уточнит детали. Скажите «это Decorator» — два слова, и разговор сразу переходит к сути: важен ли порядок наложения? что с исключениями в середине цепочки? как вы это отлаживаете?
Поэтому имена стоит знать даже тем, кто пишет на Haskell или Clojure и никогда не напишет
AbstractFactory. Каталог — разделяемый словарь профессии; его эффект виден на код-ревью, в проектных
обсуждениях, в документации и при онбординге.
Мини-итог
- Паттерн = имя + контекст + силы + решение + следствия. Без следствий это не паттерн.
- Путь идеи: архитектура зданий (Александер, 1977) → ООП (Бек и Каннингем, 1987) → каталог GoF (1994).
- Любой паттерн — шов в предсказанной точке изменений; отличаются они тем, что именно выносят за шов.
- Классификация GoF двумерна: назначение × уровень (наследование или композиция).
- Цена: косвенность при чтении, лишние сущности, иногда рантайм — и, главное, риск угадать не ту ось изменений.
- В динамических и функциональных языках больше половины паттернов сворачивается в возможности языка.
- Направление мысли: проблема → силы → паттерн, никогда наоборот. Паттерн вводится рефакторингом, не авансом.
Чек-лист перед применением паттерна: проблема повторилась трижды или изменение уже стучится в дверь? могу назвать ось изменения одним предложением? проверил, нет ли прямой языковой возможности? прочитал минусы и готов их платить? назвал класс по домену, а не по паттерну? могу за 30 секунд описать коллеге, что этот паттерн ломает?
Источники
Книги
- Gamma E., Helm R., Johnson R., Vlissides J. «Design Patterns» (1994) — первоисточник; читать не подряд, а по мере встречи с задачей.
- Freeman E., Robson E. «Head First Design Patterns», 2-е изд. (2020) — самое дружелюбное введение, обновлено под Java 8+ и лямбды.
- Kerievsky J. «Refactoring to Patterns» (2004) — industriallogic.com/xp/refactoring.
- Buschmann F. et al. «Pattern-Oriented Software Architecture», vol. 1 (1996) — архитектурный уровень.
- Fowler M. «Patterns of Enterprise Application Architecture» (2002) — каталог онлайн.
- Brown W. et al. «AntiPatterns» (1998) — обратная сторона каталога.
- Alexander C. «A Pattern Language» (1977) — источник самой идеи.
Статьи и доклады
- Beck K., Cunningham W. «Using Pattern Languages for OO Programs», OOPSLA'87 — c2.com/doc/oopsla87.html.
- Norvig P. «Design Patterns in Dynamic Languages» (1996) — norvig.com/design-patterns.
- Alexander C. «The Origins of Pattern Theory», keynote OOPSLA'96 — patternlanguage.com.
- «Design Patterns 15 Years Later», интервью с Гаммой, Хелмом и Джонсоном, InformIT (2009) — informit.com.
- Fowler M. «Writing Software Patterns» — martinfowler.com.
Справочники
- refactoring.guru/ru/design-patterns — лучший русскоязычный каталог с примерами на 8 языках.
- wiki.c2.com — Портлендский репозиторий, первая вики в истории; ценна спорами вокруг паттернов.
- hillside.net/patterns — материалы конференций PLoP.
Что дальше
Начинаем с того, как объекты появляются на свет. Порождающие паттерны отвечают на вопросы: кто решает, какой конкретно класс создать; как собрать объект с двадцатью параметрами, не превращая конструктор в загадку; и почему Singleton — тот самый паттерн, который автор каталога хотел бы отозвать.
Следующая статья: Порождающие паттерны: Factory, Builder, Singleton, Prototype