Паттерны проектирования Когда паттерн растворяется в системе типов
0%

Когда паттерн растворяется в системе типов

Когда паттерн растворяется в системе типов

В первой главе трека прозвучала мысль, к которой мы обещали вернуться: Питер Норвиг в докладе «Design Patterns in Dynamic Languages» (1996) показал, что 16 из 23 паттернов GoF в динамических языках либо невидимы, либо радикально проще. Позже Пол Грэм сформулировал резче: паттерн — это признак того, что в языке чего-то не хватает, и вы дописываете недостающее руками при каждом использовании.

Формулировка звучит как выпад в сторону каталога, но полезна как инструмент диагностики. Прежде чем строить иерархию классов, стоит задать вопрос: не решает ли это язык, на котором я пишу? Часто решает — и тогда десять классов сворачиваются в три строки. А иногда не решает, и важно понимать, почему именно.

Эта глава закрывает трек тем же движением, каким он открывался: не «какие бывают паттерны», а какая сила порождает конструкцию и где эта сила уже снята языком.


Как языки поглощали паттерны

Каталог 1994 года — снимок практики на C++ того времени. Это не делает его бесполезным: силы остались теми же, изменились только средства.


Карта растворения

Паттерн GoF Конструкция, которая его растворяет Что остаётся
Strategy функция как значение, трейт/интерфейс + дженерик остаётся при выборе варианта в рантайме из конфига
Template Method функция высшего порядка с параметрами-шагами остаётся при большом числе необязательных хуков
Command замыкание, Runnable, кортеж данных остаётся, когда нужны undo, лог, очередь, сериализация
Decorator функция, оборачивающая функцию; middleware остаётся, когда обёрток много и они конфигурируются
Factory Method функция; конструктор первого класса остаётся как точка выбора реализации
Abstract Factory дженерик с ограничением; ассоциированные типы остаётся для семейств, выбираемых в рантайме
Singleton модуль (Python), enum (Java), object (Kotlin) остаётся как время жизни в DI-контейнере
Prototype семантика копирования: Clone, copy, record with почти ничего
Iterator генераторы, yield, ленивые последовательности остаётся в языках без генераторов
Visitor sealed-типы + сопоставление с образцом остаётся, когда типы стабильны, а операции добавляются постоянно
State перечисление + сопоставление; typestate на типах остаётся при сложных переходах с побочными эффектами
Null Object Option/Maybe + типы, запрещающие null остаётся как удобный no-op для эффектов без результата
Flyweight интернирование строк, value-типы, Copy-семантика остаётся при ручном управлении памятью
Builder именованные и необязательные аргументы, record with остаётся при валидации на этапе сборки и typestate
Interpreter замыкания; деревья выражений платформы остаётся при внешних правилах (глава 10)

Строку «Singleton» стоит прочитать дважды: паттерн не исчез, он распался на две части. Идея «один экземпляр на процесс» осталась и живёт в контейнере зависимостей; идея «глобальная точка доступа» умерла заслуженно.


Функции как значения: самое дешёвое растворение

Этот разбор уже был в функциональных паттернах и в главе о цене паттернов, поэтому здесь — только вывод в одном примере. Интерфейс с единственным методом — это функция, и в языке, где функция является значением, паттерн Strategy состоит из подписи типа:

from typing import Callable
from decimal import Decimal

# Было: интерфейс + три класса + фабрика.
# Стало: имя типа. Все «реализации» — обычные функции.
ShippingPolicy = Callable[["Order"], Decimal]

def pickup(order: "Order") -> Decimal:
    return Decimal(0)

def courier(zones: "ZoneTable", free_from: Decimal) -> ShippingPolicy:
    """Замыкание держит настройки — это ровно поля объекта-стратегии."""
    def policy(order: "Order") -> Decimal:
        if order.total >= free_from:
            return Decimal(0)
        return zones.price_for(order.address)
    return policy

Граница применимости у приёма есть, и она проходит по числу методов. Как только у стратегии появляется второй метод (cost и estimated_days), функция перестаёт подходить, и объект возвращается — уже по делу, а не по привычке.


Sealed-типы против Visitor: проблема расширения

Visitor — самый громоздкий паттерн каталога, и существует он ради одной задачи, известной как проблема расширения (expression problem, Филип Уодлер, 1998): у нас есть набор типов и набор операций над ними, и хочется добавлять и то, и другое, не меняя существующий код. Обычное ООП даёт первое (новый тип — новый класс), Visitor меняет полярность и даёт второе (новая операция — новый посетитель), но фиксирует набор типов.

Современный ответ на ту же задачу — закрытая (sealed) иерархия плюс сопоставление с образцом. Смысл тот же, что у Visitor: набор типов фиксирован, операции добавляются свободно. Разница в том, что вместо двойной диспетчеризации, метода accept в каждом классе и интерфейса посетителя с методом на каждый тип — обычная функция с match, а исчерпываемость проверяет компилятор.

