Эффекты и ввод-вывод: как чистый код общается с грязным миром
Есть старая шутка: единственная по-настоящему чистая программа — та, которая нагревает процессор и ничего больше. Всё полезное, что делает софт, — это эффекты: прочитать запрос, сходить в базу, положить сообщение в очередь, записать файл, показать пиксели.
Значит, у чистого программирования есть обязательство. Если мы двенадцать статей строили мир без изменяемого состояния и побочных эффектов (Чистые функции, Неизменяемость), то придётся ответить на прямой вопрос: а как тогда вообще что-то происходит?
Ответов у ФП несколько, и они выстраиваются в лестницу — от почти бесплатного дисциплинарного приёма до полноценной системы эффектов в типах. Пройдём её снизу вверх, каждый раз начиная с боли, которая заставляет подняться на ступеньку. Обзорный взгляд на парадигму — в статье Функциональное программирование; здесь мы копаем в глубину.
Боль нулевая: что вообще считать эффектом
Прежде чем бороться, договоримся о цели. Эффект — это всё, что делает функцию не заменяемой на её результат. Формально: функция чиста, если вызов 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-сэндвичем.
Механика простая: сначала прочитали всё, что нужно, потом чисто посчитали, потом записали всё, что решили. Перепишем пример выше:
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)
Три следствия, которые обычно упускают:
mainничего не выполняет. Она строит одно большое значение типаIO (). Исполняет его рантайм GHC — единственная нечистая точка во всей программе.IOзаразителен вверх, но не вниз. ИзIOможно вызвать чистую функцию, обратно — нет (безunsafePerformIO, который так назван не случайно). Это делает эффект видимым в типе: сигнатураOrder -> Decision— гарантия, а не обещание в комментарии.- Описание можно использовать дважды.
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 показывает, что дисциплину ядро/оболочка можно получить и без типовой поддержки — через процессы, поведения и супервизоров.
- Цена реальна: отладка, кривая обучения, интероп, найм. Для скрипта на сто строк ничего из этого не нужно; для долгоживущего домена — окупается.
Что дальше
Мы научили чистый код общаться с миром, но мир редко бывает однопоточным. Следующая тема — почему неизменяемость радикально упрощает конкурентность, чем программная транзакционная память отличается от блокировок и как модель акторов делит между собой ответственность за состояние.