Функциональное программирование Эффекты и ввод-вывод: как чистый код общается с грязным миром
0%

Эффекты и ввод-вывод: как чистый код общается с грязным миром

Эффекты и ввод-вывод: как чистый код общается с грязным миром

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

Значит, у чистого программирования есть обязательство. Если мы двенадцать статей строили мир без изменяемого состояния и побочных эффектов (Чистые функции, Неизменяемость), то придётся ответить на прямой вопрос: а как тогда вообще что-то происходит?

Ответов у ФП несколько, и они выстраиваются в лестницу — от почти бесплатного дисциплинарного приёма до полноценной системы эффектов в типах. Пройдём её снизу вверх, каждый раз начиная с боли, которая заставляет подняться на ступеньку. Обзорный взгляд на парадигму — в статье Функциональное программирование; здесь мы копаем в глубину.

Боль нулевая: что вообще считать эффектом

Прежде чем бороться, договоримся о цели. Эффект — это всё, что делает функцию не заменяемой на её результат. Формально: функция чиста, если вызов f(x) можно всюду заменить на значение, которое он вернул, и программа не изменится. Это свойство называют ссылочной прозрачностью.

Список того, что её ломает, длиннее, чем кажется новичкам:

import time, random, os, logging

def price_with_discount(order):
    now = time.time()                      # 1. чтение часов
    if random.random() < 0.01:             # 2. недетерминизм
        logging.info("sampling %s", order) # 3. запись в лог
    rate = float(os.environ["RATE"])       # 4. чтение окружения
    order.total *= rate                    # 5. мутация аргумента
    CACHE[order.id] = order.total          # 6. мутация глобального состояния
    raise_if_negative(order.total)         # 7. исключение как скрытый выход
    return order.total

Семь эффектов в семи строках, и ни один не виден в сигнатуре price_with_discount(order). Особенно коварны первые четыре: часы, случайность, окружение и логи почти никогда не воспринимаются как ввод-вывод, но именно они делают тест «то зелёным, то красным» и заставляют писать time.sleep в тестах.

Заметьте: «изменение мира» — только одна ветка из четырёх. Большая часть практических проблем с эффектами — это не запись в базу, а незаметное чтение чего-то изменчивого.

Ступень 1: не устранять эффекты, а выселять их на края

Самое дешёвое решение не требует ни монад, ни библиотек. Идея: эффекты нельзя убрать, но можно собрать в тонкий слой по краям, оставив в середине чистое ядро. Гэри Бернхардт назвал это «functional core, imperative shell» в докладе Boundaries, Марк Симанн — impureim-сэндвичем.

Impureim-сэндвич: грязная оболочка снаружи, чистое ядро внутри

Механика простая: сначала прочитали всё, что нужно, потом чисто посчитали, потом записали всё, что решили. Перепишем пример выше:

from dataclasses import dataclass, replace
from datetime import datetime

# --- ЧИСТОЕ ЯДРО: ни одного обращения наружу ---

@dataclass(frozen=True)
class PricingInput:
    order: "Order"
    now: datetime
    rate: float
    sample: bool          # решение "логировать ли" тоже стало данными

@dataclass(frozen=True)
class PricingOutput:
    order: "Order"
    log_lines: tuple[str, ...]   # эффекты описаны, но не выполнены

def price(inp: PricingInput) -> PricingOutput:
    total = inp.order.total * inp.rate
    if total < 0:
        raise ValueError("отрицательная сумма")   # исключение как контракт, не как сюрприз
    logs = (f"sampled {inp.order.id}",) if inp.sample else ()
    return PricingOutput(replace(inp.order, total=total), logs)

# --- ОБОЛОЧКА: только чтение и запись, ни одного бизнес-if ---

def handle(order, clock, rng, env, logger):
    inp = PricingInput(order, clock.now(), float(env["RATE"]), rng.random() < 0.01)
    out = price(inp)
    for line in out.log_lines:
        logger.info(line)
    return out.order

Что изменилось по существу:

  • price детерминирована. Тест на неё — это assert price(inp) == expected, без моков, без подмены времени, без freezegun.
  • Решение «логировать ли» стало данными (sample: bool), а само логирование — тоже данными (log_lines). Ядро решает, оболочка исполняет.
  • Оболочка стала настолько тупой, что её почти не нужно тестировать: там нет ветвлений по бизнес-правилам.

Это правило большого пальца, и оно даёт, наверное, 80% практической пользы от всей темы эффектов: эффекты должны быть на краях, а решения — в середине. Если вы не готовы идти дальше по лестнице — остановитесь здесь, это уже огромный выигрыш.

