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> врёт умолчанием. По ней не
видно четырёх важных вещей:
- Какие ошибки возможны. В
catch (e)типe, этоunknown.NotFoundError?NetworkError?TypeErrorизres.json()? Команда держит это в голове по неписаному договору, а компилятор молчит, когда договор меняется. - От чего функция зависит. Чтобы протестировать
processOrder, которая внутри дёргает базу, Redis, логгер и почту, нужно замокать все четыре. Но по сигнатуре не видно, что именно мокать. Приходится читать тело. - Кто закроет ресурсы. Соединение с базой, файловый дескриптор,
сокет надо освободить.
try/finallyсправляется с одним ресурсом, но на трёх вложенных превращается в лесенку, в которой ошибка изfinallyещё и съедает исходную ошибку. - Кто отменит лишнее.
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.