Структурные паттерны: 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: разъём не подходит
Интуиция
У вас есть код, который умеет работать с 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.
Виртуальный прокси: как это работает во времени
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%. Если доступ запрещён — это уже не фасад, а слой изоляции.
- Фасад не добавляет функциональности, он комбинирует существующую. Если внутри появилась логика, которой нет ни в одной подсистеме, — вы пишете сервис приложения, и это нормально, просто называйте вещи именами.
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 в
стандартной библиотеке.
Как выбрать: решающее дерево
Сводная таблица различий — то, что стоит помнить наизусть:
| Паттерн | Интерфейс на выходе | Поведение | Сколько обёрнуто | Ключевой вопрос |
|---|---|---|---|---|
| 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.