Где этот приём ломается

Он отлично работает, пока сценарий укладывается в «прочитали — посчитали — записали». И начинает трещать, когда следующее чтение зависит от результата вычисления:

прочитали заказ -> решили, что нужен курс валюты -> сходили за курсом ->
решили, что нужен лимит клиента -> сходили за лимитом -> посчитали -> записали

Здесь чистого «сэндвича» не выходит: чтение и вычисление чередуются. Варианты есть — можно вычитать всё заранее с запасом (дорого и не всегда возможно), можно вернуть из ядра «запрос на дочтение» и крутить цикл в оболочке (это уже зачаток интерпретатора), а можно подняться на следующую ступень.

Ступень 2: эффект как значение

Ключевой ход всего функционального подхода к вводу-выводу выглядит почти как софизм:

Функция, которая выполняет побочный эффект, — нечиста. Функция, которая возвращает описание побочного эффекта, — чиста.

Рецепт торта не пахнет. Список покупок ничего не покупает. Пока мы строим значение, описывающее «прочитать файл, потом записать другой», — мы остаёмся в чистом мире. Мир меняется только в одной точке: когда рантайм берёт это описание и исполняет.

Одно описание — два интерпретатора

Каноническая запись: IO в Haskell

В Haskell тип IO a означает «рецепт, при исполнении которого мир изменится и получится значение типа a». Ничего волшебного: это обычное значение, которое можно передавать, класть в список, комбинировать.

-- Это НЕ "прочитать строку". Это "описание чтения строки".
getLine  :: IO String
putStrLn :: String -> IO ()

-- Чистая функция: ни разу не касается мира
greet :: String -> String
greet name = "Привет, " ++ name ++ "!"

-- Композиция описаний. Функция main тоже чиста: она ВОЗВРАЩАЕТ рецепт.
main :: IO ()
main = do
  name <- getLine            -- "когда исполнишь getLine, назови результат name"
  putStrLn (greet name)

Три следствия, которые обычно упускают:

  1. main ничего не выполняет. Она строит одно большое значение типа IO (). Исполняет его рантайм GHC — единственная нечистая точка во всей программе.
  2. IO заразителен вверх, но не вниз. Из IO можно вызвать чистую функцию, обратно — нет (без unsafePerformIO, который так назван не случайно). Это делает эффект видимым в типе: сигнатура Order -> Decision — гарантия, а не обещание в комментарии.
  3. Описание можно использовать дважды. let twice = action >> action — это композиция значения, а не двойной вызов побочного эффекта во время построения.

Классическое изложение того, как Haskell вообще пришёл к этой модели (и что он пробовал до неё — потоки запросов и откликов, continuation-стиль), — статья Саймона Пейтона Джонса «Tackling the Awkward Squad». Она до сих пор лучшее чтение по теме.

То же самое на TypeScript — вручную, за 12 строк

IO не требует Haskell. Это просто отложенное вычисление плюс map/flatMap (о том, почему именно эти две операции, — Функторы и аппликативы и Монады).

class IO<A> {
  private constructor(private readonly thunk: () => A) {}

  static of<A>(a: A): IO<A> { return new IO(() => a); }
  /** Оборачиваем нечистую функцию, НЕ вызывая её. */
  static effect<A>(f: () => A): IO<A> { return new IO(f); }

  map<B>(f: (a: A) => B): IO<B> {
    return new IO(() => f(this.thunk()));
  }
  flatMap<B>(f: (a: A) => IO<B>): IO<B> {
    return new IO(() => f(this.thunk()).thunk());
  }
  /** Единственная нечистая точка. Вызывается один раз, в самом верху. */
  unsafeRun(): A { return this.thunk(); }
}

const readLine: IO<string> = IO.effect(() => prompt("Имя?") ?? "");
const print = (s: string): IO<void> => IO.effect(() => console.log(s));

const program: IO<void> = readLine
  .map((name) => `Привет, ${name}!`)   // чистое преобразование
  .flatMap(print);                     // склейка описаний

// До этой строки на экран не выведено ничего.
program.unsafeRun();

Почему Promise — это не IO

Соблазн сказать «у нас же есть Promise, это то же самое» велик, и он неверен. Promise энергичен: он начинает работу в момент создания. Это ломает ссылочную прозрачность:

// Вариант A
const p = fetch("/api/charge", { method: "POST" });
await p; await p;      // списание произошло ОДИН раз

