Паттерны проектирования Структурные паттерны: Adapter, Decorator, Facade, Proxy, Composite
0%

Структурные паттерны: Adapter, Decorator, Facade, Proxy, Composite

Структурные паттерны: 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

Карта раздела

Дальше по каждому: интуиция → строгая формулировка → рабочий код → цена → где ломается.


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 и о том, чтобы конвертация не создавала мусор.

Типичные ошибки

  1. Адаптер, который «немножко» реализует бизнес-логику. Появляется if order.is_vip: ... — и вот у вас домен размазан по интеграционному слою. Правило: адаптер имеет право на конвертацию типов, единиц измерения, кодов ошибок и на нормализацию исключений. Всё остальное — наверх.
  2. Протекающая абстракция. Ваш PaymentGateway внезапно принимает stripe_customer_id. Это значит, что интерфейс спроектирован «снизу», от SDK, а не «сверху», от нужд домена. Проектируйте Target по потребности клиента, даже если адаптеру потом будет неудобно — неудобство локализовано.
  3. Адаптер на один-единственный вызов. Если вы обернули ровно один метод и другой реализации никогда не будет — вы, возможно, просто добавили файл. См. Паттерны в реальном коде.
  4. Забыли про ошибки. 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 иначе как по скорости.

Стек декораторов вокруг HTTP-клиента

Код

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

Оба реализуют тот же интерфейс, оба держат ссылку внутрь. Различия — практические, а не философские:

  1. Кто владеет объектом. Декоратору обёрнутый объект передают снаружи; прокси часто создаёт и владеет им сам (или вовсе не создаёт, пока не понадобится).
  2. Всегда ли делегирует. Декоратор делегирует всегда (он же расширяет). Прокси имеет право не вызвать реальный объект: отказать по правам, отдать из кэша, вернуть заглушку.
  3. Кто знает про существование. Декораторы предполагают, что клиент осознанно собирает цепочку. Прокси часто подсовывают клиенту прозрачно — он думает, что держит настоящий объект.

Практическое правило: если ваша обёртка когда-либо не зовёт внутренний объект — это 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

Жизненный цикл прокси удобно представлять как автомат — и именно из него видно все проблемы:

Типичные ошибки

  1. N+1 запрос. Прокси делает каждое обращение дешёвым по коду и дорогим по факту. Список из 100 заказов, у каждого ленивый customer — 101 запрос вместо одного JOIN. Это самая дорогая ошибка, порождаемая паттерном; лечится batch-загрузкой (selectin, dataloader) или явным eager-фетчем. Документация Hibernate прямо это обсуждает: docs.jboss.org/hibernate/orm.
  2. Прокси живёт дольше контекста. Ленивый объект отдали в шаблон/в другой поток после закрытия сессии — получили исключение в момент рендеринга, то есть максимально далеко от причины.
  3. Прокси, врущий про идентичность. proxy == real_object даёт False, type(proxy) — не тот класс. Код с isinstance, сериализация, кэш по ключу-объекту ломаются молча.
  4. Скрытая сеть. 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

Типичные ошибки

  1. Фасад-бог. Начинается с place_order, через год в нём 40 методов и все подсистемы системы. Это уже God Object (см. Антипаттерны). Признак: методы фасада не имеют между собой ничего общего, кроме того что «удобно позвать отсюда». Лечение — несколько узких фасадов по сценариям, а не один широкий.
  2. Фасад, который просто переименовывает. facade.get_user(id)repo.get_user(id) и так 30 раз. Это не упрощение, это налог. Фасад оправдан там, где 1 вызов наружу = N вызовов внутрь.
  3. Фасад, ставший обязательным. Когда подсистемы прячут в приватные модули и оставляют только фасад, любой нестандартный сценарий требует расширения фасада — и он растёт по пункту 1.
  4. Транзакционные границы размазаны. В примере выше 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 — в треке «Принципы проектирования».

Типичные ошибки

  1. Циклы в «дереве». Если разрешить узлу быть ребёнком двух родителей и не проверять, получится граф — и size() уйдёт в бесконечную рекурсию. Либо запрещайте при add, либо ведите visited.
  2. Родительские ссылки без дисциплины. Двусторонние связи удобны (node.parent) и легко рассинхронизируются при remove. Устанавливайте/снимайте parent только внутри add/remove.
  3. Мутабельные общие поддеревья. Один и тот же Directory вставлен в два места — изменение видно в обоих. Если это не задумано (а это редко задумано), клонируйте: см. Prototype в Порождающих паттернах.
  4. Композит там, где данные плоские. Если иерархия всего два уровня и никогда не станет глубже, список списков читается лучше, чем абстрактный класс с двумя наследниками.

В проде

  • 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 тот же то же пул «слишком много объектов»

Общая цена структурных паттернов

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

  1. Длина стека. Пять слоёв обёрток — пять кадров в каждом трейсе. Продакшн-инцидент, где надо быстро понять причину по логу, становится ощутимо дороже.
  2. Непрозрачность потока управления. «Куда уходит вызов?» перестаёт отвечаться переходом по определению в IDE: определение ведёт в интерфейс, а кто там за ним — решается сборкой в другом файле. Это главная причина, по которой злоупотребление паттернами делает код «read-hostile».
  3. Аллокации. Обёртки — объекты. Если цепочка строится на запрос, а не на старте приложения, вы платите GC-налог пропорционально трафику.
  4. Ложь про типы. isinstance, ==, сериализация, рефлексия — всё это видит обёртку, а не содержимое. Прокси-фреймворки (Spring AOP, Hibernate) регулярно на этом обжигаются.
  5. Стоимость понимания. 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.

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

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

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

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