// TypeScript: размеченное объединение — это sealed-иерархия
type Shape =
  | { kind: "circle"; r: number }
  | { kind: "rect"; w: number; h: number }
  | { kind: "triangle"; base: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":   return Math.PI * s.r ** 2;
    case "rect":     return s.w * s.h;
    case "triangle": return (s.base * s.height) / 2;
    default: {
      // Приём «проверка исчерпываемости»: если в Shape добавят вариант,
      // здесь возникнет ошибка компиляции — во всех местах разбора сразу.
      const _exhaustive: never = s;
      return _exhaustive;
    }
  }
}

// Новая операция — просто новая функция. Ни один существующий файл не тронут.
function perimeter(s: Shape): number { /* ... */ }
// Java 21: sealed + сопоставление с образцом даёт настоящую проверку исчерпываемости
sealed interface Shape permits Circle, Rect, Triangle {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
record Triangle(double base, double height) implements Shape {}

static double area(Shape s) {
    return switch (s) {                      // без default: компилятор требует полноты
        case Circle c   -> Math.PI * c.r() * c.r();
        case Rect r     -> r.w() * r.h();
        case Triangle t -> t.base() * t.height() / 2;
    };
}

Сравнение по существу:

Критерий Visitor (GoF) Sealed + сопоставление
Объём кода на операцию интерфейс + класс посетителя + accept в каждом типе одна функция
Добавить операцию новый посетитель новая функция
Добавить тип правка интерфейса посетителя и всех реализаций ошибка компиляции во всех местах разбора — что и нужно
Проверка полноты нет (легко забыть метод) компилятором (Java 21, Rust, TS через never, C# частично)
Где применим любой язык с ООП нужны sealed-типы и сопоставление
Читаемость логика операции размазана по классам логика операции в одном месте

Когда Visitor всё-таки остаётся правильным ответом. Язык без sealed-типов и сопоставления (классическая Java до 17, C++ без std::variant); необходимость подключать посетителей извне как расширения (см. точки расширения); обход с накоплением состояния по сложной структуре, где посетитель хранит контекст обхода. Компиляторы, кстати, обычно используют оба подхода одновременно: sealed-узлы AST плюс проходы, оформленные как посетители.


Дженерики и трейты: Strategy, выбранная на компиляции

Интерфейс в рантайме — это указатель на таблицу методов и косвенный вызов. Дженерик с ограничением — тот же контракт, но подставленный на этапе компиляции: вызов прямой, инлайнинг возможен, затрат на диспетчеризацию нет.

// Контракт как трейт — это Strategy без единого объекта-стратегии.
trait Shipping {
    fn cost(&self, order: &Order) -> Money;
}

struct Pickup;
struct Courier { zones: ZoneTable, free_from: Money }

impl Shipping for Pickup {
    fn cost(&self, _order: &Order) -> Money { Money::ZERO }
}
impl Shipping for Courier {
    fn cost(&self, order: &Order) -> Money {
        if order.total >= self.free_from { Money::ZERO } else { self.zones.price(&order.address) }
    }
}

// Статическая диспетчеризация: своя копия функции на каждый S, вызов прямой.
fn checkout_total<S: Shipping>(order: &Order, shipping: &S) -> Money {
    order.total + shipping.cost(order)
}

// Динамическая: одна копия кода, выбор реализации в рантайме — когда варианты
// приходят из конфигурации и на компиляции неизвестны.
fn checkout_total_dyn(order: &Order, shipping: &dyn Shipping) -> Money {
    order.total + shipping.cost(order)
}

Выбор между impl/дженериком и dyn — это ровно выбор «паттерн растворился» против «паттерн остался»: если множество вариантов известно на компиляции, платить за косвенность незачем. Цена статического пути — раздувание кода при мономорфизации и рост времени компиляции; подробно разобрано в обобщённом программировании и типах и трейтах Rust.

Тайпклассы (Haskell), трейты (Rust), концепты (C++20) и расширения-протоколы (Swift) добавляют к этому свойство, которого нет у интерфейсов ООП: реализацию можно добавить к чужому типу, не меняя его. Именно поэтому в квадранте выше они попадают в «расширяемо по обеим осям».


Typestate: состояние объекта, проверяемое компилятором

Самый эффектный приём главы. Классический State хранит состояние в поле и проверяет допустимость операций в рантайме: вызвали send() до connect() — исключение. Typestate поднимает состояние в тип, и недопустимый вызов перестаёт компилироваться.

use std::marker::PhantomData;

pub struct Set;      // маркерные типы: значений не имеют, существуют только для проверки
pub struct Unset;

pub struct RequestBuilder<U, M> {
    url: Option<String>,
    method: Option<Method>,
    timeout: Duration,
    _url_state: PhantomData<U>,
    _method_state: PhantomData<M>,
}

impl RequestBuilder<Unset, Unset> {
    pub fn new() -> Self {
        RequestBuilder {
            url: None, method: None, timeout: Duration::from_secs(30),
            _url_state: PhantomData, _method_state: PhantomData,
        }
    }
}

impl<U, M> RequestBuilder<U, M> {
    // Установка обязательного поля МЕНЯЕТ ТИП билдера: Unset -> Set.
    pub fn url(self, url: impl Into<String>) -> RequestBuilder<Set, M> {
        RequestBuilder {
            url: Some(url.into()), method: self.method, timeout: self.timeout,
            _url_state: PhantomData, _method_state: PhantomData,
        }
    }

    pub fn method(self, method: Method) -> RequestBuilder<U, Set> {
        RequestBuilder {
            url: self.url, method: Some(method), timeout: self.timeout,
            _url_state: PhantomData, _method_state: PhantomData,
        }
    }

    // Необязательное поле тип не меняет.
    pub fn timeout(mut self, d: Duration) -> Self { self.timeout = d; self }
}

// build() СУЩЕСТВУЕТ только для полностью заполненного билдера.
impl RequestBuilder<Set, Set> {
    pub fn build(self) -> Request {
        Request { url: self.url.unwrap(), method: self.method.unwrap(), timeout: self.timeout }
    }
}

// RequestBuilder::new().timeout(d).build();  // ошибка компиляции: метода build нет
// RequestBuilder::new().url("...").method(Method::Get).build();  // ок

Что здесь произошло с точки зрения трека. Классический Builder проверяет обязательные поля в build() и бросает исключение. Typestate-версия делает невозможным даже вызов: ошибка обнаруживается не в рантайме, не в тестах, а редактором, пока вы печатаете. Та же техника применяется к протоколам соединений (TcpStream<Connected> против TcpStream<Closed>), к транзакциям, к машинам состояний в прошивках.

Ограничения, из-за которых typestate не стал повсеместным:

  • Комбинаторика. Три обязательных поля — восемь типовых состояний. Пять — тридцать два. Приём работает для двух-трёх измерений, дальше сообщения об ошибках становятся нечитаемыми.
  • Состояние из данных. Если состояние определяется рантайм-условием (ответ сервера, флаг из конфигурации), типом его выразить нельзя — компилятор не знает будущего.
  • Стоимость обучения. Код с PhantomData понятен не всей команде, и это реальный фактор.

Newtype: Value Object ценой в одну строку

Последний приём — самый дешёвый и самый недоиспользуемый. Обёртка над примитивом с собственным типом убирает целый класс ошибок «перепутал аргументы», причём без единого класса в привычном смысле:

struct OrderId(String);
struct CustomerId(String);
struct Meters(f64);
struct Seconds(f64);

fn load(id: OrderId) -> Order { /* ... */ }
// load(CustomerId("c-1".into()));  // не компилируется — раньше это был инцидент

В рантайме newtype в Rust не стоит ничего (обёртка исчезает при компиляции), в Java до появления value-типов стоит аллокацию, в TypeScript — ноль (branded types существуют только в типах), в Python — проверку статическим анализатором (NewType). Практическое применение к границам системы разобрано в предыдущей главе; знаменитая потеря Mars Climate Orbiter из-за смешения фунтов и ньютонов — тот же класс ошибки, только ценой в 125 миллионов USD.


Что не растворяется никогда

Ошибочный вывод из всего вышесказанного: «в хорошем языке паттерны не нужны». Не растворяется целая группа, и у неё есть общий признак.

Причина в том, что синтаксис снимает только вопрос «как выразить выбор поведения». Он не снимает вопросы «кто кого уведомляет и когда отписывается», «кто владеет ресурсом», «как обойти дерево», «как склеить два чужих словаря». Это отношения между сущностями, и никакая языковая конструкция за вас их не спроектирует.

Отсюда практический вывод для всего трека: паттерны, которые растворяются, — это паттерны про синтаксис; паттерны, которые остаются, — это паттерны про архитектуру. Первые полезно уметь узнавать, чтобы не писать лишний код. Вторые полезно знать, потому что они и есть проектирование.


Цена трюкачества на уровне типов

Симметричная честность, без которой глава была бы агитацией.

Приём Что даёт Чем платите
Дженерики с ограничениями статическая диспетчеризация, нет косвенности время компиляции, размер бинарника, длинные сообщения об ошибках
Sealed + сопоставление проверка полноты компилятором добавление типа задевает все места разбора — иногда это дорого
Typestate недопустимый вызов не компилируется комбинаторный рост типов, порог входа
Newtype повсеместно нет путаницы аргументов шум конверсий на границах
Программирование на типах инварианты доказаны код читает половина команды; отладка «в компиляторе»

Граница здравого смысла проходит там же, где во всём треке: тип должен ловить ошибку, которая реально случается. Если за три года не было ни одного бага «построили запрос без URL», typestate для билдера — это спекулятивная обобщённость с новым лицом.

И обратное движение тоже бывает правильным: когда варианты приходят из конфигурации, из базы, от пользователя — статика бессильна, и правильный ответ — рантайм-структура из главы про правила как данные.


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

Ошибка Как выглядит Что делать
Классы там, где хватает функции Интерфейс с одним методом и три реализации-класса Тип-псевдоним функции
Visitor в языке с сопоставлением accept/visit в каждом узле Sealed-иерархия + match
switch без проверки полноты Новый вариант тихо попадает в default Проверка исчерпываемости (never, switch-выражение без default)
dyn/интерфейс там, где варианты известны Косвенный вызов в горячем цикле без причины Дженерик со статической диспетчеризацией
Строковые идентификаторы везде Перепутали order_id и customer_id Newtype / branded type
Typestate ради красоты Восемь маркерных типов на трёхполевой билдер Проверка в build(), если багов не было
Вера, что типы заменят тесты «Компилируется — значит работает» Типы ловят форму, тесты — поведение
Перенос паттернов из чужого языка Фабрики-фабрик в Python, как в Java 2004 Сначала спросить, что даёт ваш язык

Мини-итог

  • Значительная часть каталога GoF — компенсация отсутствующих языковых конструкций. Перед тем как строить иерархию, проверьте, не решает ли задачу сам язык.
  • Функция как значение растворяет Strategy, Command, Template Method и Decorator — пока у контракта один метод.
  • Sealed-типы с сопоставлением образцов заменяют Visitor и дают то, чего у Visitor не было: проверку полноты компилятором.
  • Дженерики и трейты превращают выбор реализации в решение времени компиляции: контракт остаётся, косвенность исчезает.
  • Typestate переводит проверку последовательности вызовов из рантайма в компиляцию; цена — комбинаторный рост типов.
  • Newtype — самый дешёвый приём с самым высоким отношением пользы к затратам.
  • Не растворяются паттерны про отношения: Observer, Proxy, Composite, Adapter, Facade, управление ресурсами и сборка графа зависимостей. Они и есть проектирование.
  • Типовой трюк оправдан ровно тогда, когда ловит ошибку, которая у вас действительно случается.

Источники

  • Peter Norvig, «Design Patterns in Dynamic Languages», 1996 — norvig.com/design-patterns.
  • Philip Wadler, «The Expression Problem», 1998 — homepages.inf.ed.ac.uk.
  • Erich Gamma et al., «Design Patterns», 1994 — раздел «Implementation» каждого паттерна, где прямо обсуждается зависимость от возможностей языка.
  • «Design Patterns 15 Years Later: An Interview with Erich Gamma, Richard Helm, Ralph Johnson» — informit.com.
  • JEP 441, «Pattern Matching for switch» и JEP 409, «Sealed Classes» — openjdk.org/jeps/441, openjdk.org/jeps/409.
  • TypeScript Handbook, «Discriminated Unions» и проверка исчерпываемости — typescriptlang.org.
  • Rust API Guidelines и типовое состояние — cliffle.com/blog/rust-typestate.
  • Alexis King, «Parse, don’t validate» — lexi-lambda.github.io.
  • Scott Wlaschin, «Domain Modeling Made Functional», 2018 — систематическое проектирование через типы.

Что дальше

Это последняя статья трека «Паттерны проектирования». Мы прошли путь от происхождения каталога через порождающие, структурные, поведенческие, конкурентные и функциональные паттерны, разобрали антипаттерны и цену паттернов, а затем — практическую половину, которой в каталоге 1994 года не было: сборку графа зависимостей, управление ресурсами, правила как данные, точки расширения, паттерны границ — и закончили тем, что часть каталога растворяется в системе типов.

Куда двигаться дальше, в зависимости от того, чего вам не хватает.

  • Не хватает фундамента под решениями о структуре кода — читайте принципы проектирования и парадигмы программирования: паттерны выводятся из них, а не наоборот.
  • Паттерны стали тесны, вопросы теперь про системы целиком — переходите к архитектурным паттернам и к DDD: там те же силы, но на уровне сервисов и границ.
  • Нужна база под производительностью решенийструктуры данных и алгоритмы.
  • Хочется закрепить всё на конкретном языке — треки Go, TypeScript, C#, Rust, Elixir. Особенно полезно сравнить, как одна и та же сила выражается в языке с наследованием (C#), в языке с интерфейсами-утиными типами (Go), в языке с владением и трейтами (Rust) и в языке без изменяемого состояния (Elixir).

Общая карта портала с рекомендуемым порядком прохождения треков — дорожная карта.

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

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

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

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