// Вариант B — казалось бы, простая подстановка p на его определение
await fetch("/api/charge", { method: "POST" });
await fetch("/api/charge", { method: "POST" });   // списание произошло ДВА раза

Замена переменной на её определение изменила поведение — значит, ссылочной прозрачности нет. У IO (и у Task/Effect/ZIO) такой проблемы нет: значение — это описание, каждый запуск исполняет его заново. Отсюда же практическое следствие: Promise нельзя повторить, отменить (без AbortController, прикрученного сбоку) или переиспользовать как стратегию ретрая. Ленивые описания можно — подробнее про ленивость в статье Ленивость и потоки.

Ступень 3: свой язык эффектов и интерпретаторы к нему

IO честно говорит «здесь будет эффект», но не говорит какой. Сигнатура IO String одинакова у «прочитать конфиг» и «удалить прод-базу». Для тестов это тоже мало что даёт: подменить внутренности IO нельзя.

Следующий ход: описывать не «эффект вообще», а конкретные команды своей предметной области, и писать под них несколько интерпретаторов. Это идея Free-монады («Data Types à la Carte», Wouter Swierstra), но её суть выражается без единого слова из теории категорий — например, на генераторах Python.

from dataclasses import dataclass

# --- 1. Алфавит эффектов: обычные данные (см. статью про АТД) ---
@dataclass(frozen=True)
class ReadFile:  path: str
@dataclass(frozen=True)
class WriteFile: path: str; data: str
@dataclass(frozen=True)
class Log:       msg: str

# --- 2. Программа: чистый генератор, который ТОЛЬКО описывает ---
def import_orders(path):
    raw = yield ReadFile(path)             # "мне нужен результат этой команды"
    orders = [line for line in raw.splitlines() if line]
    yield Log(f"принято строк: {len(orders)}")
    yield WriteFile("out.json", "\n".join(orders))
    return len(orders)

# --- 3. Продовый интерпретатор ---
def run_prod(gen):
    result = None
    try:
        while True:
            cmd = gen.send(result)
            match cmd:
                case ReadFile(path):        result = open(path).read()
                case WriteFile(path, data): open(path, "w").write(data); result = None
                case Log(msg):              print(msg); result = None
    except StopIteration as stop:
        return stop.value

# --- 4. Тестовый интерпретатор: тот же код программы, другой мир ---
def run_test(gen, files):
    journal, result = [], None
    try:
        while True:
            cmd = gen.send(result)
            journal.append(cmd)
            match cmd:
                case ReadFile(path):        result = files[path]
                case WriteFile() | Log():   result = None
    except StopIteration as stop:
        return stop.value, journal

# Тест без файловой системы, без моков, с проверкой ПОСЛЕДОВАТЕЛЬНОСТИ эффектов
count, journal = run_test(import_orders("in.txt"), {"in.txt": "a\nb\n"})
assert count == 2
assert journal[1] == Log("принято строк: 2")
assert journal[2] == WriteFile("out.json", "a\nb")

Что мы получили: программа стала проверяемым значением. Тест смотрит не только на результат, но и на то, какие эффекты и в каком порядке были запрошены. Можно написать третий интерпретатор — логирующий, кэширующий, «сухой прогон» (dry-run), который печатает план и ничего не делает.

Что мы за это заплатили:

  • Аллокации и диспетчеризация. Каждый шаг — объект команды плюс match в интерпретаторе. Для горячего цикла это неприемлемо; для оркестрации бизнес-сценария — незаметно.
  • Комбинаторный рост. Три набора команд в одной программе требуют либо общего супертипа, либо композиции интерпретаторов — и код быстро становится не для всех.
  • Плохие трассировки. Ошибка в интерпретаторе показывает стек интерпретатора, а не место в бизнес-логике.

Из-за последних двух пунктов Free-монады в чистом виде вышли из моды даже в Scala-сообществе. Практическая альтернатива — tagless final: вместо данных описываем эффекты интерфейсом, параметризованным контейнером, а интерпретатор — это его реализация. Каноническое изложение — работы Олега Киселёва. Без типов высшего рода (TypeScript, Python, Go, Java) tagless final вырождается в обычную инверсию зависимостей — и это, честно говоря, нормально:

// "Tagless final для бедных": порт как интерфейс, адаптеры как реализации
interface OrderStore { load(id: string): Promise<Order>; save(o: Order): Promise<void>; }
interface Clock { now(): Date; }

