Алгебраические типы данных и сопоставление с образцом
Тип, который врёт
Начнём не с определения, а с кода, который вы точно писали. Загрузка данных на экране:
interface RequestState {
loading: boolean;
error: string | null;
data: User[] | null;
}
Выглядит невинно. Теперь посчитаем, сколько разных значений может принять этот тип. boolean — 2. string | null — бесконечность плюс один, но для наших целей достаточно «есть или нет», то есть 2. User[] | null — тоже «есть или нет», 2. Итого 2 × 2 × 2 = 8 комбинаций.
А сколько из них осмысленны? Четыре: «ничего не запрашивали», «грузим», «упало», «пришло». Остальные четыре — мусор:
{ loading: true, error: "500", data: null } // грузим и одновременно упали?
{ loading: true, error: null, data: [...] } // грузим и уже показываем?
{ loading: false, error: "500", data: [...] } // что рисовать — ошибку или данные?
{ loading: true, error: "500", data: [...] } // всё сразу
Половина пространства состояний — это состояния, которых не должно существовать. Но тип их разрешает, а значит, рано или поздно код их создаст. И тогда начинается знакомое: спиннер поверх данных, «ошибка» под успешно загруженным списком, if (data && !error && !loading) в каждом компоненте.
Стандартная реакция — дисциплина: «мы же договорились не выставлять error, пока loading». Но договорённости не проверяются компилятором, а состояния комбинируются экспоненциально: добавили четвёртый флаг — стало 16 комбинаций и 12 невозможных.
Второй симптом той же болезни — null как способ сказать «значения нет»:
def find_user(uid: int) -> User | None: ...
user = find_user(42)
print(user.name) # AttributeError, если пользователя нет
Тип честно предупредил, но не заставил проверить. Тони Хоар, придумавший null-ссылку в ALGOL W в 1965 году, назвал её «ошибкой на миллиард долларов» именно за это: язык даёт возможность забыть.
Третий симптом — «stringly typed» код, где вариант закодирован строкой:
if event["type"] == "payment": # опечатка "paymnet" — тихо уходит в else
...
elif event["type"] == "refund":
...
Все три боли — про одно и то же: в языке нет способа сказать «это ровно одно из перечисленного, и у каждого варианта свои данные». Алгебраические типы данных (ADT) — этот способ. Сопоставление с образцом (pattern matching) — способ их разбирать.
Произведение: «и»
Начнём с половины, которая есть везде. Тип-произведение склеивает несколько значений в одно: чтобы построить его, нужны все компоненты сразу.
-- Haskell: запись с именованными полями
data Point = Point { x :: Double, y :: Double }
type Point = { x: number; y: number };
from dataclasses import dataclass
@dataclass(frozen=True) # frozen — неизменяемое значение, см. статью про immutability
class Point:
x: float
y: float
defmodule Point do
defstruct [:x, :y]
end
Кортеж, структура, class с полями, запись — всё это произведения. Название буквальное: количество значений типа Point — это количество значений x, умноженное на количество значений y. Если Флаг × Цвет, где флаг двузначный, а цвет трёхзначный, — получится ровно 6 комбинаций.
Произведения умеет любой язык, начиная с C. Проблема не в них.
Сумма: «или»
Тип-сумма (он же вариант, tagged union, discriminated union) говорит: значение — это ровно один из перечисленных вариантов, и у каждого варианта свой набор данных.
data Shape
= Circle Double -- радиус
| Rectangle Double Double -- ширина и высота
| Triangle Double Double Double
Одна строка, а сказано много: Shape — либо круг с одним числом, либо прямоугольник с двумя, либо треугольник с тремя. Никакого «прямоугольника с радиусом». Никакого «круга без данных». Никакого четвёртого варианта.
В мейнстриме это выражается через дискриминант — поле-метку с литеральным типом:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "triangle"; a: number; b: number; c: number };
from dataclasses import dataclass
@dataclass(frozen=True)
class Circle: radius: float
@dataclass(frozen=True)
class Rect: width: float; height: float
@dataclass(frozen=True)
class Triangle: a: float; b: float; c: float
Shape = Circle | Rect | Triangle # алиас размеченного объединения (Python 3.10+)
# В Elixir типов на этапе компиляции нет, роль варианта играет
# помеченный кортеж (tagged tuple) или структура, а контракт описывается в typespec.
@type shape ::
{:circle, float()}
| {:rect, float(), float()}
| {:triangle, float(), float(), float()}
Обратите внимание на разницу философий. В Haskell/Rust/F#/Scala сумма — часть системы типов, компилятор её проверяет. В TypeScript сумма эмулируется через литеральные типы, но проверяется тоже компилятором. В Python Union проверяется только внешним type checker (mypy/pyright), в рантайме это просто разные классы. В Elixir это соглашение, которое проверяет Dialyzer — и то не строго.
Почему они «алгебраические»
Название — не понты, а буквальная арифметика. Каждому типу сопоставим число его значений (кардинальность):
Bool→ 2()(unit, пустой кортеж) → 1Void(тип без значений) → 0(A, B)→|A| × |B|— произведениеA | B→|A| + |B|— сумма
Из этой арифметики немедленно следуют полезные факты.
Maybe A — это 1 + |A|. Тип data Maybe a = Nothing | Just a добавляет к значениям A ровно одно новое — «ничего». Это не то же самое, что null, потому что «ничего» здесь отдельное значение отдельного типа, а не универсальный призрак, который может притвориться чем угодно.
Функция A -> B — это |B| в степени |A|. Отсюда английское «exponential type» и тот факт, что функций из Bool в Bool ровно четыре.
Дистрибутивность работает. Тип (Bool, A | B) изоморфен (Bool, A) | (Bool, B): 2 × (a + b) = 2a + 2b. Это не игра — это практический рефакторинг: общее поле можно вынести за скобку или, наоборот, вдавить внутрь вариантов.
Наконец, самое красивое: производная типа по переменной даёт тип «дырки» в структуре. Производная от списка L(a) = 1/(1 - a) равна 1/(1-a)^2 — то есть паре списков, что в точности соответствует «позиции внутри списка»: элементы слева и элементы справа. Это не совпадение, а результат Конора Макбрайда из работы «The Derivative of a Regular Type is its Type of One-Hole Contexts» — так строятся зипперы, структуры для навигации по неизменяемым деревьям. Формальную сторону — полукольца, изоморфизмы, комбинаторные виды — стоит смотреть в треке mathematics; для написания кода она не нужна.
Сопоставление с образцом: разбор, а не проверка
ADT бесполезны без обратной операции. Собрали значение конструктором — надо уметь его разобрать. Наивный способ — спрашивать про тег и лезть за полями:
// плохо: проверка и извлечение разъехались
if (shape.kind === "rect") {
const s = (shape as Rect).width * (shape as Rect).height;
}
Сопоставление с образцом делает это одним движением: паттерн одновременно проверяет форму и связывает переменные.
area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
area (Triangle a b c) = let s = (a + b + c) / 2
in sqrt (s * (s-a) * (s-b) * (s-c))
def area({:circle, r}), do: :math.pi() * r * r
def area({:rect, w, h}), do: w * h
def area({:triangle, a, b, c}) do
s = (a + b + c) / 2
:math.sqrt(s * (s - a) * (s - b) * (s - c))
end
import math
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return math.pi * r * r
case Rect(width=w, height=h):
return w * h
case Triangle(a=a, b=b, c=c):
s = (a + b + c) / 2
return math.sqrt(s * (s - a) * (s - b) * (s - c))
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2; // здесь shape сужен до Circle
case "rect": return shape.width * shape.height; // а здесь — до Rect
case "triangle": {
const s = (shape.a + shape.b + shape.c) / 2;
return Math.sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c));
}
}
}
TypeScript не имеет паттернов как синтаксиса, но у него есть сужение типов (narrowing): проверка shape.kind === "circle" заставляет компилятор внутри ветки считать shape кругом. Практический эффект тот же, синтаксис беднее. Подробности — в handbook по narrowing.
Ключевое отличие от if-цепочки: паттерн вложенный и структурный. Можно матчить на несколько уровней вглубь одним выражением:
case response do
{:ok, %{status: 200, body: %{"items" => [first | _rest]}}} -> {:first, first}
{:ok, %{status: 200, body: %{"items" => []}}} -> :empty
{:ok, %{status: code}} when code >= 400 -> {:http_error, code}
{:error, reason} -> {:transport_error, reason}
end
Одно выражение проверяет: успешность вызова, HTTP-код, наличие ключа, непустоту списка — и попутно вытаскивает первый элемент. На if это пять уровней вложенности.
Проверка исчерпывающности — вот ради чего всё
Само по себе разбиение на варианты — сахар. Настоящая ценность в том, что компилятор знает полный список вариантов и ругается, если вы забыли ветку.
Добавили в Shape четвёртый вариант Ellipse. В Haskell с -Wincomplete-patterns (входит в -Wall):
warning: [-Wincomplete-patterns]
Pattern match(es) are non-exhaustive
In an equation for 'area': Patterns of type 'Shape' not matched: Ellipse _ _
Компилятор нашёл все места, которые надо дописать. Это превращает страшный рефакторинг «добавить новое состояние в систему» в механическую процедуру: правь, пока не перестанет ругаться.
В TypeScript исчерпывающность не встроена, но выражается через тип never:
function assertNever(x: never): never {
throw new Error(`Необработанный вариант: ${JSON.stringify(x)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "rect": return shape.width * shape.height;
default:
// если все варианты разобраны, сюда попадает never и всё компилируется.
// добавили "triangle" в тип, но не в switch — тут ошибка компиляции.
return assertNever(shape);
}
}
Это идиома, которую стоит завести в каждом TS-проекте. Альтернатива — switch без default при включённых strictNullChecks и noImplicitReturns: тогда пропущенная ветка даст «не все пути возвращают значение».
В Python исчерпывающность даёт не рантайм, а type checker. mypy и pyright понимают ту же идиому:
from typing import assert_never
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r): return math.pi * r * r
case Rect(width=w, height=h): return w * h
case _ as unreachable: assert_never(unreachable) # mypy/pyright проверит
Без запуска mypy это просто исключение в рантайме. Честно говоря — половина пользы.
В Elixir полноценной проверки исчерпывающности нет: case без подходящей ветки бросит CaseClauseError, вызов функции — FunctionClauseError, и узнаете вы об этом в проде. Компенсируют тремя вещами: typespec + Dialyzer, дисциплина «не писать _ -> ... в бизнес-логике» и философия let it crash (см. трек elixir) — упасть на неизвестном варианте лучше, чем молча его проглотить. С версии 1.17 Elixir постепенно внедряет систему типов с проверкой ветвлений, но на 2026 год она покрывает не всё.
Отдельная категория языков — те, где исчерпывающность обязательна: Rust (match без всех веток не компилируется), Gleam, F#, Scala 3 с sealed. Java подтянулась в JEP 441: switch по sealed-интерфейсу проверяется компилятором.
Make illegal states unrepresentable
Теперь вернёмся к боли из начала статьи и переделаем её через сумму.
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "failure"; error: string }
| { status: "success"; data: T };
Было 8 состояний, стало 4. Невозможные состояния теперь не выражаются синтаксически — их нельзя создать даже намеренно. И заметьте побочный эффект: data доступно только внутри ветки success, поэтому проверка «а вдруг null» исчезает не потому, что мы её забыли, а потому, что она не нужна.
Формулировка «make illegal states unrepresentable» принадлежит Ярону Мински из Jane Street — см. его серию «Effective ML». Развёрнутый практикум на F# — «Designing with Types» Скотта Влашина, там же разобрано, как поднимать инварианты в типы постепенно.
Полезно видеть ADT как компактную запись конечного автомата:
Каждое состояние автомата — вариант суммы, а данные, привязанные к состоянию, живут внутри варианта. Переходы — функции RequestState -> RequestState. Это тот приём, на котором держатся Elm Architecture, Redux с дискриминированными экшенами и большинство современных стейт-менеджеров.
Приём масштабируется дальше поля-статуса. Классический пример из Влашина — контакт, у которого должен быть e-mail или телефон, но не «ни одного»:
// плохо: разрешает контакт вообще без связи
type Contact = { email?: string; phone?: string };
// хорошо: «хотя бы один» выражено типом
type ContactInfo =
| { kind: "email"; email: Email }
| { kind: "phone"; phone: Phone }
| { kind: "both"; email: Email; phone: Phone };
И ещё одно правило, которое стоит больше, чем кажется: новый тип на каждое понятие. Email вместо string, UserId вместо int. Тогда sendTo(userId, email) нельзя вызвать с перепутанными аргументами. В Haskell это newtype с нулевой стоимостью в рантайме, в TypeScript — branded types, в Python — NewType.
Рекурсивные ADT: где раскрывается настоящая сила
Вариант суммы может ссылаться на сам тип. Отсюда — деревья, списки, JSON, синтаксис языков.
data Expr
= Num Double
| Add Expr Expr
| Mul Expr Expr
| Neg Expr
| Var String
Пять строк описывают бесконечное множество арифметических выражений. Выражение 2 * (x + 3) — это дерево:
Интерпретатор пишется буквально по структуре типа — одна ветка на конструктор:
eval :: Map String Double -> Expr -> Either String Double
eval _ (Num n) = Right n
eval env (Var v) = maybe (Left ("неизвестная переменная: " ++ v)) Right (Map.lookup v env)
eval env (Add a b) = (+) <$> eval env a <*> eval env b
eval env (Mul a b) = (*) <$> eval env a <*> eval env b
eval env (Neg a) = negate <$> eval env a
То же на Python — видно, что дело не в языке, а в форме данных:
from dataclasses import dataclass
@dataclass(frozen=True)
class Num: value: float
@dataclass(frozen=True)
class Var: name: str
@dataclass(frozen=True)
class Add: left: "Expr"; right: "Expr"
@dataclass(frozen=True)
class Mul: left: "Expr"; right: "Expr"
@dataclass(frozen=True)
class Neg: arg: "Expr"
Expr = Num | Var | Add | Mul | Neg
def evaluate(expr: Expr, env: dict[str, float]) -> float:
match expr:
case Num(value=v): return v
case Var(name=n): return env[n]
case Add(left=l, right=r): return evaluate(l, env) + evaluate(r, env)
case Mul(left=l, right=r): return evaluate(l, env) * evaluate(r, env)
case Neg(arg=a): return -evaluate(a, env)
Сложность: обход дерева — O(n) по времени, где n — число узлов, и O(h) по памяти на стек рекурсии, где h — высота. Для сбалансированного дерева h = O(log n), для вырожденного (1+1+1+...) — O(n), и глубокое выражение уронит стек. Лечится хвостовой рекурсией с явным стеком или трамплином — подробно в статье про рекурсию.
А теперь самое интересное: оптимизации пишутся как переписывание паттернов.
simplify :: Expr -> Expr
simplify (Add (Num 0) e) = simplify e -- 0 + e = e
simplify (Add e (Num 0)) = simplify e
simplify (Mul (Num 0) _) = Num 0 -- 0 * e = 0
simplify (Mul (Num 1) e) = simplify e
simplify (Mul a b) = Mul (simplify a) (simplify b)
simplify (Add a b) = Add (simplify a) (simplify b)
simplify (Neg (Neg e)) = simplify e -- двойное отрицание
simplify e = e
Каждое правило алгебры читается как строчка из учебника. Вложенные паттерны вроде Mul (Num 0) _ и Neg (Neg e) — то, ради чего сопоставление с образцом вообще придумали: описывать форму данных на глубину, а не только верхний тег. Ровно так устроены реальные оптимизаторы компиляторов и normalizer’ы в системах символьных вычислений.
Арсенал паттернов
Паттерны — это отдельный маленький язык. Что в нём бывает:
Guard — дополнительное условие поверх формы. Важная деталь: в большинстве языков guard’ы не учитываются проверкой исчерпывающности (компилятор не решает арифметику), поэтому when x > 0 / when x < 0 без третьей ветки — потенциальный рантайм-краш:
def classify(n) when n > 0, do: :positive
def classify(n) when n < 0, do: :negative
def classify(0), do: :zero # без этой строки classify(0) упадёт
As-паттерн — связать целое и разобрать его одновременно:
firstTwice all@(x:_) = x : all -- all — весь список, x — его голова
case Point(x=0, y=0) as origin: # origin — целая точка
log(origin)
Бинарные паттерны Elixir — то, чего нет почти нигде: сопоставление по битам, прямо в заголовке функции. Разбор IPv4-заголовка:
def parse_ipv4(<<4::4, ihl::4, _dscp::8, total_len::16, rest::binary>>) do
{:ok, %{ihl: ihl, total_length: total_len, payload: rest}}
end
def parse_ipv4(_), do: {:error, :not_ipv4}
Это одна из причин, по которой Erlang/Elixir доминируют в телекоме: парсинг протоколов пишется декларативно и компилируется в эффективный код. См. документацию по binaries.
Active patterns (F#) и view patterns (Haskell) решают проблему абстракции: паттерны обычно завязаны на конструкторы, то есть на внутреннее представление. Если однажды поменять представление, все паттерны сломаются. Идея, предложенная Филом Уодлером ещё в 1987 году, — позволить матчить через функцию-проекцию, сохраняя инкапсуляцию. В Python аналог — протокол __match_args__ и case Class(...), который тоже отвязывает паттерн от порядка полей.
Полезное правило про порядок: паттерны проверяются сверху вниз, первый подошедший выигрывает. Отсюда самая частая ошибка — общий паттерн выше частного:
case value do
x -> "любое" # съедает всё
0 -> "ноль" # недостижимо; компилятор предупредит
end
Как это компилируется (и почему это быстро)
Наивная реализация паттернов — цепочка проверок сверху вниз, O(k) тестов на k веток. Реальные компиляторы строят дерево решений: анализируют все паттерны сразу и выбирают порядок проверок так, чтобы каждый тег проверялся один раз. Для суммы это обычно один косвенный переход по таблице — то есть O(1), независимо от числа вариантов.
Каноническая работа — Люк Маранже, «Compiling Pattern Matching to Good Decision Trees» (ML'08); тот же автор описал алгоритм проверки исчерпывающности и обнаружения недостижимых веток в «Warnings for pattern matching» (JFP 2007). Именно эти алгоритмы стоят за предупреждениями в OCaml, Haskell, Rust и Scala.
Практический вывод: switch по строковому полю kind в TypeScript — это хеш-сравнение строк на каждой ветке, а match по конструктору в Haskell/Rust/OCaml — чтение тега и прыжок. Разница обычно неощутима, но в горячем цикле числовой тег быстрее строкового.
Цена: где ADT мешают
Обещал честно — вот честно.
1. Expression problem. Это фундаментальный компромисс, сформулированный Уодлером в 1998 году. Есть данные (варианты) и есть операции над ними. ADT + pattern matching делают добавление операции тривиальным (пишем новую функцию, старый код не трогаем) и добавление варианта дорогим (надо править все функции). ООП с полиморфизмом — ровно наоборот: новый класс добавляется без правки старых, а новый метод требует лезть во все классы.
дёшево: одна функция"] A2["новый вариант
дорого: править все функции"] end subgraph OOP["ООП + полиморфизм"] O1["новый класс
дёшево: один файл"] O2["новый метод
дорого: править все классы"] end ADT -.->|"один и тот же
компромисс с двух сторон"| OOP style A1 fill:#4c7fa8,stroke:#2f5a7d,color:#fff style O1 fill:#4c7fa8,stroke:#2f5a7d,color:#fff style A2 fill:#a04a4a,stroke:#7a3535,color:#fff style O2 fill:#a04a4a,stroke:#7a3535,color:#fff
Вывод не «ADT лучше», а выбирайте по оси изменчивости. Домен с фиксированным набором сущностей и растущим набором операций (AST, протокольные сообщения, состояния UI, события) — ADT. Домен с растущим набором сущностей и стабильным набором операций (плагины, драйверы, стратегии, рендереры фигур) — интерфейсы и полиморфизм. Это же объясняет, почему в компиляторах доминируют ADT, а в GUI-фреймворках — классы.
Замечу: «дорого» при добавлении варианта частично компенсируется проверкой исчерпывающности — компилятор сам покажет весь список мест. Ошибиться нельзя, просто работы много. Это дорого, но безопасно — что обычно лучше, чем дёшево и опасно.
2. Производительность. Значения суммы обычно боксируются: каждое Just x или {:ok, v} — отдельная аллокация в куче плюс тег. В горячем цикле, обрабатывающем миллионы элементов, это заметно: вместо плотного массива чисел получается массив указателей, и кэш процессора начинает промахиваться.
Смягчения по языкам: в Rust enum укладывается в размер максимального варианта плюс дискриминант — без аллокации; там же работает niche optimization, благодаря которой Option<&T> занимает столько же, сколько &T. GHC умеет UNPACK-прагмы и strictness-анализ. JVM/.NET c записями и sealed всё равно аллоцируют объекты. В Python dataclass со slots=True заметно снижает накладные расходы. Практический совет один: на границах системы моделируйте через ADT, в горячих ядрах — измеряйте и, если нужно, спускайтесь к плоским представлениям.
3. Кривая обучения и читаемость. Глубоко вложенные паттерны с guard’ами становятся нечитаемыми быстрее, чем кажется автору:
# так делать не надо
case {user, order, payment} do
{%{role: :admin}, %{status: :draft, items: [_ | _]}, {:ok, %{amount: a}}} when a > 0 -> ...
end
Три уровня вложенности и кортеж-контейнер, собранный только ради матчинга. Разбейте на функции с говорящими именами. Правило большой пальца: больше двух уровней вложенности в паттерне — извлеките предикат или вспомогательную функцию.
4. Комбинаторный взрыв при матчинге нескольких значений. Матч по паре из двух пятивариантных сумм — 25 комбинаций. Компилятор потребует их закрыть, и появляется соблазн написать _ -> ..., который убивает всю ценность исчерпывающности. Обычно это сигнал, что модель неверна: два независимых состояния лучше обрабатывать по отдельности либо свернуть в одно, где невозможные пары не выражаются.
5. Эволюция схем и совместимость. ADT предполагают закрытый мир: список вариантов известен на компиляции. Для внешних контрактов (события в Kafka, API) это неудобно — старый потребитель падает на новом варианте. Здесь нужен явный «unknown»-вариант и версионирование, иначе строгость типа превращается в отказ в обслуживании. См. трек data-engineering про эволюцию схем.
6. Динамические языки дают только половину. В Python и Elixir паттерны есть, а гарантий нет: без mypy/Dialyzer забытая ветка обнаружится в проде. ADT в динамике — это документация с проверкой формы, но не с проверкой полноты. Не стоит обещать себе безопасность, которой нет.
Как это выглядит в проде
Несколько мест, где ADT реально дают выигрыш прямо завтра.
Результаты вместо исключений. Result<T, E> / Either — сумма «получилось или нет», где ошибка часть типа, а не побочный канал. Об этом целая статья дальше по треку: обработка ошибок.
Доменные события. Событие — сумма вариантов, обработчик — match. Добавили новый тип события — компилятор показал все проекции, которые надо дописать. Это резко снижает стоимость эволюции event-sourced систем (трек ddd).
Состояния UI и загрузки. Тот самый RequestState из начала статьи. Здесь выигрыш измеряется в количестве не случившихся багов вида «спиннер поверх ошибки».
Парсинг и протоколы. Результат парсера — сумма «токен такой-то», AST — рекурсивная сумма. Elixir с бинарными паттернами закрывает ещё и сетевой уровень.
Конфигурация. Вместо «строка, которая может быть URL, путём или inline-значением» — три варианта, разобранные один раз на входе и дальше типизированные.
Общий приём: валидируйте на границе, внутри работайте с типами, которые не могут быть неправильными. Александр Челлендж сформулировал это как «parse, don’t validate» — вместо функции validate(input): bool пишите parse(input): Result<Domain, Error>, которая возвращает уже разобранное значение. Разбор «Parse, don’t validate» Алексиса Кинг — обязательное чтение к этой статье.
Мини-итог
- Произведение — «и»: запись, кортеж, структура. Есть везде, проблем не создаёт.
- Сумма — «или»: ровно один вариант, у каждого свои данные. Именно её не хватает в мейнстриме, и именно она убирает невозможные состояния.
- Число значений типа считается арифметикой:
×для произведений,+для сумм. ОтсюдаMaybe = 1 + aи понимание, почему три булевых флага — плохая модель. - Сопоставление с образцом проверяет форму и связывает переменные одним движением, работает вглубь и компилируется в дерево решений — быстро.
- Проверка исчерпывающности — главная ценность связки. В Haskell/Rust/F#/Scala встроена, в TypeScript эмулируется через
never, в Python требует mypy, в Elixir почти отсутствует. - Правило моделирования: make illegal states unrepresentable. Если тип допускает бессмысленное значение — тип неверный.
- Цена: expression problem (новый вариант дорог), боксирование и промахи кэша, нечитаемые вложенные паттерны, закрытый мир при эволюции внешних схем.
- Практика:
Result, доменные события, состояния UI, парсеры, конфигурация. Разбирайте на границе — внутри держите типы, которые не врут.
Источники и что почитать:
- Алексис Кинг, «Parse, don’t validate»
- Скотт Влашин, «Designing with Types»
- Ярон Мински, «Effective ML»
- Фил Уодлер, «The Expression Problem»
- Люк Маранже, «Compiling Pattern Matching to Good Decision Trees»
- Конор Макбрайд, «The Derivative of a Regular Type is its Type of One-Hole Contexts»
- PEP 636 — Structural Pattern Matching Tutorial
- TypeScript Handbook: Narrowing
- Elixir: Pattern matching
Обзорный взгляд на ФП как парадигму — в статье Функциональное программирование трека парадигм.
Что дальше
Мы научились описывать данные так, чтобы они не врали, и разбирать их так, чтобы компилятор ловил забытые случаи. Следующий шаг — заметить, что многие ADT (Maybe, Either, список, дерево) устроены похоже: это контейнеры, к содержимому которых хочется применять функции, не разбирая контейнер вручную каждый раз. Из этого наблюдения вырастают функторы и аппликативы.
Функторы и аппликативы: контейнеры, к которым применяют функции