Когда паттерн растворяется в системе типов
В первой главе трека прозвучала мысль, к которой мы обещали вернуться: Питер Норвиг в докладе «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.
Что не растворяется никогда
Ошибочный вывод из всего вышесказанного: «в хорошем языке паттерны не нужны». Не растворяется целая группа, и у неё есть общий признак.
какой код выполнить"| C["Растворяется языком:
функции, дженерики, sealed, match"] B -->|"Про отношения объектов
во времени и пространстве"| D["Не растворяется"] D --> D1["Observer: подписки, время жизни,
порядок, отписка"] D --> D2["Proxy: перехват доступа,
ленивость, права, кэш"] D --> D3["Composite: рекурсивная структура
и обход дерева"] D --> D4["Adapter: несовпадение ЧУЖИХ контрактов"] D --> D5["Facade: сокращение поверхности
для чтения человеком"] D --> D6["Пулы, ресурсы, DI:
время жизни и владение"]
Причина в том, что синтаксис снимает только вопрос «как выразить выбор поведения». Он не снимает вопросы «кто кого уведомляет и когда отписывается», «кто владеет ресурсом», «как обойти дерево», «как склеить два чужих словаря». Это отношения между сущностями, и никакая языковая конструкция за вас их не спроектирует.
Отсюда практический вывод для всего трека: паттерны, которые растворяются, — это паттерны про синтаксис; паттерны, которые остаются, — это паттерны про архитектуру. Первые полезно уметь узнавать, чтобы не писать лишний код. Вторые полезно знать, потому что они и есть проектирование.
Цена трюкачества на уровне типов
Симметричная честность, без которой глава была бы агитацией.
| Приём | Что даёт | Чем платите |
|---|---|---|
| Дженерики с ограничениями | статическая диспетчеризация, нет косвенности | время компиляции, размер бинарника, длинные сообщения об ошибках |
| 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).
Общая карта портала с рекомендуемым порядком прохождения треков — дорожная карта.