const importOrders = (store: OrderStore, clock: Clock) => async (id: string) => {
  const order = await store.load(id);
  const decided = decide(order, clock.now());   // чистое ядро
  await store.save(decided);
  return decided;
};

Разница с ООП-версией только в том, что зависимости передаются функции, а не хранятся в поле объекта. Марк Симанн подробно разбирает, почему для чистого ядра часто лучше вообще не внедрять зависимости, а отвергать их — вынести решение так, чтобы зависимость стала не нужна.

Ступень 4: эффект-системы и то, ради чего их правда берут

Если бы дело было только в тестируемости, ступени 1 и 3 закрывали бы вопрос. Промышленные эффект-системы — ZIO, Cats Effect, Effect-TS — берут не за это. Их продают три вещи, которые в императивном коде решаются плохо.

Ресурсы, которые нельзя не закрыть

Классическая проблема: соединение открыли, дальше упало исключение, close не вызвался. try/finally спасает, пока сценарий линейный; при вложенности и асинхронности он превращается в лестницу. Функциональный ответ — комбинатор bracket: «взять, использовать, гарантированно отпустить» — как одно значение, которое можно передавать и комбинировать.

// Effect-TS: ресурс как значение, освобождение гарантировано рантаймом
const withConn = Effect.acquireRelease(
  Effect.promise(() => pool.connect()),          // acquire
  (conn) => Effect.promise(() => conn.release()) // release: вызовется при любом исходе
);

const program = Effect.scoped(
  Effect.flatMap(withConn, (conn) =>
    Effect.promise(() => conn.query("select 1")))
);

Ключевое отличие от try/finally — композиционность: два ресурса объединяются в один комбинатором, и порядок освобождения (обратный порядку захвата) обеспечивается автоматически, включая случай отмены.

Отмена, которая не рвёт инварианты

В мире Promise отмены де-факто нет: AbortController работает только там, где библиотека его поддержала. В fiber-based рантаймах отмена — часть модели: файбер прерывается в безопасных точках, finalizer-ы отрабатывают, критические секции помечаются uninterruptible. Это то, что делает надёжными таймауты и гонки:

// Кто первый — тот и ответ; проигравший гарантированно отменён и подчищен
const fastest = Effect.race(fromCache, fromApi);
const guarded = Effect.timeout(fastest, "2 seconds");

Написать это корректно руками — с гарантией, что проигравший запрос не оставит после себя открытый сокет, — задача на полдня и источник вечных утечек.

Ретраи, таймауты и политика как данные

Поскольку эффект — значение, стратегии тоже становятся значениями, которые можно комбинировать и тестировать:

const policy = Schedule.exponential("100 millis")
  .pipe(Schedule.jittered, Schedule.compose(Schedule.recurs(5)));

const resilient = Effect.retry(callApi, policy);

Сравните с типичным императивным ретраем: цикл for, ручной sleep, ручной подсчёт попыток, и всё это скопировано в семь мест с разными константами.

Обратите внимание на последнюю стрелку: в этих системах ошибка тоже присутствует в типе (Effect<A, E, R> — успех, ошибка, требуемое окружение). Это прямое продолжение темы из статьи Обработка ошибок в ФП, только распространённое на асинхронный код.

Ступень 5: алгебраические эффекты — то, куда всё движется

У монадического подхода есть структурная беда, о которой мы говорили в статье Монады: монады не композируются. Если нужны и состояние, и ошибки, и ввод-вывод — приходится строить стек трансформеров, lift-ить, помнить порядок слоёв.

Алгебраические эффекты с обработчиками решают это иначе. Идея: код объявляет, какие операции ему нужны, а вызывающая сторона устанавливает обработчик — почти как try/catch, только обработчик может вернуть управление обратно в точку вызова. Теоретическая база — работа Гордона Плоткина и Матии Претнара «Handlers of Algebraic Effects».

// Koka: эффект виден в типе, но синтаксис — обычный прямой код
effect read
  fun ask() : string

fun greeting() : read string
  "Привет, " ++ ask()          // никаких map/flatMap

fun main()
  // Обработчик решает, что значит "ask" — здесь фикстура, в проде был бы stdin
  with handler
    fun ask() resume("мир")
  println(greeting())

Что здесь принципиально:

  • Код пишется прямолинейно, без явных flatMap и без монадической окраски, а эффекты всё равно отражены в типе (: read string).
  • Обработчики композируются: два эффекта — просто два обработчика, никаких трансформеров и вопросов «какой слой снаружи».
  • На этом одном механизме выражаются исключения, состояние, генераторы, async/await и кооперативная многозадачность — они перестают быть отдельными языковыми фичами.

