Раздел 2 · Программирование / CS

Effect systems: эффект как значение, от монад к algebraic effects

senior~70 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Effect systems: эффект как значение, от монад к algebraic effects

Урок 07 закрыл общую алгебру: Functor, Applicative, Monad, плюс Semigroup-Monoid и Foldable-Traversable. Сегодня мы возьмём эту алгебру и применим её к самой беспокойной части программы, к эффектам: асинхронности, сетевым вызовам, чтению окружения, ошибкам.

Идея одна: эффект, это не “что-то, что сейчас случится”, а значение, которое описывает, что должно случиться. Программа собирает дерево описаний и в одном месте передаёт его runtime-у. Runtime обходит дерево и выполняет настоящие операции. Дальше по уроку мы увидим этот приём в трёх воплощениях: ручная IO-монада (об этом существующий воркшоп io-monad), стек fp-ts с Task и TaskEither, и наконец Effect-TS с одним типом Effect<A, E, R>. Заодно пройдём родословную идеи: от монад Moggi через algebraic effect handlers Plotkin и Pretnar до боевых языков Koka и OCaml 5.

Этот урок концептуальный: он объясняет, почему effect-системы устроены именно так. Прикладной разбор Effect-TS (Schema, Layer, Файбер, Stream, STM) живёт отдельным треком в разделе Effect-TS, тринадцать уроков с проектом Pulse. После 04-08 ты прочитаешь этот трек, io-monad и orm-workshop без шифровки.

Четыре проблемы, которые мы лечим

Прежде чем смотреть решения, посмотрим на болезнь. Возьми обычную async-функцию:

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (res.status === 404) throw new NotFoundError(id);
  if (!res.ok) throw new NetworkError(res.statusText);
  return res.json();
}

Сигнатура (id: string) => Promise<User> врёт умолчанием. По ней не видно четырёх важных вещей:

  1. Какие ошибки возможны. В catch (e) тип e, это unknown. NotFoundError? NetworkError? TypeError из res.json()? Команда держит это в голове по неписаному договору, а компилятор молчит, когда договор меняется.
  2. От чего функция зависит. Чтобы протестировать processOrder, которая внутри дёргает базу, Redis, логгер и почту, нужно замокать все четыре. Но по сигнатуре не видно, что именно мокать. Приходится читать тело.
  3. Кто закроет ресурсы. Соединение с базой, файловый дескриптор, сокет надо освободить. try/finally справляется с одним ресурсом, но на трёх вложенных превращается в лесенку, в которой ошибка из finally ещё и съедает исходную ошибку.
  4. Кто отменит лишнее. Promise.all запускает параллельно, но если один упал, остальные надо погасить руками через AbortController. Это легко забыть.

Effect-системы целятся ровно в эти четыре дыры. Дальше увидим, как “эффект, это значение” закрывает каждую.

Эффект как значение

В JS привычная форма побочного действия, императивный вызов:

const greet = async () => {
  const name = await readLine('как тебя зовут?');
  console.log(`привет, ${name}`);
};

Каждая строчка тут это инструкция, которая немедленно начинает что-то делать. readLine обращается к stdin, console.log пишет в stdout. Если положить функцию в переменную, эффекты пока не происходят, но как только мы её вызовем, начнут.

Альтернатива: эффект как значение. Программа описывает, что должно случиться, а runtime в одном месте эту программу запускает:

import { Effect, Console } from 'effect';

const greet = Effect.gen(function* () {
  const name = yield* Effect.promise(() => readLine('как тебя зовут?'));
  yield* Console.log(`привет, ${name}`);
});

// здесь ничего не произошло, greet это значение
// эффекты выполняются только в одном месте
Effect.runPromise(greet);

Разница тонкая на первый взгляд, фундаментальная по последствиям. Если эффект, значение, его можно:

  • передать в функцию-комбинатор без выполнения (retry(greet, schedule), timeout(greet, 5000)),
  • протестировать, подменив runtime (тест видит, что описано, не вызывая настоящий I/O),
  • собирать программу в чистом ядре, а runtime подключать на границе.

Ленивость тут ключевая. Promise стартует в момент создания, а Effect, это план, который ещё не выполнялся. Поэтому план можно сколько угодно перекладывать, оборачивать и переставлять, пока не дошёл до запуска.

Это та же архитектура, что в Haskell: значение IO () это дерево инструкций. Воркшоп io-monad показывает, как такое дерево собрать руками на TS с tagged union и интерпретатором. Сегодня мы возьмём готовые библиотечные ответы.

Откуда взялась идея: от монад к algebraic effects

“А что если отслеживать побочные эффекты системой типов?”, этот вопрос индустрия и академия копают тридцать лет. Полезно знать родословную: она объясняет, почему fp-ts выглядит так, а Effect-TS иначе.

Moggi (1991): вычисление это монада

В работе “Notions of Computation and Monads” Эудженио Моджи заметил простую вещь. Чистая функция A -> B означает “берёт A, возвращает B”. Добавь побочный эффект, и она превращается в A -> T(B), где T, это монада, “возвращается B, но по пути что-то происходит”. Подбором T выражаются разные эффекты:

  • T(B) = B | Error, вычисление, которое может бросить исключение;
  • T(B) = State -> (B, State), вычисление, меняющее состояние;
  • T(B) = List<B>, недетерминированное вычисление (несколько результатов);
  • T(B) = IO<B>, вычисление, которое говорит с внешним миром.

