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

Функциональное программирование: чистота, неизменяемость, композиция

Функциональное программирование: чистота, неизменяемость, композиция

Одна строчка, которая ломает рассуждение

Посмотрите на выражение 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' для чисел.

Цена красивого конвейера

Наивная цепочка map/filter/reduce против слияния в один проход

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»): вся логика — чистая функция «состояние + факты → новое состояние + список команд», а ввод-вывод живёт тонким слоем снаружи.

Ключевая мысль: ядро не вызывает эффекты, оно их описывает. Функция возвращает [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 прямая: события — неизменяемый журнал фактов, состояние — свёртка по нему.

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

  1. «Я использую map, значит пишу функционально». Если внутри лямбды мутируется внешний список или пишется в базу — это императивный цикл в модной обёртке, да ещё с неочевидным порядком выполнения.
  2. Мутация аргумента «чистой» функции — самая частая протечка: в Python/JS передача идёт по ссылке.
  3. Скрытые входы: datetime.now(), random(), os.environ, глобальный кэш. Это недекларированные аргументы; передайте их параметрами — получите детерминированный тест бесплатно.
  4. O(n²) из-за конкатенации в свёртке: acc + [x], acc ++ [x], str += part в цикле.
  5. foldr или глубокая рекурсия в строгом языке → переполнение стека на «неожиданно большом» входе.
  6. Утечка ленивости: foldl вместо foldl' в Haskell; генератор, захвативший переменную цикла, в Python; замыкание, удерживающее большой объект, в JS.
  7. Абстракции ради абстракций. Трансформеры монад поверх четырёх слоёв в CRUD-сервисе — верный способ потерять команду. Начните с чистых функций и Result, добирайте абстракцию по нужде.
  8. Тотальная неизменяемость в горячем цикле без профилирования: локальный мутабельный буфер за чистой границей не нарушает ни одного свойства, которое вам действительно нужно.
  9. Состояние течёт через 15 сигнатур — сигнал склеить его в агрегат, а не вернуться к глобальным переменным.
  10. Ловля исключений вместо моделирования провала: «записи может не быть» — это 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 по краю модуля — или эффекты размазаны по бизнес-логике?
  • Моделирую ли «может не получиться» типом или прячу за исключением?
  • Выразимо ли в моих типах некорректное состояние? Можно ли запретить его суммой типов?
  • Сколько проходов и аллокаций делает мой красивый конвейер на реальном объёме данных?
  • Не стала ли абстракция самоцелью — поймёт ли этот код новый человек в команде за один заход?

Источники

Что дальше

Чистые функции описывают, как посчитать ответ — пусть и очень абстрактно. Следующий шаг — парадигмы, где вы описываете только, что должно быть верно, а поиск решения берёт на себя движок: 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/.

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

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

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

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