Это уже не экзотика: обработчики эффектов есть в OCaml 5 (на них построена библиотека конкурентности Eio), в Unison под именем abilities, в исследовательских языках Koka и Effekt. Цена — недостаточная зрелость экосистем, сложность вывода типов эффектов и то, что производительность обработчиков сильно зависит от реализации (наивная реализация через захват продолжений дорога).

А что делает Elixir: путь без IO-монады

Полезный контрпример. BEAM-языки — функциональные, но IO-монады в них нет: File.read/1 честно выполняет чтение. Эффекты изолируются не типами, а процессами и сообщениями.

defmodule Orders.Core do
  # Чистое ядро: обычные функции над данными, ноль эффектов
  @spec decide(map(), DateTime.t()) :: {:ok, map()} | {:error, atom()}
  def decide(%{total: total} = order, now) when total > 0 do
    {:ok, %{order | priced_at: now, total: total * 1.2}}
  end
  def decide(_order, _now), do: {:error, :invalid_total}
end

defmodule Orders.Shell do
  # Оболочка: эффекты, но никакой бизнес-логики
  def handle(id) do
    with {:ok, order} <- Repo.fetch(id),                 # эффект
         {:ok, priced} <- Orders.Core.decide(order, DateTime.utc_now()),  # чисто
         {:ok, saved} <- Repo.save(priced) do            # эффект
      Events.publish({:order_priced, saved.id})          # эффект
      {:ok, saved}
    end
  end
end

Изоляция достигается другими средствами: состояние живёт внутри процесса и снаружи недоступно (GenServer), падение процесса не портит остальных, супервизор перезапускает с чистого листа, а подмена зависимостей в тестах делается через поведения и Mox — библиотеку, автор которой отдельно настаивает: мок — это существительное (подставная реализация контракта), а не глагол (подмена чего попало на лету).

Вывод для практики: дисциплина «ядро/оболочка» ценна и без типовой поддержки. Elixir получает 80% выигрыша, не заплатив за монадический стиль ничем — и это осознанный размен, о котором подробнее в курсе Elixir.

Честная цена: где эффект-системы мешают

Обязательный раздел, без которого статья была бы рекламой.

Производительность. Здесь важно различать три случая. В Haskell IO a компилируется в функцию над токеном состояния и почти полностью стирается оптимизатором — накладные расходы близки к нулю. Free-монады и наивные интерпретаторы платят аллокацией и диспетчеризацией на каждый шаг — это десятки-сотни наносекунд на операцию, что критично в горячем цикле и незаметно рядом с сетевым вызовом на 20 мс. Fiber-рантаймы (ZIO, Cats Effect, Effect-TS) платят за планировщик, но выигрывают на конкурентности — тысячи файберов дешевле тысяч потоков ОС. Практическое правило: эффект-система оправдана там, где стоимость шага измеряется в миллисекундах ввода-вывода, и вредна там, где в наносекундах вычислений. Отдельная тема — цена самих неизменяемых структур, разобранная в статье Персистентные структуры данных.

Отладка и трассировки. Это самая недооценённая статья расходов. Стек вызовов перестаёт соответствовать логике программы: вы видите внутренности рантайма, а не свои функции. ZIO и Effect-TS строят собственные трассировки — и платят за это временем выполнения и объёмом кода. Точку останова внутрь цепочки flatMap поставить сложнее, чем на строку императивного кода. Команда, которая привыкла к пошаговой отладке, ощутит это в первый же инцидент.

Кривая обучения и найм. Эффект-система — это фактически второй язык поверх основного: свой контроль потока, своя обработка ошибок, своя конкурентность, свои идиомы. Новый человек в проекте на Effect-TS не «просто пишет TypeScript». Это реальный организационный риск, и его стоит оценивать до, а не после.

Интероп. Внешний мир состоит из библиотек, которые выполняют эффекты сами. Каждая такая библиотека требует обёртки на границе. Обёртки бывают протекающими — особенно там, где библиотека завела свои потоки, свой пул или свою отмену.

Ложное чувство безопасности. Тип IO говорит, что эффект есть, но не говорит какой. Программа, которая «типобезопасно» удаляет прод-базу, типобезопасна. Гранулярность даёт только собственный алфавит эффектов — а это уже ступень 3 со всеми её издержками.

