Структурные паттерны: Adapter, Decorator, Facade, Proxy, Composite
Порождающие паттерны (см. Порождающие паттерны) отвечали на вопрос «откуда взялся объект». Структурные отвечают на следующий: «как объекты соединены между собой».
Их объединяет одна механическая идея, до смешного простая:
class Wrapper:
def __init__(self, inner):
self._inner = inner # объект держит ссылку на другой объект
Всё. Шесть из семи классических структурных паттернов GoF — это вот эта строчка. Adapter, Decorator, Proxy, Facade, Bridge отличаются не структурой, а намерением: зачем именно вы поставили обёртку и что происходит с интерфейсом на входе и выходе. Composite ломает ряд: там обёртка держит не один объект, а список — и вместо цепочки получается дерево.
Это важно понять сразу, потому что большая часть путаницы в интервью и код-ревью («это Proxy или Decorator?») возникает у тех, кто пытается различать паттерны по UML-картинке. По картинке они неразличимы. Различает намерение.
Карта раздела
паттерны)) Обёртка одного объекта Adapter меняет интерфейс поведение то же Decorator интерфейс тот же поведение расширено Proxy интерфейс тот же контроль доступа Обёртка группы Facade сужает интерфейс прячет подсистему Разделение иерархий Bridge абстракция и реализация растут независимо Рекурсивная структура Composite лист и узел одинаковы дерево целое-часть Экономия памяти Flyweight разделяемое состояние внутреннее и внешнее
Дальше по каждому: интуиция → строгая формулировка → рабочий код → цена → где ломается.
Adapter: разъём не подходит
Интуиция
У вас есть код, который умеет работать с PaymentGateway. Приходит требование подключить провайдера,
у которого SDK называет всё иначе: не charge(amount, currency), а executeTransaction(dto) и деньги
не в Decimal, а в копейках int. Переписывать свой код под чужой SDK — значит навсегда пустить
чужую модель мира внутрь домена. Adapter — это переходник, который живёт на границе и переводит.
Аналогия буквальна: розетка в стене (Adaptee) и вилка вашего ноутбука (Target) физически несовместимы; переходник не меняет ни ток, ни ноутбук — он меняет форму разъёма.
Строго
Adapter конвертирует интерфейс класса в другой интерфейс, ожидаемый клиентом, позволяя работать вместе классам, которые иначе несовместимы по сигнатурам.
Ключевой признак: поведение не меняется, меняется только форма вызова. Если ваша обёртка добавляет ретраи — это уже не Adapter, это Decorator, который заодно адаптирует.
Есть две реализации:
- Объектный адаптер — держит ссылку на adaptee (композиция). Работает везде, включая языки без множественного наследования. Умеет адаптировать целое семейство подклассов.
- Классовый адаптер — наследуется одновременно от Target и Adaptee. В Python/C++ возможен, в Java/C# — только если Target это интерфейс. Экономит один указатель и один вызов, но намертво привязывает адаптер к конкретному классу adaptee.
В 99% случаев нужен объектный.
Код
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
@dataclass(frozen=True)
class ChargeResult:
charge_id: str
approved: bool
class PaymentGateway(Protocol):
"""Наш доменный контракт. Про Stripe тут не знает никто."""
def charge(self, amount: Decimal, currency: str) -> ChargeResult: ...
def refund(self, charge_id: str) -> None: ...
class StripeAdapter:
"""Объектный адаптер: переводит доменный язык в язык чужого SDK и обратно."""
# У SDK свои коды причин возврата — маппинг живёт здесь, а не в домене.
_REASON_REQUESTED_BY_CUSTOMER = 3
def __init__(self, sdk: "StripeSDK") -> None:
self._sdk = sdk
def charge(self, amount: Decimal, currency: str) -> ChargeResult:
# Конвертация модели: Decimal в минорные единицы, ISO-код в нижнем регистре.
minor_units = int((amount * 100).to_integral_value())
raw = self._sdk.execute_transaction(
{"amount": minor_units, "curr": currency.lower(), "capture": True}
)
# Конвертация результата: чужие поля и коды в наш ChargeResult.
return ChargeResult(charge_id=raw["txn_id"], approved=raw["status"] == "ok")
def refund(self, charge_id: str) -> None:
self._sdk.reverse(charge_id, self._REASON_REQUESTED_BY_CUSTOMER)
Сервис при этом не знает ничего:
class CheckoutService:
def __init__(self, gateway: PaymentGateway) -> None:
self._gateway = gateway # Protocol, а не StripeAdapter
def pay(self, order) -> ChargeResult:
return self._gateway.charge(order.total, order.currency)
Стоимость. Один дополнительный вызов на операцию (O(1)), одна аллокация обёртки на весь процесс, плюс аллокации на конвертацию DTO. Для сетевого вызова, который стоит десятки миллисекунд, это шум. Для адаптера в горячем цикле на миллион итераций — уже нет, там думают об inline и о том, чтобы конвертация не создавала мусор.
Типичные ошибки
- Адаптер, который «немножко» реализует бизнес-логику. Появляется
if order.is_vip: ...— и вот у вас домен размазан по интеграционному слою. Правило: адаптер имеет право на конвертацию типов, единиц измерения, кодов ошибок и на нормализацию исключений. Всё остальное — наверх. - Протекающая абстракция. Ваш
PaymentGatewayвнезапно принимаетstripe_customer_id. Это значит, что интерфейс спроектирован «снизу», от SDK, а не «сверху», от нужд домена. Проектируйте Target по потребности клиента, даже если адаптеру потом будет неудобно — неудобство локализовано. - Адаптер на один-единственный вызов. Если вы обернули ровно один метод и другой реализации никогда не будет — вы, возможно, просто добавили файл. См. Паттерны в реальном коде.
- Забыли про ошибки.
stripe.error.CardErrorне должен вылетать из адаптера наружу — иначе домен всё равно узнает про Stripe, просто черезexcept. Транслируйте исключения тоже.
В проде
logging.Handlerв Python и мосты вроде SLF4J в Java — классические адаптеры между вашим кодом логирования и конкретным бэкендом.- Драйверы БД под общий интерфейс: PEP 249 (DB-API 2.0, peps.python.org/pep-0249)
— это спецификация Target, а
psycopg,sqlite3,mysqlclient— адаптеры к нему. - Порты и адаптеры («гексагональная архитектура» Алистера Кокбёрна) — целый архитектурный стиль, выросший из этого паттерна: alistair.cockburn.us/hexagonal-architecture.
Decorator: то же самое, но ещё и…
Интуиция
Нужно к HTTP-клиенту добавить метрики. Потом ретраи. Потом кэш. Потом трассировку. Наследование даёт
комбинаторный взрыв: CachingRetryingTimedClient, RetryingTimedClient, CachingTimedClient… для
k независимых фич нужно до 2^k классов, и половина из них будет копипастой.
Decorator превращает умножение в сложение: k классов, из которых во время выполнения собирается любая комбинация в любом порядке.
Строго
Decorator динамически добавляет объекту новые обязанности, реализуя тот же интерфейс, что и обёрнутый объект, и делегируя ему основную работу. Это гибкая альтернатива наследованию для расширения функциональности.
Инвариант, который делает паттерн работающим: декоратор подставим вместо компонента — то есть
соблюдает принцип подстановки Лисков относительно интерфейса компонента. Клиент не должен уметь
отличить Cache(client) от client иначе как по скорости.
Код
import time
from typing import Protocol
class HttpClient(Protocol):
def get(self, url: str) -> bytes: ...
class RealHttpClient:
"""Ядро: единственный, кто реально ходит в сеть."""
def get(self, url: str) -> bytes:
... # сокет, TLS, чтение тела
return b"payload"
class RetryingClient:
"""Декоратор: повторяет при сетевых сбоях с экспоненциальной задержкой."""
def __init__(self, inner: HttpClient, attempts: int = 3, base_delay: float = 0.1) -> None:
self._inner = inner
self._attempts = attempts
self._base_delay = base_delay
def get(self, url: str) -> bytes:
last: Exception | None = None
for attempt in range(self._attempts):
try:
return self._inner.get(url)
except (TimeoutError, ConnectionError) as exc:
last = exc
# Экспоненциальный backoff; в проде сюда же добавляют jitter,
# иначе клиенты синхронно долбят сервис после общего сбоя.
time.sleep(self._base_delay * (2 ** attempt))
raise last # type: ignore[misc]
class CachingClient:
"""Декоратор: отдаёт из памяти, если уже видели этот URL."""
def __init__(self, inner: HttpClient) -> None:
self._inner = inner
self._cache: dict[str, bytes] = {}
def get(self, url: str) -> bytes:
if url not in self._cache:
self._cache[url] = self._inner.get(url)
return self._cache[url]
class TimedClient:
"""Декоратор: измеряет длительность, ничего не зная о том, что внутри."""
def __init__(self, inner: HttpClient, histogram) -> None:
self._inner = inner
self._histogram = histogram
def get(self, url: str) -> bytes:
started = time.perf_counter()
try:
return self._inner.get(url)
finally:
# finally, а не после return: иначе ошибки не попадут в метрику
self._histogram.observe(time.perf_counter() - started)
Сборка — обычный код, а не конфигурация фреймворка:
client: HttpClient = TimedClient(RetryingClient(CachingClient(RealHttpClient())), histogram)
Порядок обёрток — это семантика
Самая частая ошибка новичка — считать декораторы коммутативными. Они не коммутативны:
| Композиция | Что получится |
|---|---|
Retrying(Caching(real)) |
Кэш внутри: при ретрае повторно бьём в кэш, промах уходит в сеть каждый раз |
Caching(Retrying(real)) |
Кэш снаружи: кэшируется итог всех попыток, повторы не видны кэшу |
Timed(Retrying(real)) |
Метрика измеряет суммарное время со всеми повторами — «время, которое ждал пользователь» |
Retrying(Timed(real)) |
Метрика измеряет каждую попытку отдельно — «латентность бэкенда» |
Ни один вариант не «правильный» вообще — правильный зависит от того, на какой вопрос вы отвечаете дашбордом. Но выбор должен быть осознанным и записанным в комментарии рядом со сборкой.
Стоимость
- Время: цепочка длины k добавляет k виртуальных вызовов на операцию — O(k). Для 3–5 слоёв поверх сетевого вызова это доли процента. Для декоратора вокруг сравнения в сортировке — катастрофа.
- Память: O(k) объектов на цепочку. Если цепочку собирают на каждый запрос, а не один раз при старте, это k аллокаций на запрос — заметная нагрузка на GC.
- Отладка: стек-трейс растёт на k кадров, и все они называются
get. Это реальная эксплуатационная боль:AttributeErrorгде-то в глубине пятислойной цепочки читается плохо. Помогает__repr__, возвращающий структуру цепочки:TimedClient(RetryingClient(RealHttpClient())). - Идентичность:
decorated is real— ложь,isinstance(decorated, RealHttpClient)— ложь. Любой код, полагающийся на конкретный тип или на identity объекта, сломается. Это ровно та причина, по которой Java-прокси и Spring AOP регулярно ломаютequals/hashCode-логику.
Как это выглядит в других языках
В Go декораторы почти всегда пишут как функции над функциями — это тот же паттерн без слова «класс»:
// Middleware — декоратор для http.Handler: тот же интерфейс на входе и на выходе.
type Middleware func(http.Handler) http.Handler
func WithLogging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // делегируем «внутрь»
logger.Info("request", "path", r.URL.Path, "dur", time.Since(start))
})
}
}
// Сборка цепочки: Logging(Auth(Recover(mux)))
handler := WithLogging(log)(WithAuth(store)(WithRecover()(mux)))
Стандартная библиотека Go построена на этом же: io.Reader оборачивается в bufio.Reader,
gzip.Reader, io.LimitReader — все они io.Reader (pkg.go.dev/io#Reader).
В Java та же идея — new BufferedInputStream(new GZIPInputStream(new FileInputStream(f)));
в .NET — Stream и CryptoStream/GZipStream (learn.microsoft.com/dotnet/api/system.io.stream).
Отдельно стоит сказать: декоратор в Python (@decorator) — это не совсем паттерн Decorator.
Синтаксис @ — это применение функции к функции; он часто используется для реализации паттерна
(@functools.lru_cache — буквально кэширующий декоратор,
docs.python.org/3/library/functools.html),
но точно так же используется для регистрации, валидации и метапрограммирования, не имеющих к GoF
никакого отношения.
Proxy: тот же интерфейс, но я решаю, пущу ли
Интуиция
Пользователь открывает список заказов. У каждого заказа есть customer, у клиента — история платежей,
у платежей — чеки. Если грузить всё сразу, один запрос списка вытянет полбазы. Хочется, чтобы объект
Customer в заказе выглядел как настоящий Customer, но реально шёл в базу только тогда, когда у
него впервые спросят поле.
Proxy — это заместитель с тем же интерфейсом, который контролирует доступ к настоящему объекту.
Строго
Proxy предоставляет суррогат другого объекта, контролируя доступ к нему. Канонические разновидности:
| Вид | Зачем | Пример |
|---|---|---|
| Виртуальный | отложить дорогое создание | lazy loading в ORM |
| Защитный (protection) | проверить права до вызова | ACL-обёртка над репозиторием |
| Удалённый (remote) | спрятать сеть за локальным вызовом | gRPC-стаб, Envoy sidecar |
| Кэширующий | не звать реальный объект без нужды | CDN, HTTP-прокси |
| Умная ссылка | считать ссылки, логировать доступ | shared_ptr, счётчики использования |
| Логирующий | зафиксировать факт обращения | аудит доступа к PII |
Proxy против Decorator
Оба реализуют тот же интерфейс, оба держат ссылку внутрь. Различия — практические, а не философские:
- Кто владеет объектом. Декоратору обёрнутый объект передают снаружи; прокси часто создаёт и владеет им сам (или вовсе не создаёт, пока не понадобится).
- Всегда ли делегирует. Декоратор делегирует всегда (он же расширяет). Прокси имеет право не вызвать реальный объект: отказать по правам, отдать из кэша, вернуть заглушку.
- Кто знает про существование. Декораторы предполагают, что клиент осознанно собирает цепочку. Прокси часто подсовывают клиенту прозрачно — он думает, что держит настоящий объект.
Практическое правило: если ваша обёртка когда-либо не зовёт внутренний объект — это Proxy.
Виртуальный прокси: как это работает во времени
есть только id = 42 P->>R: load(42) R->>DB: SELECT * FROM customers WHERE id = 42 DB-->>R: строка R-->>P: Customer(42, "Ирина", ...) Note over P: сохранили ссылку на настоящий объект P-->>V: "Ирина" V->>P: order.customer.email Note over P: объект уже загружен —
в базу не идём P-->>V: "irina@example.com"
from typing import Protocol
class Customer(Protocol):
@property
def name(self) -> str: ...
@property
def email(self) -> str: ...
class LazyCustomerProxy:
"""Виртуальный прокси: обращается в БД при первом реальном доступе."""
__slots__ = ("_id", "_repo", "_loaded")
def __init__(self, customer_id: int, repo) -> None:
self._id = customer_id
self._repo = repo
self._loaded: Customer | None = None
def _target(self) -> Customer:
if self._loaded is None:
self._loaded = self._repo.load(self._id) # первое обращение = запрос
return self._loaded
@property
def name(self) -> str:
return self._target().name
@property
def email(self) -> str:
return self._target().email
Жизненный цикл прокси удобно представлять как автомат — и именно из него видно все проблемы:
Типичные ошибки
- N+1 запрос. Прокси делает каждое обращение дешёвым по коду и дорогим по факту. Список из 100
заказов, у каждого ленивый
customer— 101 запрос вместо одного JOIN. Это самая дорогая ошибка, порождаемая паттерном; лечится batch-загрузкой (selectin,dataloader) или явным eager-фетчем. Документация Hibernate прямо это обсуждает: docs.jboss.org/hibernate/orm. - Прокси живёт дольше контекста. Ленивый объект отдали в шаблон/в другой поток после закрытия сессии — получили исключение в момент рендеринга, то есть максимально далеко от причины.
- Прокси, врущий про идентичность.
proxy == real_objectдаётFalse,type(proxy)— не тот класс. Код сisinstance, сериализация, кэш по ключу-объекту ломаются молча. - Скрытая сеть. Remote proxy делает удалённый вызов похожим на локальный — и разработчик пишет
for item in items: item.details.price, не подозревая, что это 500 RPC. «Fallacies of Distributed Computing» (nighthacks.com/jag/res/Fallacies.html) ровно про это: прозрачность сети — вредная иллюзия. Оставляйте в имени/типе след того, что вызов удалённый.
В проде
- Service mesh. Envoy/Linkerd как sidecar — это буквально remote proxy на уровне инфраструктуры:
приложение думает, что ходит на
localhost, а прокси делает mTLS, ретраи, балансировку и трейсинг (envoyproxy.io). - ORM. Hibernate, SQLAlchemy (
lazy="select"), Django (QuerySetленив до итерации) — виртуальные прокси. - Circuit breaker — protection proxy, который перестаёт пускать вызовы к падающему сервису: martinfowler.com/bliki/CircuitBreaker.html.
- Копирование при записи. Строки в CPython,
shared_ptr/COW-контейнеры в C++ — умные ссылки.
Facade: один вход вместо семи объектов
Интуиция
Чтобы оформить заказ, надо: проверить остатки на складе, зарезервировать их, списать деньги, создать накладную, отправить письмо, записать событие в аналитику. Шесть подсистем. Если контроллер знает про все шесть — он знает слишком много: любое изменение в любой подсистеме трогает контроллер, и написать его тест невозможно без шести моков.
Facade — это один объект с одним осмысленным методом place_order(cart), за которым спрятана вся
оркестрация.
Строго
Facade предоставляет унифицированный интерфейс к набору интерфейсов подсистемы, определяя интерфейс более высокого уровня, который упрощает использование подсистемы.
Два свойства, которые часто упускают:
- Фасад не запрещает доступ к подсистеме напрямую. Он делает лёгкий путь для 90% случаев, оставляя сложный путь доступным для оставшихся 10%. Если доступ запрещён — это уже не фасад, а слой изоляции.
- Фасад не добавляет функциональности, он комбинирует существующую. Если внутри появилась логика, которой нет ни в одной подсистеме, — вы пишете сервис приложения, и это нормально, просто называйте вещи именами.
у контроллера"| after style F fill:#3fa66b,stroke:#3fa66b,fill-opacity:0.2 style C1 fill:#c9793a,stroke:#c9793a,fill-opacity:0.2
class OrderFacade:
"""Фасад: знает сценарий целиком, но не реализует ни один его шаг сам."""
def __init__(self, inventory, payments, shipping, notifier, analytics) -> None:
self._inventory = inventory
self._payments = payments
self._shipping = shipping
self._notifier = notifier
self._analytics = analytics
def place_order(self, cart, customer) -> str:
# Порядок шагов и компенсация — единственное «знание» фасада.
reservation = self._inventory.reserve(cart.items)
try:
charge = self._payments.charge(cart.total, cart.currency)
except PaymentError:
self._inventory.release(reservation) # компенсируем то, что уже сделали
raise
shipment = self._shipping.create(reservation, customer.address)
# Побочные эффекты, от которых заказ не должен падать, — вне критического пути.
self._notifier.order_confirmed(customer.email, shipment.tracking)
self._analytics.track("order_placed", value=float(cart.total))
return shipment.order_id
Типичные ошибки
- Фасад-бог. Начинается с
place_order, через год в нём 40 методов и все подсистемы системы. Это уже God Object (см. Антипаттерны). Признак: методы фасада не имеют между собой ничего общего, кроме того что «удобно позвать отсюда». Лечение — несколько узких фасадов по сценариям, а не один широкий. - Фасад, который просто переименовывает.
facade.get_user(id)→repo.get_user(id)и так 30 раз. Это не упрощение, это налог. Фасад оправдан там, где 1 вызов наружу = N вызовов внутрь. - Фасад, ставший обязательным. Когда подсистемы прячут в приватные модули и оставляют только фасад, любой нестандартный сценарий требует расширения фасада — и он растёт по пункту 1.
- Транзакционные границы размазаны. В примере выше
reserveиcharge— разные подсистемы, возможно, разные БД. Фасад — то самое место, где надо принять решение: распределённая транзакция, сага с компенсацией или eventual consistency. Молча оставлять «как получится» нельзя.
Composite: часть и целое неотличимы
Интуиция
Файловая система: есть файлы и папки, папки содержат файлы и другие папки. Вопрос «сколько весит?» осмыслен и для файла, и для папки. Если писать клиентский код честно, получится
if isinstance(node, File):
total += node.size
elif isinstance(node, Directory):
for child in node.children: # рекурсия с той же проверкой
...
— и такая проверка расползётся по всему коду, который работает с деревом. Composite убирает if:
лист и составной узел реализуют один интерфейс, и клиент просто зовёт size().
Строго
Composite компонует объекты в древовидные структуры для представления иерархий «часть-целое» и позволяет клиентам единообразно трактовать отдельные объекты и их композиции.
Код
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Iterator
class FSNode(ABC):
"""Общий интерфейс листа и узла — весь смысл паттерна в том, что он один."""
def __init__(self, name: str) -> None:
self.name = name
@abstractmethod
def size(self) -> int: ...
@abstractmethod
def walk(self) -> Iterator[FSNode]: ...
class File(FSNode):
def __init__(self, name: str, nbytes: int) -> None:
super().__init__(name)
self._bytes = nbytes
def size(self) -> int:
return self._bytes
def walk(self) -> Iterator[FSNode]:
yield self
class Directory(FSNode):
def __init__(self, name: str) -> None:
super().__init__(name)
self._children: list[FSNode] = []
def add(self, node: FSNode) -> "Directory":
self._children.append(node)
return self # fluent-стиль для сборки дерева
def remove(self, node: FSNode) -> None:
self._children.remove(node)
def size(self) -> int:
# Рекурсия: одинаковый вызов для файлов и вложенных папок.
return sum(child.size() for child in self._children)
def walk(self) -> Iterator[FSNode]:
yield self
for child in self._children:
yield from child.walk()
root = (
Directory("/")
.add(File("boot.img", 4_194_304))
.add(
Directory("home")
.add(File("notes.md", 1_024))
.add(Directory("photos").add(File("cat.jpg", 2_097_152)))
)
)
print(root.size()) # 6_292_480
print(sum(1 for _ in root.walk())) # 6 узлов
Сложность
Пусть n — число узлов, h — высота дерева, b — средняя ветвистость.
size()наивно: O(n) по времени, O(h) по памяти (глубина стека рекурсии). При кэшировании суммы в узле — O(1) на чтение, но O(h) на каждое изменение (инвалидация вверх по родителям).walk(): O(n) по времени; генератор наyield fromдаёт O(h) кадров, а не O(n) — это важное преимущество перед сборкой списка.- Опасность глубины. Дефолтный лимит рекурсии CPython — 1000 (
sys.getrecursionlimit()). Дерево комментариев или DOM реальной страницы легко бывает глубже. Для недоверенных данных пишите обход явным стеком:
def iter_nodes(root: FSNode) -> Iterator[FSNode]:
"""Итеративный обход: не зависит от лимита рекурсии, O(ширина) по памяти."""
stack = [root]
while stack:
node = stack.pop()
yield node
if isinstance(node, Directory):
stack.extend(node._children)
Главный компромисс: где живут add/remove
GoF описывают два варианта, и это до сих пор живой спор:
- Прозрачный (
add/removeв базовом интерфейсе): клиент единообразен, затоFile.add()существует и обязан бросать исключение — нарушение LSP, ошибка уезжает в рантайм. - Безопасный (
add/removeтолько уDirectory): типобезопасно, но клиенту, который строит дерево, всё равно нуженisinstance/каст — единообразие частично теряется.
Практика: делайте безопасный вариант. Единообразие нужно для чтения дерева (size, walk,
render) — там оно и сохраняется. Изменение дерева почти всегда происходит в узком коде-построителе,
которому не жалко знать про типы. Подробнее о конфликте с LSP — в треке «Принципы проектирования».
Типичные ошибки
- Циклы в «дереве». Если разрешить узлу быть ребёнком двух родителей и не проверять, получится
граф — и
size()уйдёт в бесконечную рекурсию. Либо запрещайте приadd, либо ведитеvisited. - Родительские ссылки без дисциплины. Двусторонние связи удобны (
node.parent) и легко рассинхронизируются приremove. Устанавливайте/снимайтеparentтолько внутриadd/remove. - Мутабельные общие поддеревья. Один и тот же
Directoryвставлен в два места — изменение видно в обоих. Если это не задумано (а это редко задумано), клонируйте: см. Prototype в Порождающих паттернах. - Композит там, где данные плоские. Если иерархия всего два уровня и никогда не станет глубже, список списков читается лучше, чем абстрактный класс с двумя наследниками.
В проде
- DOM — канонический композит:
NodeсchildNodes,ElementиTextкак узел и лист (dom.spec.whatwg.org). - UI-деревья:
View/ViewGroupв Android, компоненты React (элемент может быть примитивом или составным — и рендерится одинаково). - AST компиляторов и парсеров выражений:
BinaryOp(Number, BinaryOp(...)); вычисление выражения — тот же рекурсивныйevaluate(). Обход таких деревьев обычно оформляют паттерном Visitor — см. Поведенческие паттерны. - Иерархии оргструктур, категорий товаров, прав доступа. Здесь композит в памяти встречается с хранением дерева в SQL: adjacency list, nested sets, materialized path, closure table.
Ещё два структурных: Bridge и Flyweight
Их нет в заголовке, но без них каталог неполон.
Bridge
Проблема: две независимо изменяющиеся оси. Есть Notification (Alert, Reminder, Digest) и есть
канал доставки (Email, SMS, Push). Наследованием получается 3×3 = 9 классов, и добавление четвёртого
канала даёт +3.
Решение: разделить на две иерархии и связать композицией — «абстракция» держит ссылку на «реализацию». Теперь 3 + 3 = 6 классов, а новый канал стоит +1.
class Channel(Protocol): # «реализация»
def send(self, to: str, text: str) -> None: ...
class Notification: # «абстракция»
def __init__(self, channel: Channel) -> None:
self._channel = channel # мост между иерархиями
def notify(self, user) -> None:
self._channel.send(user.contact, self._render(user))
def _render(self, user) -> str:
raise NotImplementedError
class Alert(Notification):
def _render(self, user) -> str:
return f"СРОЧНО, {user.name}: сработал алерт"
Структурно Bridge и Adapter близнецы. Разница во времени: Adapter применяют постфактум, когда несовместимость уже случилась; Bridge закладывают заранее, когда вы знаете про две оси вариативности.
Flyweight
Проблема: миллионы мелких объектов с сильно повторяющимся состоянием — символы в текстовом редакторе, тайлы в игровой карте, частицы.
Решение: разделить состояние на внутреннее (общее, неизменяемое — глиф, текстура) и внешнее (уникальное — координаты, передаются в методы параметрами). Внутреннее хранится в одном экземпляре на значение и разделяется.
from functools import lru_cache
class Glyph:
"""Внутреннее состояние: тяжёлое, неизменяемое, разделяемое."""
__slots__ = ("char", "font", "outline")
def __init__(self, char: str, font: str) -> None:
self.char, self.font = char, font
self.outline = load_outline(char, font) # мегабайты векторов
def draw(self, x: int, y: int) -> None: # внешнее состояние — в аргументах
render(self.outline, x, y)
@lru_cache(maxsize=None)
def glyph(char: str, font: str) -> Glyph: # фабрика-пул разделяемых объектов
return Glyph(char, font)
Экономия из миллиона символов при 100 уникальных глифах: O(уникальных) вместо O(всех). Цена —
объекты обязаны быть иммутабельными (иначе изменение у одного клиента видно всем) и потокобезопасность
пула. Строковый интернинг в Python/Java и Integer.valueOf для малых чисел — flyweight в
стандартной библиотеке.
Как выбрать: решающее дерево
работать с которыми неудобно"] --> B{"Интерфейс должен
измениться?"} B -->|"Да, чужой интерфейс
не подходит"| C[Adapter] B -->|"Да, слишком много
объектов и вызовов"| D[Facade] B -->|"Нет, интерфейс
остаётся тем же"| E{"Обёртка может
НЕ вызвать внутренний объект?"} E -->|"Да: права, кэш,
ленивость, сеть"| F[Proxy] E -->|"Нет, всегда делегирует,
но добавляет работу"| G[Decorator] A --> H{"Структура
рекурсивная?"} H -->|"Да, целое-часть
произвольной глубины"| I[Composite] A --> J{"Две независимые оси
вариативности?"} J -->|"Да, знаю заранее"| K[Bridge] A --> L{"Миллионы объектов,
память кончается?"} L -->|"Да, состояние
сильно повторяется"| M[Flyweight] style C fill:#c9793a,fill-opacity:0.2,stroke:#c9793a style D fill:#9b5bbf,fill-opacity:0.2,stroke:#9b5bbf style F fill:#5b8def,fill-opacity:0.2,stroke:#5b8def style G fill:#3fa66b,fill-opacity:0.2,stroke:#3fa66b style I fill:#3fa66b,fill-opacity:0.2,stroke:#3fa66b style K fill:#c9793a,fill-opacity:0.2,stroke:#c9793a style M fill:#9b5bbf,fill-opacity:0.2,stroke:#9b5bbf
Сводная таблица различий — то, что стоит помнить наизусть:
| Паттерн | Интерфейс на выходе | Поведение | Сколько обёрнуто | Ключевой вопрос |
|---|---|---|---|---|
| Adapter | другой | то же | 1 | «не стыкуется» |
| Decorator | тот же | расширено | 1 (рекурсивно) | «добавить, не меняя» |
| Proxy | тот же | то же или отказ | 1 | «контролировать доступ» |
| Facade | новый, узкий | то же | N | «слишком много деталей» |
| Bridge | тот же | то же | 1 (другая иерархия) | «две оси изменений» |
| Composite | тот же, что у листа | агрегирует | список | «часть и целое» |
| Flyweight | тот же | то же | пул | «слишком много объектов» |
Общая цена структурных паттернов
Все они покупают гибкость одной валютой — косвенностью. Стоит перечислить, за что вы платите, потому что счёт приходит не сразу:
- Длина стека. Пять слоёв обёрток — пять кадров в каждом трейсе. Продакшн-инцидент, где надо быстро понять причину по логу, становится ощутимо дороже.
- Непрозрачность потока управления. «Куда уходит вызов?» перестаёт отвечаться переходом по определению в IDE: определение ведёт в интерфейс, а кто там за ним — решается сборкой в другом файле. Это главная причина, по которой злоупотребление паттернами делает код «read-hostile».
- Аллокации. Обёртки — объекты. Если цепочка строится на запрос, а не на старте приложения, вы платите GC-налог пропорционально трафику.
- Ложь про типы.
isinstance,==, сериализация, рефлексия — всё это видит обёртку, а не содержимое. Прокси-фреймворки (Spring AOP, Hibernate) регулярно на этом обжигаются. - Стоимость понимания. Junior, встретивший
Timed(Retrying(Caching(Real()))), должен знать паттерн, чтобы прочитать строку. Это разумная цена в команде, где паттерн известен, и высокая — там, где нет.
Правило, к которому сходятся практики: вводите структурный паттерн, когда вторая реализация уже существует или точно появится в ближайшем спринте. Джошуа Кериевски в «Refactoring to Patterns» (martinfowler.com/books/r2p.html) формулирует это как «двигайтесь к паттерну, а не начинайте с паттерна».
Мини-итог
- Структурные паттерны — про соединение объектов; почти все они технически одинаковы («держу ссылку»), различаются намерением и судьбой интерфейса.
- Adapter меняет форму разъёма и живёт на границе системы; бизнес-логики в нём быть не должно.
- Decorator добавляет обязанности, сохраняя интерфейс; порядок обёрток — часть семантики, не деталь.
- Proxy контролирует доступ и имеет право не звать оригинал; главный риск — N+1 и скрытая сеть.
- Facade сужает интерфейс подсистемы; оправдан, когда один вызов наружу превращается в много внутрь.
- Composite делает лист и узел неотличимыми для читающего кода; следите за глубиной рекурсии и
за тем, где живут
add/remove. - Bridge разводит две оси вариативности заранее, Flyweight экономит память на повторяющемся состоянии.
- Общая валюта — косвенность. Она реальна, измерима и не бесплатна.
Источники
- Gamma, Helm, Johnson, Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software, 1994 — главы про Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy: en.wikipedia.org/wiki/Design_Patterns
- Каталог структурных паттернов с диаграммами и кодом на 8 языках: refactoring.guru/design-patterns/structural-patterns
- Kerievsky J. Refactoring to Patterns — про то, как приходить к паттернам рефакторингом: martinfowler.com/books/r2p.html
- Fowler M. Patterns of Enterprise Application Architecture — Lazy Load, Gateway, Remote Facade: martinfowler.com/eaaCatalog
- Cockburn A. Hexagonal Architecture — адаптеры как архитектурный стиль: alistair.cockburn.us/hexagonal-architecture
- Norvig P. Design Patterns in Dynamic Languages — какие паттерны исчезают в языках с функциями первого класса: norvig.com/design-patterns
- Go
io— декораторы в стандартной библиотеке: pkg.go.dev/io - WHATWG DOM Standard — композит в самой популярной объектной модели мира: dom.spec.whatwg.org
- Envoy — прокси как элемент инфраструктуры: envoyproxy.io
Что дальше
Мы научились соединять объекты в структуры. Осталось главное: как объекты разговаривают —
кто кому что посылает, кто хранит состояние диалога, как обменять if/else на объект и как
подписчик узнаёт об изменении. Об этом следующая статья:
Поведенческие паттерны: Strategy, Observer, Command, State и другие
Если нужен общий контекст — вернитесь к обзору каталога;
если интересно, как эти же идеи выглядят без классов, загляните в
функциональные паттерны, где декоратор оказывается
композицией функций, а адаптер — обычным map.