Тип IO в Haskell, это прямое следствие. Но у монад была фундаментальная беда: разные монады плохо складываются. Чтобы выразить “может бросить исключение и при этом меняет состояние”, приходится штабелировать монадные трансформеры вроде EitherT[StateT[IO, S, _], E, A]. Это рушит вывод типов и бьёт по производительности. Стек fp-ts ниже, ровно такой штабель.

Plotkin & Power (2002): разложить монаду на операции

Plotkin и Power предложили другой угол. Вместо того чтобы тащить монаду целиком, разложи её на отдельные операции. Монада State, это две операции, get и put. Монада Exception, это операция raise. Их назвали алгебраическими операциями: они подчиняются законам и свободно комбинируются, как операции в обычной алгебре.

Это важно, потому что решает проблему композиции. Целые монады складывать тяжело, а отдельные операции, легко. Просто перечисляешь, что вычислению нужно (“использует get, put, raise”), и этот набор операций становится типом эффекта. Хочешь добавить эффект, добавь операцию в набор. Никаких трансформеров.

Plotkin & Pretnar (2009): handler как возобновляемое исключение

В работе “Handlers of Algebraic Effects” появился effect handler, обобщение обработчика исключений. Сравни. Обычный try/catch разматывает стек: после throw вернуться в точку броска и продолжить нельзя.

try {
  throw new Error('failure');
} catch (e) {
  // можно обработать, но нельзя вернуться и продолжить с места броска
}

Effect handler снимает это ограничение. Он получает continuation, остаток вычисления, и может передать значение обратно, продолжив с того же места. Это “возобновляемое исключение”:

function getName() {
  // perform: запускает эффект, как throw, но может вернуться
  let name = perform 'askName';
  return "привет, " + name;
}

// handle: обрабатывает эффект, как catch, но может продолжить
try {
  getName();
} handle (effect) {
  if (effect === 'askName') {
    resume with "мир";  // 'мир' попадает в name, исполнение продолжается
  }
}
// результат: "привет, мир"

Как объясняет Дан Абрамов в “Algebraic Effects for the Rest of Us”, главный выигрыш: промежуточные функции не обязаны знать про эффект. Сколько бы функций ни стояло между вызовом getName() и handler-ом, их менять не нужно. Нет function coloring, той самой проблемы async/await, где async приходится приклеивать ко всей цепочке вызовов.

Боевые языки: Koka, OCaml 5, Eff

Теория доехала до настоящих языков.

  • Koka отслеживает эффекты row-polymorphic типами: fun foo(): <exn, io> int читается как “может бросить, делает I/O, возвращает int”. Эффекты складываются без трансформеров.
  • OCaml 5 (2022) добавил handlers в язык: perform запускает эффект, continue k value возобновляет continuation. Поддержка single-shot: один continuation нельзя вызвать дважды, зато предсказуемо и быстро.
  • Eff, язык-полигон, на котором обкатали теорию handler-ов до Koka и OCaml.

Общее у этих реализаций: continuation захватывает runtime языка. Когда вызывается perform, runtime сохраняет текущее состояние исполнения, а handler его возобновляет. Без поддержки на уровне языка этот механизм не построить. Запомни этот факт, через раздел он объяснит, почему Effect-TS устроен иначе.

Стек fp-ts: Task, TaskEither, Reader, RTE

Вернёмся в TypeScript. В fp-ts эффект собирается из четырёх независимых “частей” по нужде:

  • Task<A>, асинхронность без ошибок: () => Promise<A>,
  • TaskEither<E, A>, асинхронность с ошибками: () => Promise<Either<E, A>>,
  • Reader<R, A>, чтение окружения: (r: R) => A,
  • ReaderTaskEither<R, E, A>, всё вместе: (r: R) => () => Promise<Either<E, A>>.

Это MTL-стек, ровно те монадные трансформеры из родословной выше: каждая обёртка несёт один эффект, нужные складываются. Универсально, но шумно: подпись функции легко вырастает в (deps: Deps) => () => Promise<Either<AppError, Order>>.

import { taskEither as TE } from 'fp-ts';
import { pipe } from 'fp-ts/function';

type AppError = 'NotFound' | 'Network';

declare const fetchUser: (id: string) => TE.TaskEither<AppError, User>;
declare const fetchOrders: (user: User) => TE.TaskEither<AppError, Order[]>;

const program = (id: string): TE.TaskEither<AppError, Order[]> =>
  pipe(
    fetchUser(id),
    TE.flatMap(fetchOrders),
  );

// запуск
program('u-42')().then((either) => {
  if (either._tag === 'Right') console.log(either.right);
  else console.error(either.left);
});

flatMap тут это та же монадическая операция, что в уроке 07, только в другом контейнере. Оператор pipe пришёл из урока 05. Видишь, как куски складываются.

Когда стек становится тяжёлым (все четыре эффекта плюс перевод между ними), удобнее перейти на одно-эффектную систему.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.