Функциональное программирование: чистота, неизменяемость, композиция
Одна строчка, которая ломает рассуждение
Посмотрите на выражение x = f(a) + f(a). Можно ли заменить его на x = 2 * f(a)? В школьной алгебре ответ
«конечно». В большинстве промышленных кодовых баз ответ другой: не знаю, надо читать f. Вдруг она дёргает
счётчик, читает курсор из базы, возвращает следующее случайное число, пишет в лог. Тогда два вызова — это не одно
значение дважды, а две разные истории.
Функциональное программирование — это дисциплина, возвращающая право на подстановку. Если f чистая, то
f(a) навсегда равно своему результату, и снова работает всё, что вы знаете из алгебры: подставлять, выносить за
скобки, кэшировать, переставлять, распараллеливать, вычислять лениво. Из этого единственного свойства растут
и неизменяемость, и композиция, и монады.
Императивный код — рецепт («шаг, потом шаг»), см. https://courses.digitable.life/post/paradigms/01-imperative-procedural/. Объектный — сеть взаимодействующих капсул состояния, см. https://courses.digitable.life/post/paradigms/02-oop/. Функциональный — выражение, которое описывает, чему равен ответ. Общая карта парадигм: https://courses.digitable.life/post/paradigms/00-overview/.
Немного истории: это не мода 2015 года
Перелом был инженерным, а не эстетическим. Пока процессор был один и ускорялся каждый год, цена разделяемого изменяемого состояния оставалась терпимой. Когда рост пошёл вширь (ядра, узлы, реплики), выяснилось: самое дорогое в системе — места, где два исполнителя смотрят на одну изменяемую ячейку. ФП убирает их по построению. Об этом — «Out of the Tar Pit» Моусли и Маркса: главный источник сложности софта — состояние, а не объём кода.
Чистота и ссылочная прозрачность
Чистая функция — та, что (1) при одинаковых аргументах всегда возвращает одинаковый результат и (2) не производит наблюдаемых побочных эффектов: не пишет в глобальные переменные, файлы и сеть, не мутирует переданные аргументы, не читает изменяемое внешнее состояние — часы, генератор случайных чисел, БД.
Ссылочная прозрачность — свойство выражения: его можно заменить на его значение, и смысл программы не изменится. Это операциональный тест на чистоту, который легко применять руками.
# НЕЧИСТО: скрытые входы и выходы
totals: dict[str, float] = {}
def add_order(user_id: str, amount: float, orders: list) -> float:
orders.append(amount) # мутация чужого аргумента
totals[user_id] = totals.get(user_id, 0) + amount # скрытое глобальное состояние
print(f"добавлен заказ {amount}") # эффект ввода-вывода
return totals[user_id]
# ЧИСТО: всё нужное приходит аргументами, всё случившееся возвращается результатом
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True)
class Totals:
by_user: dict[str, float]
def add_order(state: Totals, user_id: str, amount: float) -> Totals:
return replace(state, by_user={**state.by_user,
user_id: state.by_user.get(user_id, 0.0) + amount})
def test_add_order():
s0 = Totals(by_user={})
s2 = add_order(add_order(s0, "u1", 100.0), "u1", 50.0)
assert s2.by_user == {"u1": 150.0}
assert s0.by_user == {} # прошлое не переписано — ключевое свойство
Вторая версия скучнее, и в этом её ценность: ни одного скрытого входа, ни одного скрытого выхода, тест без моков.
| Свойство | Что становится возможным |
|---|---|
| Детерминизм | Тесты без моков, воспроизведение бага по входу, property-based тесты |
| Отсутствие эффектов | Свободный порядок вычисления, параллелизм без блокировок |
| Подстановка | Мемоизация, вынесение инвариантов из цикла, оптимизации компилятора |
| Локальность | Чтобы понять функцию, достаточно прочитать её саму |
| Сохранность истории | Старые значения целы: undo, откат, аудит, отладка «на прошлом состоянии» |
Джон Хьюз в «Why Functional Programming Matters» формулирует главное: ценность ФП не в том, чего в нём нет (присваивания), а в том, что появляется — клей для сборки программ: функции высшего порядка и ленивость.
Неизменяемость и персистентные структуры данных
Наивное возражение: «копировать список на каждое изменение — это O(n), я разорюсь». Оно справедливо ровно до тех пор, пока вы копируете целиком. Настоящие функциональные коллекции копируют только путь до изменённого узла и переиспользуют всё остальное.
Это structural sharing, и работает оно потому, что общие узлы неизменяемы: их безопасно видеть из двух версий сразу — никто их не испортит. Каноническое изложение — «Purely Functional Data Structures» Криса Окасаки; промышленные векторы и словари Clojure/Scala/Immutable.js основаны на HAMT из статьи Бэгвелла «Ideal Hash Trees».
| Операция | Изменяемый массив | Персистентный вектор (ветвление 32) | Односвязный список |
|---|---|---|---|
| Чтение по индексу | O(1) | O(log₃₂ n) ≈ 1–7 шагов | O(n) |
| Обновление элемента | O(1) | O(log₃₂ n), ~log₃₂ n новых узлов | O(n) |
| Добавление в конец | O(1) амортиз. | O(log₃₂ n) амортиз. | O(n) |
| Добавление в начало | O(n) | O(n) | O(1) |
| «Снять копию» | O(n) | O(1) — копия не нужна | O(1) |
| Память на версию | n | O(log n) на изменение | O(1) на cons |
Последняя строка важнее прочих: у неизменяемых структур копия бесплатна, потому что копия не нужна. Разделять неизменяемое значение между потоками можно вообще без синхронизации — ни мьютексов, ни атомиков, ни защитных копий на границе API. Этим живут Erlang/Elixir (https://courses.digitable.life/post/elixir/00-overview/) и модель акторов из https://courses.digitable.life/post/paradigms/05-concurrent-actors-csp/.
# Elixir: неизменяемость — свойство языка, а не соглашение
user = %{name: "Аня", roles: [:reader]}
promoted = %{user | roles: [:admin | user.roles]} # новая map, старая цела
user.roles # => [:reader]
promoted.roles # => [:admin, :reader]
# Python: замороженный dataclass + неизменяемые коллекции внутри
@dataclass(frozen=True, slots=True)
class User:
name: str
roles: tuple[str, ...] = () # tuple, а не list — мутировать нечем
def promote(u: User) -> User:
return replace(u, roles=u.roles + ("admin",))
Тонкость Python: frozen=True запрещает переприсваивание поля, но не мутацию содержимого — c.items.append(1)
внутри замороженного объекта пройдёт. Кладите внутрь tuple, frozenset, MappingProxyType либо берите готовые
персистентные коллекции (pyrsistent, immutables).
В TypeScript ту же роль играют readonly-типы: push у readonly string[] просто не существует.
Когда неизменяемость мешает
- Горячие численные циклы. Матрица 4096×4096, обновляемая миллион раз, в неизменяемом виде убьёт кэш и GC.
Правильный ответ — локальная мутация внутри чистой функции: снаружи чистая, внутри крутит буфер. Так устроены
ST-монада в Haskell, transient-коллекции в Clojure,mutableв F#. - Аллокации и GC. Каждая «модификация» — мусор. На JVM/BEAM/V8 молодое поколение дёшево, но при миллионах операций в секунду это уже видимая в профайлере статья расходов.
- Кэш-локальность. Дерево 32-арных узлов раскидано по куче; плотный массив читается последовательно и на линейных проходах выигрывает в разы.
- Прокидывание состояния. Если состояние передаётся руками через двадцать уровней — это больно. Лечится не мутацией, а укрупнением: один агрегат состояния на модуль вместо двадцати разрозненных полей.
Правило: неизменяемость по умолчанию, мутация — локально, осознанно и за чистой границей.
Функции высшего порядка и композиция
Функция высшего порядка принимает функции аргументами и/или возвращает функцию. Это и есть «клей» Хьюза: он позволяет вытащить из алгоритма изменчивую часть и оставить многоразовый скелет.
from functools import reduce
def compose(f, g):
"""Математическая композиция: (f ∘ g)(x) = f(g(x)) — читается справа налево."""
return lambda x: f(g(x))
def pipe(*fns):
"""Конвейер: читается слева направо, как поток данных. pipe(f, g)(x) == g(f(x))."""
return lambda x: reduce(lambda acc, fn: fn(acc), fns, x)
normalize = pipe(str.strip, str.lower, lambda s: s.replace("ё", "е"))
normalize(" Ёжик ") # => 'ежик'
Композиция ассоциативна и имеет нейтральный элемент identity — формально функции образуют моноид. Практический
вывод: порядок группировки шагов конвейера не важен, любой кусок пайплайна можно вынести в именованную функцию,
ничего не сломав. Поэтому функциональный код так легко рефакторить — у него есть алгебра.
// Каррирование: функция n аргументов -> цепочка функций одного аргумента
const between = (lo: number) => (hi: number) => (x: number) => x >= lo && x <= hi;
const isAdult = between(18)(120); // частичное применение
[15, 22, 70].filter(isAdult); // => [22, 70]
Каррирование не самоцель: конвейер собирается из функций одного аргумента, поэтому «донастроенные» варианты нужны постоянно. Стиль point-free (без явного упоминания аргументов) — тоже инструмент с мерой:
-- идеальный случай: три осмысленных шага, ничего лишнего
solve :: [Int] -> Int
solve = sum . map (^2) . filter even
Пока читатель видит цепочку понятных шагов — пишите point-free. Как только приходится распутывать порядок
аргументов (f = (. g) . h . flip i) — верните явный параметр. Красивый код тот, который понятен через полгода.
Свёртки: map, filter, reduce и вся семья
Почти любой цикл по коллекции — одна из трёх схем: преобразование каждого элемента (map), отбор (filter) или
накопление (fold/reduce). fold фундаментален: через него выражаются и map, и filter.
def fold_left(step, init, xs):
"""Свёртка слева: ((init ⊕ x1) ⊕ x2) ⊕ ... Время O(n), доп. память O(1)."""
acc = init
for x in xs: # честная итерация: в CPython нет TCO
acc = step(acc, x)
return acc
# ЛОВУШКА: acc + [f(x)] создаёт новый список на каждом шаге -> O(n^2) времени и аллокаций
slow_map = lambda f, xs: fold_left(lambda acc, x: acc + [f(x)], [], xs)
# ПРАВИЛЬНО: аккумулятор с O(1)-добавлением; мутация локальна, снаружи функция чистая
def fast_map(f, xs):
def step(acc, x):
acc.append(f(x))
return acc
return fold_left(step, [], xs) # O(n)
Красивый функциональный код не отменяет анализа сложности — slow_map встречается в проде пугающе часто.
foldl против foldr. foldl естественно итеративен (аккумулятор слева, разворачивается в цикл с памятью
O(1)). foldr строит выражение справа: x1 ⊕ (x2 ⊕ (... ⊕ init)) — в строгом языке это стек глубины O(n) и
переполнение на большом входе. В ленивом языке foldr с ленивым оператором, наоборот, работает даже на
бесконечных списках, потому что не обязан доходить до конца:
-- (||) не вычисляет второй аргумент, если первый True -> ответ приходит мгновенно
anyBig :: [Int] -> Bool
anyBig = foldr (\x rest -> x > 1000 || rest) False
anyBig [1..] -- => True
Практика: в строгих языках берите строгий foldl', в ленивых — foldr для конструирования потенциально
бесконечных структур и foldl' для чисел.
Цена красивого конвейера
xs.map(f).filter(p).reduce(step) в наивной реализации — три прохода и две временные коллекции. Асимптотика та же
O(n), но константа и давление на GC заметно выше. Ответы индустрии: ленивые последовательности (Java Stream,
LINQ, генераторы Python, итераторы Rust, Stream в Elixir), трансдьюсеры Clojure
как композиция самих шагов свёртки, stream fusion в компиляторе GHC.
# Генераторы дают ленивый однопроходный конвейер без временных списков
def total_amount(rows):
parsed = (parse(r) for r in rows) # ничего ещё не посчитано
valid = (p for p in parsed if p.ok) # тоже отложено
return sum(p.amount for p in valid) # один проход, O(1) доп. памяти
Та же идея в .NET-идиоматике — Select().Where().Sum(), где до вызова Sum() не выполняется ничего
(https://courses.digitable.life/post/csharp/00-overview/); в типизированном TypeScript — https://courses.digitable.life/post/typescript/00-overview/.
Рекурсия, хвостовые вызовы и как не уронить стек
В ФП цикл выражается рекурсией, а наивная рекурсия расходует стек глубины O(n). Спасение — хвостовой вызов: если рекурсивный вызов последнее, что делает функция, кадр можно переиспользовать (TCO).
defmodule Sum do
# НЕ хвостовая: после возврата ещё надо выполнить сложение — кадр держится, память O(n)
def naive([]), do: 0
def naive([h | t]), do: h + naive(t)
# Хвостовая: вызов последний, BEAM превращает её в цикл. Память O(1)
def fast(list), do: do_fast(list, 0)
defp do_fast([], acc), do: acc
defp do_fast([h | t], acc), do: do_fast(t, acc + h)
end
Оптимизируют хвостовые вызовы: Scheme (обязан по стандарту), Erlang/Elixir, OCaml, Lua, Kotlin (tailrec),
Scala (@tailrec, только самовызов). Не оптимизируют: CPython (принципиальная позиция), Java, C#, основные
движки JS. Что делать там, где TCO нет: заменить рекурсию циклом (правильный ответ в 90 % случаев) либо применить
трамплин.
from functools import lru_cache
def trampoline(f, *args):
result = f(*args)
while callable(result): # пока функция возвращает продолжение — крутим цикл
result = result()
return result
def sum_tramp(xs, i=0, acc=0):
if i == len(xs):
return acc
return lambda: sum_tramp(xs, i + 1, acc + xs[i]) # хвостовой вызов как замыкание
trampoline(sum_tramp, list(range(100_000))) # стек постоянной глубины
@lru_cache(maxsize=None) # кэш безопасен ИМЕННО потому, что функция чистая
def fib(n: int) -> int: # без кэша O(φ^n) времени; с кэшем O(n) время и память
return n if n < 2 else fib(n - 1) + fib(n - 2)
Мемоизация — прямой дивиденд чистоты: кэшировать можно только детерминированное.
Алгебраические типы: сделать некорректные состояния невыразимыми
Вторая опора ФП после чистоты — алгебраические типы данных: суммы (это ИЛИ то) и произведения (это И то). Вместе с исчерпывающим сопоставлением с образцом они переносят целый класс ошибок из рантайма в компиляцию.
Обратите внимание на Paid: поле paidAt существует только в оплаченном состоянии. Это принципиально
отличается от объектной модели с nullable-полем paid_at, где «черновик с датой оплаты» технически выразим — и
рано или поздно окажется в базе.
// Размеченное объединение — родная конструкция TypeScript
type Status =
| { kind: "draft" }
| { kind: "paid"; paidAt: Date }
| { kind: "cancelled"; reason: string };
function describe(s: Status): string {
switch (s.kind) {
case "draft": return "черновик";
case "paid": return `оплачен ${s.paidAt.toISOString()}`; // paidAt виден только тут
case "cancelled": return `отменён: ${s.reason}`;
default: {
const _exhaustive: never = s; // добавите вариант — компилятор укажет сюда
return _exhaustive;
}
}
}
В Python тот же приём даёт связка замороженных dataclass-ов, объединения Draft | Paid | Cancelled и match
из версии 3.10; в Elixir — кортежи с тегом и case; в Rust/Scala/Haskell — enum/sealed trait/data.
Ошибки как значения вместо исключений
Исключение — скрытый второй выход из функции, невидимый в её сигнатуре. Функциональный подход делает провал частью
типа: Option/Maybe для «может не быть», Either/Result для «может провалиться с причиной».
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T,>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E,>(error: E): Result<never, E> => ({ ok: false, error });
// andThen (он же flatMap, bind) — склейка шагов, каждый из которых может провалиться
const andThen = <T, U, E>(r: Result<T, E>, f: (t: T) => Result<U, E>): Result<U, E> =>
r.ok ? f(r.value) : r;
const parseAmount = (s: string): Result<number, string> =>
Number.isFinite(Number(s)) ? ok(Number(s)) : err(`не число: ${s}`);
const positive = (n: number): Result<number, string> =>
n > 0 ? ok(n) : err(`неположительное: ${n}`);
// ошибка не теряется и не «выпрыгивает» — она течёт по конвейеру обычным значением
andThen(parseAmount("42"), positive); // { ok: true, value: 42 }
Trade-off честный: там, где throw был бы в одну строку, кода становится больше — зато сигнатура перестаёт врать,
а компилятор напоминает про необработанный сбой. На практике исключения оставляют для действительно исключительного
(сломанный инвариант, OOM), а ожидаемые провалы — валидацию, отсутствие записи, таймаут — моделируют типом. Так
устроены Result в Rust, Either в Scala/cats, fp-ts в TypeScript, кортежи
{:ok, v} | {:error, r} в Elixir.
Эффекты как значения: функтор, аппликатив, монада
Если функция обязана быть чистой, как программа вообще что-то делает? Ответ ФП: эффект описывается значением, а
исполняется на краю системы. IO String в Haskell — не строка и не действие, а описание действия, которое
когда-нибудь выполнит рантайм. Пока это описание, его можно передавать, складывать в список, комбинировать —
абсолютно чисто. Чтобы такие описания было чем склеивать, придумали три ступени абстракции:
Разница между аппликативом и монадой не философская, а поведенческая:
- Аппликатив: шаги независимы — их можно выполнять параллельно и собрать все ошибки сразу. Идеально для валидации формы, где пользователю нужен список всех проблем, а не первая.
- Монада: следующий шаг зависит от результата предыдущего — строго последовательное выполнение и остановка на первой ошибке. Идеально для конвейера «найти пользователя → проверить права → списать деньги».
-- do-нотация — это сахар над >>= (bind)
transfer :: AccountId -> AccountId -> Money -> IO (Either Text Receipt)
transfer from to amount = do
src <- loadAccount from -- каждый шаг зависит от предыдущего
dst <- loadAccount to
case withdraw src amount of -- чистая бизнес-логика внутри
Left e -> pure (Left e)
Right src' -> do
let dst' = deposit dst amount
saveBoth src' dst' -- эффект — на самом краю
pure (Right (receiptFor src' dst' amount))
Законы монады (левая и правая единица, ассоциативность bind) — не бюрократия: именно они гарантируют, что
рефакторинг do-блока не меняет смысл. Первоисточник — «Monads for functional programming»
Уодлера; инженерная сторона — «Tackling the Awkward Squad»
Пейтона Джонса. Тот же приём без слова «монада» вы применяете ежедневно: async/await, ?., Optional.flatMap,
LINQ-синтаксис — всё это специализированные монады с человеческим лицом.
Ленивость: считать только то, что понадобилось
Строгая (энергичная) семантика вычисляет аргумент до вызова; ленивая — только когда значение реально понадобилось, и не более одного раза: результат кэшируется в самом thunk.
Плюсы: бесконечные структуры, разделение «генератор — потребитель» на независимые модули, естественный short-circuit.
-- решето Эратосфена на бесконечном списке: определение и есть алгоритм
primes :: [Int]
primes = sieve [2..]
where sieve (p:xs) = p : sieve [x | x <- xs, x `mod` p /= 0]
take 10 primes -- => [2,3,5,7,11,13,17,19,23,29]; посчитано ровно столько, сколько спросили
Минусы реальны и болезненны: space leaks (вместо числа копится башня thunk’ов), непредсказуемое время отклика
(работа сдвигается в неожиданный момент), сложное профилирование. Поэтому даже Haskell активно использует строгие
аннотации (!, seq, foldl', BangPatterns), а большинство языков выбирают строгую семантику по умолчанию с
точечной ленивостью через генераторы и потоки.
Функциональное ядро, императивная оболочка
Полностью чистой программы не бывает — программа без эффектов бесполезна. Вопрос не «есть эффекты или нет», а где проходит граница. Самая работоспособная промышленная схема — «functional core, imperative shell» (Гэри Бернхардт, «Boundaries»): вся логика — чистая функция «состояние + факты → новое состояние + список команд», а ввод-вывод живёт тонким слоем снаружи.
чтение БД, время, конфиг, курсы"] B --> C{"Все данные собраны?"} C -- нет --> D["Ошибка или повтор
эффект остаётся в оболочке"] C -- да --> E["Чистое ядро — decide от state и facts
ни одного I-O вызова"] E --> F["Новое состояние + список команд
обычные значения, не действия"] F --> G["Оболочка исполняет команды
запись в БД, публикация события, ответ"] E -.->|"чистая функция — ноль моков"| T1["Быстрые unit- и property-тесты"] G -.->|"контракты и интеграция"| T2["Немного медленных тестов"] style E fill:none,stroke:#10b981,stroke-width:3px style F fill:none,stroke:#10b981,stroke-width:2px style B fill:none,stroke:#f59e0b,stroke-width:2px style G fill:none,stroke:#f59e0b,stroke-width:2px
Ключевая мысль: ядро не вызывает эффекты, оно их описывает. Функция возвращает [ChargeCard(...), SendEmail(...)] —
обычные структуры данных, которые тривиально сравнить в тесте. Исполняет их оболочка.
from datetime import datetime
@dataclass(frozen=True)
class ChargeCard: order_id: str; amount: float
@dataclass(frozen=True)
class SendEmail: to: str; template: str
Command = ChargeCard | SendEmail
@dataclass(frozen=True)
class Order: id: str; email: str; total: float; status: str
@dataclass(frozen=True)
class Facts: # всё внешнее собрано ЗАРАНЕЕ оболочкой
now: datetime; balance: float; discount: float
# --- ЧИСТОЕ ЯДРО: тестируется без базы, без сети, без моков ---
def submit_order(order: Order, facts: Facts) -> tuple[Order, list[Command], str | None]:
if order.status != "draft":
return order, [], "заказ уже отправлен"
price = round(order.total * (1 - facts.discount), 2)
if facts.balance < price:
return order, [], "недостаточно средств"
paid = Order(order.id, order.email, price, "paid")
return paid, [ChargeCard(order.id, price), SendEmail(order.email, "receipt")], None
# --- ИМПЕРАТИВНАЯ ОБОЛОЧКА: единственное место с вводом-выводом ---
def handle(order_id: str, db, gateway, mailer, clock) -> None:
order = db.load(order_id)
facts = Facts(clock.now(), db.balance(order.email), db.promo(order.email))
new_order, commands, error = submit_order(order, facts) # чистый вызов
if error:
raise ValueError(error)
db.save(new_order)
for cmd in commands: # интерпретация команд
match cmd:
case ChargeCard(order_id=oid, amount=a): gateway.charge(oid, a)
case SendEmail(to=to, template=t): mailer.send(to, t)
Тест на ядро занимает три строки, выполняется за микросекунды и покрывает ровно ту часть, где живут деньги. Тот же приём известен как «функциональная архитектура = порты и адаптеры» Марка Симанна. Связь с event sourcing прямая: события — неизменяемый журнал фактов, состояние — свёртка по нему.
Типичные ошибки
- «Я использую
map, значит пишу функционально». Если внутри лямбды мутируется внешний список или пишется в базу — это императивный цикл в модной обёртке, да ещё с неочевидным порядком выполнения. - Мутация аргумента «чистой» функции — самая частая протечка: в Python/JS передача идёт по ссылке.
- Скрытые входы:
datetime.now(),random(),os.environ, глобальный кэш. Это недекларированные аргументы; передайте их параметрами — получите детерминированный тест бесплатно. - O(n²) из-за конкатенации в свёртке:
acc + [x],acc ++ [x],str += partв цикле. foldrили глубокая рекурсия в строгом языке → переполнение стека на «неожиданно большом» входе.- Утечка ленивости:
foldlвместоfoldl'в Haskell; генератор, захвативший переменную цикла, в Python; замыкание, удерживающее большой объект, в JS. - Абстракции ради абстракций. Трансформеры монад поверх четырёх слоёв в CRUD-сервисе — верный способ потерять
команду. Начните с чистых функций и
Result, добирайте абстракцию по нужде. - Тотальная неизменяемость в горячем цикле без профилирования: локальный мутабельный буфер за чистой границей не нарушает ни одного свойства, которое вам действительно нужно.
- Состояние течёт через 15 сигнатур — сигнал склеить его в агрегат, а не вернуться к глобальным переменным.
- Ловля исключений вместо моделирования провала: «записи может не быть» — это
Option, а неNotFoundErrorв пятнадцатиtry.
Где это уже работает в проде
- Erlang/Elixir — неизменяемость плюс акторы держат телеком-нагрузки; WhatsApp обслуживал десятки миллионов соединений на узел ровно за счёт отсутствия разделяемого состояния (https://courses.digitable.life/post/elixir/00-overview/, hexdocs).
- React / Redux — UI как чистая функция состояния:
view = f(state), а редьюсер(state, action) => state— буквальноfoldпо потоку событий. Правило чистоты компонентов задокументировано явно; Immer даёт мутабельный синтаксис поверх персистентных структур. - Apache Spark — RDD и датафреймы неизменяемы, преобразования ленивы, а отказоустойчивость достигается повторным вычислением по графу происхождения, что возможно только благодаря детерминизму (статья NSDI'12).
- Java Streams и .NET LINQ — функциональный конвейер в самых консервативных корпоративных стеках (Stream API, LINQ).
- Rust — владение и эксклюзивность
&mutреализуют главное обещание ФП (нет разделяемой мутации) без сборщика мусора;Option/Resultи итераторы пришли прямо из ML-традиции. - Go — язык не функциональный, но идиома «передавай значения, а не разделяй память» родственная (https://courses.digitable.life/post/golang/00-overview/, CSP разбирается в https://courses.digitable.life/post/paradigms/05-concurrent-actors-csp/).
- Финтех и системы учёта — event sourcing: журнал неизменяемых событий и чистая свёртка в текущее состояние. Аудит и «состояние на вчера в 14:03» получаются бесплатно.
Мини-итог и чеклист
Функциональная парадигма стоит на трёх опорах: чистота (функция = отображение входа в выход), неизменяемость (значения не переписываются, версии сосуществуют), композиция (сложное собирается из простого по алгебраическим правилам). Всё остальное — ФВП, ADT, свёртки, монады, ленивость — инструменты, обслуживающие эти три свойства.
- Может ли функция вернуть разный ответ на одинаковых аргументах? Если да — можно ли сделать причину параметром?
- Мутирую ли я что-то, что мне не принадлежит: аргумент, поле, глобал?
- Проходит ли граница I/O по краю модуля — или эффекты размазаны по бизнес-логике?
- Моделирую ли «может не получиться» типом или прячу за исключением?
- Выразимо ли в моих типах некорректное состояние? Можно ли запретить его суммой типов?
- Сколько проходов и аллокаций делает мой красивый конвейер на реальном объёме данных?
- Не стала ли абстракция самоцелью — поймёт ли этот код новый человек в команде за один заход?
Источники
- John Backus. Can Programming Be Liberated from the von Neumann Style? (Turing Award Lecture, 1978)
- John Hughes. Why Functional Programming Matters
- Ben Moseley, Peter Marks. Out of the Tar Pit
- Chris Okasaki. Purely Functional Data Structures
- Phil Bagwell. Ideal Hash Trees
- Philip Wadler. Monads for functional programming, Theorems for free!
- Simon Peyton Jones. Tackling the Awkward Squad
- Rich Hickey. Simple Made Easy · Gary Bernhardt. Boundaries
- Mark Seemann. Functional architecture is Ports and Adapters · Martin Fowler. Collection Pipeline
- Clojure Transducers · fp-ts · Typelevel Cats · Haskell documentation
Что дальше
Чистые функции описывают, как посчитать ответ — пусть и очень абстрактно. Следующий шаг — парадигмы, где вы описываете только, что должно быть верно, а поиск решения берёт на себя движок: SQL, Prolog, системы правил и ограничений.
Читайте дальше: Декларативное и логическое программирование.
Если ближе прикладной угол: конкурентность на неизменяемых данных — https://courses.digitable.life/post/paradigms/05-concurrent-actors-csp/, потоки значений во времени — https://courses.digitable.life/post/paradigms/06-reactive-and-dataflow/, а как смешивать ФП с ООП в живом проекте без религиозных войн — https://courses.digitable.life/post/paradigms/09-choosing-and-mixing/.