Паттерны проектирования Паттерны проектирования: что это, откуда взялись и как читать каталог
0%

Паттерны проектирования: что это, откуда взялись и как читать каталог

Паттерны проектирования: что это, откуда взялись и как читать каталог

Есть два способа прочитать книгу «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 года — снимок практики, а не священный текст.


Как читать каталог: процедура от задачи, а не от списка

Правильное направление мысли — проблема → силы → паттерн. Неправильное — паттерн → куда бы его применить.

Три правила, вытекающие из схемы:

  1. Правило трёх. Первый случай — пишем прямо. Второй — замечаем дублирование, терпим. Третий — обобщаем. Две точки задают сколько угодно кривых; ось изменения видна только с третьей.
  2. Читайте «Следствия» раньше «Решения». Если минусы неприемлемы — паттерн не ваш, и знать, как он устроен, вам сейчас не нужно.
  3. Именуйте по домену. 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

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов