Функциональное программирование Алгебраические типы данных и сопоставление с образцом
0%

Алгебраические типы данных и сопоставление с образцом

Алгебраические типы данных и сопоставление с образцом

Тип, который врёт

Начнём не с определения, а с кода, который вы точно писали. Загрузка данных на экране:

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, пустой кортеж) → 1
  • Void (тип без значений) → 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 делают добавление операции тривиальным (пишем новую функцию, старый код не трогаем) и добавление варианта дорогим (надо править все функции). ООП с полиморфизмом — ровно наоборот: новый класс добавляется без правки старых, а новый метод требует лезть во все классы.

Вывод не «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, парсеры, конфигурация. Разбирайте на границе — внутри держите типы, которые не врут.

Источники и что почитать:

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

Что дальше

Мы научились описывать данные так, чтобы они не врали, и разбирать их так, чтобы компилятор ловил забытые случаи. Следующий шаг — заметить, что многие ADT (Maybe, Either, список, дерево) устроены похоже: это контейнеры, к содержимому которых хочется применять функции, не разбирая контейнер вручную каждый раз. Из этого наблюдения вырастают функторы и аппликативы.

Функторы и аппликативы: контейнеры, к которым применяют функции

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

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

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

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