Где эффекты выселять не надо. Скрипт на сто строк, ETL-джоба, одноразовая миграция, прототип — здесь «сэндвич» и IO дают только накладные расходы. Простой императивный код с try/finally честнее. И наоборот: длинноживущий домен со сложными правилами и высокими требованиями к тестируемости — идеальный кандидат.

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

  • Считать эффектом только запись. Чтение часов, random, uuid4(), переменные окружения — эффекты, и именно они делают тесты «мигающими». Передавайте их в ядро как аргументы.
  • Прятать эффект в геттере или конструкторе. order.total не должно ходить в базу. Скрытый эффект хуже явного: он ломает интуицию о стоимости и порядке.
  • Строить IO внутри цикла и тут же его запускать. Смысл описания в том, что оно строится один раз; unsafeRun в середине пайплайна возвращает вас в исходную точку с дополнительным слоем церемонии.
  • Путать IO с Promise. Энергичный Promise уже стартовал; ретраить, отменять или переиспользовать его как описание нельзя.
  • Мокать всё подряд вместо того, чтобы уменьшать оболочку. Если тест требует шесть моков — это сигнал, что бизнес-решения протекли в оболочку. Правильная реакция — сдвинуть логику в ядро, а не написать седьмой мок.
  • Забывать release при отмене. try/finally не покрывает случай, когда задачу прервали снаружи. Используйте bracket/Scope/acquireRelease — они покрывают.
  • Вводить эффект-систему «снизу», в одном модуле. Она заразительна: границы между «эффектным» и «обычным» кодом превращаются в постоянные адаптеры. Решение принимается на уровне сервиса целиком.
  • Тестировать журнал эффектов вместо результата. Проверка «вызвали ровно эти семь команд в этом порядке» делает тест хрупким к любому рефакторингу. Проверяйте наблюдаемый итог, а последовательность — только там, где порядок и есть контракт.

Связь с теорией — коротко и в конце

Теперь можно назвать вещи именами. IO — монада: у неё есть pure : A -> IO A и flatMap : IO A -> (A -> IO B) -> IO B, подчиняющиеся тем же трём законам. Историческая интуиция, из которой она выросла: IO a примерно эквивалентен функции World -> (a, World) — «взять мир, вернуть значение и новый мир». Мир нельзя скопировать, поэтому такая функция должна использовать свой аргумент ровно один раз (линейно) — именно это и обеспечивает flatMap, продевая мир через цепочку по одному шагу. В GHC эта модель реализована буквально: IO a — обёртка над функцией над токеном State# RealWorld, который стирается на этапе компиляции.

Алгебраические эффекты имеют другую теоретическую основу — алгебраические операции и их обработчики, тесно связанные с ограниченными продолжениями (delimited continuations). Free-монада же формально свободна в том же смысле, в каком свободен свободный моноид: она даёт монадическую структуру «даром», не добавляя никаких уравнений сверх обязательных законов.

Ничего из этого не нужно знать, чтобы писать код по ступеням 1–2 — но полезно, чтобы понимать, почему узор один и тот же. Формальная база — раздел про алгебраические структуры в курсе Математика и вычислительные основы в курсе Лямбда-исчисление. Первоисточник по монадам и вводу-выводу — работы Филипа Уодлера.

Мини-итог

  • Эффект — это всё, что ломает ссылочную прозрачность: не только запись в мир, но и чтение часов, случайность, окружение, мутация аргумента, исключение.
  • Ступень 1 (даёт 80% пользы): выселить эффекты в тонкую оболочку, оставить в середине чистое ядро, где живут все решения. Не требует ни библиотек, ни языковой поддержки.
  • Ступень 2: сделать эффект значением-описанием. Чистой остаётся вся программа, кроме единственной точки запуска. Promise этому не соответствует — он энергичен.
  • Ступень 3: описывать не «эффект вообще», а свой алфавит команд, и писать под него несколько интерпретаторов — прод, тест, dry-run. Цена — аллокации, сложность и плохие трассировки.
  • Ступень 4: промышленные эффект-системы берут не за тестируемость, а за ресурсы (bracket), надёжную отмену и политики ретраев как данные.
  • Ступень 5: алгебраические эффекты дают прямолинейный код и композицию без трансформеров — за счёт зрелости экосистем.
  • BEAM показывает, что дисциплину ядро/оболочка можно получить и без типовой поддержки — через процессы, поведения и супервизоров.
  • Цена реальна: отладка, кривая обучения, интероп, найм. Для скрипта на сто строк ничего из этого не нужно; для долгоживущего домена — окупается.

Что дальше

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

Конкурентность в ФП: неизменяемость, STM, акторы

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

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

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

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