Построй свою IO-монаду
В Haskell IO a это не функция, которая “делает side effect”. Это описание программы: дерево инструкций, которое runtime (GHC RTS) интерпретирует. Само по себе IO-значение, чистые данные. Эффекты происходят только когда интерпретатор обходит это дерево.
В этом задании ты воспроизведёшь эту архитектуру на JS/TS, шаг за шагом, на одном сквозном примере (программа, которая спрашивает имя и возраст, потом подтягивает приветствие из API):
- Опишешь DSL, набор инструкций (
ReadLine,WriteLine,Fetch) - Реализуешь
bind(>>=), композицию инструкций с передачей результата - Напишешь интерпретатор, единственное место, где происходят side effects
- Покажешь, что одну и ту же программу можно запустить в разных “мирах” (browser / Node / test)
- (★) Добавишь обработку ошибок, do-notation через генераторы и Freer encoding
Сквозной пример
Через все части прокатимся на одной и той же программе. Сначала она тривиальная: спросить имя, поздороваться. К Части 4 у неё появится второй вопрос (возраст). К Части 7, сетевой вызов за подписью дня. К Части 8 та же программа будет переписана через do-notation на генераторах. К Части 9, обрастёт счётчиком на IORef. К Части 10, обзаведётся типизированным каналом ошибок. На каждом шаге одна и та же бизнес-логика, разные представления. Контраст между ними и есть главный вывод: программа, как данные. Меняешь представление, смысл остаётся прежним.
Главное правило, которое легко нарушить
IO<A> это значение, а не функция. Программа на этом DSL, обычное значение, лежащее в переменной. Не () => value, не () => IO<A>, не thunk. Просто значение.
const greeting: IO<void> = ... // ✓ значение программы
const greeting = (): IO<void> => ... // ✗ thunk поверх IO, лишний слой лени
Сравни с Haskell: greeting :: IO () это значение. Используется как runIO greeting, без (). Если ловишь себя на () => IO<X>, ты не уловил суть. IO<X> уже отложенное вычисление, второй слой обёртки не нужен и вреден: он шепчет “вдруг что-то выполнится при импорте”, а смысл всей конструкции в обратном, ничего не выполняется, пока не пришёл интерпретатор.
Это правило держим в голове до самого конца, особенно в Части 8 (doIO).
Часть 0. Разминка: что не так с этим кодом?
Тот же сквозной пример, но в наивной императивной форме. Прочитай и поймай нос:
const main = () => {
const name = readLine("What is your name?");
writeLine(`Hello, ${name}!`);
const data = fetch("https://api.example.com/greet");
writeLine(data);
};
Вопрос: почему этот код не может быть чистой функцией? Что мешает тестировать его без реального stdin и сети?
Подсказка: подумай, в какой момент происходят side effects и можно ли подменить реализацию
readLine/fetch, не меняя самmain.
Ответ, который мы будем строить дальше: разделить описание и выполнение. main должен возвращать структуру данных, описывающую “что сделать”, а отдельный интерпретатор пусть решает “как именно”: через реальный stdin или через тестовый mock.
Часть 1. DSL, опиши свой язык
Определи тип IO<A>, tagged union, описывающий вычисление, которое когда-нибудь вернёт значение типа A.
Начни с трёх инструкций:
| Инструкция | Аналог в Haskell | Что делает |
|---|---|---|
Pure(a) | pure a / return a | Завершает вычисление, возвращая a |
ReadLine(next) | getLine >>= next | Читает строку, передаёт результат в next |
WriteLine(text, next) | putStrLn s >> next | Печатает строку, продолжает next |
Обрати внимание на разницу:
ReadLine.nextэто функция(string) => IO<A>, потому что read возвращает значениеWriteLine.nextэто простоIO<A>, потому что write ничего не возвращает
Это именно то, как работает >>= в Haskell: getLine >>= \name -> ..., лямбда получает результат. По сути next это continuation, остаток программы после этой инструкции.
Проверь себя, попробуй вручную собрать первый шаг сквозного примера:
// Эта структура, ЧИСТЫЕ ДАННЫЕ, ноль side effects
const greeting: IO<void> = {
tag: "writeLine",
text: "What is your name?",
next: {
tag: "readLine",
next: (name) => ({
tag: "writeLine",
text: `Hello, ${name}!`,
next: { tag: "pure", value: undefined }
})
}
};
Вопрос: greeting это значение. Его можно положить в переменную, передать в функцию, сериализовать в JSON (если бы не лямбда внутри). Что произойдёт, когда мы его создадим? Правильный ответ, ничего: ни одного байта в stdout, ни одного байта из stdin. Дерево лежит в памяти и ждёт интерпретатора.
Часть 2. Конструкторы, умные обёртки
Писать вложенные объекты вручную, больно. Напиши конструкторы:
const pure = <A>(value: A): IO<A> => ???
const readLine: IO<string> = ???
const writeLine = (text: string): IO<void> => ???
Подсказка: каждый конструктор создаёт “минимальную” инструкцию, у которой
nextпросто оборачивает результат вPure. Обрати внимание, чтоreadLineэто значение, а не функция. КакgetLineв Haskell, этоIO String, а не() -> IO String.
С конструкторами тот же greeting собирается так (но без bind пока, поэтому всё ещё вложенно):
const greeting: IO<void> = writeLineThen(
"What is your name?",
readLineThen((name) => writeLine(`Hello, ${name}!`))
);
writeLineThen и readLineThen, временные хелперы, в Части 3 их заменит универсальный bind. Но даже с этим хаком уже видно: код стал короче, форма дерева, та же.
Часть 3. bind, сердце монады
bind (он же >>=, он же chain, он же flatMap) это то, что делает IO монадой.
bind : IO<A> -> (A -> IO<B>) -> IO<B>
По-русски: “возьми вычисление, которое вернёт A, и функцию, которая из A сделает новое вычисление IO<B>. Склей их в одно IO<B>”.
bind не выполняет эффекты. Он рекурсивно перестраивает дерево, “приклеивая” продолжение f к каждому листу Pure(a):
bind(Pure(a), f) = f(a)
bind(ReadLine(next), f) = ReadLine(x => bind(next(x), f))
bind(WriteLine(t, next), f) = WriteLine(t, bind(next, f))
Реализуй:
const bind = <A, B>(io: IO<A>, f: (a: A) => IO<B>): IO<B> => {
switch (io.tag) {
case "pure":
return ???
case "readLine":
return ???
case "writeLine":
return ???
}
};
Подсказка: для
Pure, подставляешь значение вf. Для остальных, создаёшь такой же узел, но “протаскиваешь”fглубже через рекурсивный вызовbind.
Бонус: выведи из bind ещё две операции:
// map (fmap): IO<A> -> (A -> B) -> IO<B>
const map = <A, B>(io: IO<A>, f: (a: A) => B): IO<B> => ???
// then (>>): IO<A> -> IO<B> -> IO<B>
// "сделай первое, выброси результат, сделай второе"
const then = <A, B>(first: IO<A>, second: IO<B>): IO<B> => ???
map это инстанс Functor: операция, которая применяет чистую функцию к результату вычисления, не трогая структуру эффектов. then это упрощённый bind, для случая, когда тебя не интересует значение из первого шага (как >> в Haskell vs >>=).
Замечание про сложность. Твоя реализация будет работать, но обрати внимание:
bind(WriteLine(t, next), f)создаёт новый узел и рекурсивно идёт вnext. Это значит, чтоbind(bind(bind(a, f), g), h), left-associated цепочка, переобходит дерево квадратичное число раз. Подержи это в голове, в Части 12 разберём, что с этим делать.Замечание про стек.
bindрекурсивен. Для глубоких чистых цепочек (bind(pure(0), x => bind(pure(x+1), ...))× 100k) словишь stack overflow ещё на этапе построения дерева, до всякой интерпретации. На реальных IO-программах это редко критично, между эффектами стек размотается, но trampoline-safe эту реализацию не назвать.
Часть 4. Напиши программу
Развиваем сквозной пример: поздороваться, потом спросить возраст, потом подтвердить. Используя bind, pure, readLine, writeLine, then, опиши:
- Напечатай
"What is your name?" - Прочитай строку ->
name - Напечатай
"Hello, {name}! How old are you?" - Прочитай строку ->
age - Напечатай
"Wow, {name}, {age} is a great age!"
const myProgram: IO<void> = ???
Заметь: myProgram это значение, лежащее в const. Не функция. Никаких () => .... Та же программа из Части 1, только теперь содержит два чтения подряд, а вложенные объекты собирает за тебя bind. На уровне типа это всё ещё IO<void>, описание дерева в памяти.
Сравни с Haskell do-notation:
myProgram :: IO ()
myProgram = do
putStrLn "What is your name?"
name <- getLine
putStrLn ("Hello, " ++ name ++ "! How old are you?")
age <- getLine
putStrLn ("Wow, " ++ name ++ ", " ++ age ++ " is a great age!")
Каждая строка x <- action это bind(action, (x) => ...). Каждая строка без <- это then(action, ...). Do-notation, синтаксический сахар над bind. К этому виду мы вернёмся в Части 8 уже на JS-генераторах.
Часть 5. Интерпретатор, единственное нечистое место
В Haskell main :: IO () это просто значение. GHC RTS берёт его и интерпретирует: обходит дерево, выполняя реальные эффекты. Напиши свой “RTS”.
Реализуй runIO, интерпретатор, который принимает IO<A> и “мир” (набор реальных реализаций эффектов):
interface World {
readLine: () => Promise<string>;
writeLine: (s: string) => Promise<void>;
}
const runIO = async <A>(io: IO<A>, world: World): Promise<A> => {
let current: IO<any> = io;
while (true) {
switch (current.tag) {
case "pure":
return current.value;
case "readLine":
???
case "writeLine":
???
}
}
};
Подсказка: для каждого тега, выполни реальный эффект через
world, получи результат, подставь его вnext, присвой вcurrent. Циклwhile(true)вместо рекурсии, это trampoline для stack safety.
Структурно интерпретатор это fold по AST: каждый тег маппится в реальный эффект, результат подставляется в continuation, цикл крутится дальше.
Сердце монады и интерпретатор стоит написать не в уме, а в редакторе: мир для проверки уже готов, от тебя нужны bind, map, then и runIO.
Часть 6. Разные миры
Напиши три реализации World:
1. Браузерный мир, prompt для ввода, console.log для вывода.
2. Node.js мир, readline.createInterface для ввода, console.log для вывода.
3. Тестовый мир, принимает массив строк как “ввод”, собирает вывод в массив. Никаких side effects.
Важно про тесты. Тестовый мир должен падать на незамоканных вызовах, а не возвращать
''. Mock-as-default-empty, антипаттерн: тест зелёный там, где должен быть красный, потому что программа молча получила пустую строку. Базовое правило mocks, fail loud.
Проверь тестовым миром:
const test = async () => {
const world = testWorld(["Alice", "30"]);
await runIO(myProgram, world); // myProgram, без скобок!
console.assert(world.output[0] === "What is your name?");
console.assert(world.output[1] === "Hello, Alice! How old are you?");
console.assert(world.output[2] === "Wow, Alice, 30 is a great age!");
console.log("All tests passed!");
};
Ключевой момент: одна и та же программа myProgram работает в браузере, в Node.js и в тестах, без единого изменения. Программа не знает, откуда берутся строки и куда идёт вывод. Это и есть dependency inversion в чистом виде: зависимость не на конкретный stdin, а на интерфейс World.
Часть 7. Расширение, Fetch
Развиваем сквозной пример. Хочется, чтобы перед прощанием программа сходила в API за персонализированным приветствием дня. Добавь инструкцию Fetch в DSL:
| { tag: "fetch"; url: string; options?: RequestInit; next: (body: string) => IO<A> }
Что нужно обновить:
- Конструктор
fetchUrl - Кейс в
bind - Кейс в
runIO - Поле
fetchвWorld - Мок в тестовом мире
Расширим myProgram: после возраста сделаем fetch и напечатаем тело ответа:
const myProgram: IO<void> =
bind(writeLine("What is your name?"), () =>
bind(readLine, (name) =>
bind(writeLine(`Hello, ${name}! How old are you?`), () =>
bind(readLine, (age) =>
bind(writeLine("Loading greeting of the day..."), () =>
bind(fetchUrl("https://httpbin.org/uuid"), (body) =>
writeLine(`Wow, ${name}, ${age}! Today's lucky token: ${body}`)
))))));
К этому моменту вложенность уже больно бьёт по глазам. Это нормально, в Части 8 рефакторнём её через генераторы. А сейчас обрати внимание на главное: логика та же, новый эффект не потребовал переписать ни одной существующей строки в myProgram, кроме встраивания нового шага.
Замечание про масштабируемость. Заметил, что добавление одной инструкции потребовало правок в пяти местах? Это нарушение принципа “открыт для расширения, закрыт для модификации”: ядро языка (
IO,bind) знает обо всех инструкциях. В Части 11 покажу, как это разделить, чтобы новый эффект добавлялся только новым вариантом в одном объединении, без правокbindиIO.
Часть 8 ★. Do-notation через генераторы
Вложенные bind из Части 7 уродливы. В JS есть генераторы, они позволяют записать ту же программу почти как do-notation. Перепишем myProgram так, чтобы он читался как обычный императивный код.
8.1. Наивная версия
type IOGen<A> = Generator<IO<unknown>, A, unknown>;
const doIO = <A>(genFn: () => IOGen<A>): IO<A> => ???
Подсказка: вызови
gen.next(value), получиresult. Еслиresult.done, верниpure(result.value). Иначе,bind(result.value, ...)с рекурсивным шагом.doIOпринимает фабрику() => IOGen, а не сам генератор, потому что генераторы мутабельны и одноразовые, фабрика создаёт свежий генератор на каждый запуск интерпретатора.
Сразу как doIO готов, тот же myProgram из Части 7 преобразуется в линейный текст:
const myProgram = doIO(function* () {
yield writeLine('What is your name?')
const name = (yield readLine) as string
yield writeLine(`Hello, ${name}! How old are you?`)
const age = (yield readLine) as string
yield writeLine('Loading greeting of the day...')
const body = (yield fetchUrl('https://httpbin.org/uuid')) as string
yield writeLine(`Wow, ${name}, ${age}! Today's lucky token: ${body}`)
})
Семь строк вместо лесенки bind. Уродливые as string уберём в 8.3 и 8.4.
8.2. Главное: doIO возвращает IO-значение, а не функцию
Самое частое место для ошибки. Сначала правильный вариант:
// ✓ ПРАВИЛЬНО, myProgram это IO<void>, обычное значение
const myProgram = doIO(function* () {
yield writeLine('what is your name?')
const name = yield* _(readLine)
yield writeLine(`hello, ${name}`)
})
await runIO(myProgram, world) // используется как значение
// ✗ НЕПРАВИЛЬНО, это () => IO<void>, лишний thunk
const myProgram = (): IO<void> => doIO(function* () { ... })
await runIO(myProgram(), world) // приходится звать как функцию
В чём разница? В первом варианте myProgram это программа. Во втором, функция, которая создаёт программу. Лишняя обёртка ломает главное обещание задания: “построение IO чистое, побочных эффектов нет, значение можно положить в переменную и таскать сколько угодно”. В Haskell тип у программы myProgram :: IO (), а не myProgram :: () -> IO (). Никакой разницы.
Если ловишь себя на () => IO<X>, спроси: зачем мне второй слой лени? Ответа обычно нет.
8.3. Адаптер, избавляемся от as string
В наивной версии есть проблема. У IOGen<A> = Generator<IO<unknown>, A, unknown> третий параметр типа (тип того, что возвращает gen.next(value)) равен unknown. Любой yield отдаёт unknown, и приходится кастовать:
const name = (yield readLine) as string // ✗ некрасиво и небезопасно
Это структурное ограничение TypeScript: генераторы не type-aligned, тип yield-выражения не зависит от того, что именно ты yield-ишь.
Решение, паттерн адаптера из Effect-TS. Идея: вместо прямого yield io используем функцию-обёртку _(io), которая делает yield* и возвращает уже типизированное значение:
type Adapter = <A>(io: IO<A>) => Generator<IO<A>, A, A>;
function* _<A>(io: IO<A>): Generator<IO<A>, A, A> {
return (yield io) as A; // каст спрятан внутри адаптера
}
Теперь тот же myProgram:
const myProgram = doIO(function* () {
yield* _(writeLine('What is your name?'))
const name = yield* _(readLine) // name: string, без каста!
yield* _(writeLine(`Hello, ${name}! How old are you?`))
const age = yield* _(readLine)
const body = yield* _(fetchUrl('https://httpbin.org/uuid'))
yield* _(writeLine(`Wow, ${name}, ${age}! Token: ${body}`))
})
yield* (delegating yield) разворачивает вложенный генератор и отдаёт его возвращаемое значение уже с правильным типом. Каст as A остался, но спрятан внутри _, пользователь его не видит. Это и есть трюк Effect-TS.
Честная оговорка. TypeScript не умеет в полностью типизированную do-notation.
fp-tsзабил и кастит на месте. Effect-TS раньше использовал адаптер. В свежих версиях обходятся без него, об этом в 8.4.
8.4. Без адаптера: трюк Effect-TS с Symbol.iterator
Пользователи стиля _(io) жаловались: лишняя сущность в API, её нужно объяснять, документировать, не забывать пробрасывать во вспомогательные функции. В свежих версиях Effect-TS _ исчез, теперь пишут просто:
const elapsed = <A, E, R>(self: Effect<A, E, R>): Effect<A, E, R> =>
Effect.gen(function* () {
const startMillis = yield* now // тип A корректно выводится
const result = yield* self // тип A корректно выводится
const endMillis = yield* now
console.log(`Elapsed: ${endMillis - startMillis}`)
return result
})
Никакого _(...), никаких as. Как они это сделали?
Главный трюк: сам Effect сделали Iterable. Тогда yield* (делегирующий yield) идёт прямо к нему, а TypeScript вытаскивает тип результата из объявления итератора.
Вспомни сигнатуру: Generator<TYield, TReturn, TNext>. У yield* тип результата, это TReturn итератора, к которому делегируем. Effect-TS объявляет:
interface Effect<out A, out E = never, out R = never> {
[Symbol.iterator](): EffectIterator<Effect<A, E, R>>
}
interface EffectIterator<T extends Effect<any, any, any>> {
next(...args: ReadonlyArray<any>): IteratorResult<
T, // TYield, что летит наружу к Effect.gen интерпретатору
Effect.Success<T> // TReturn, это и есть A, тип результата эффекта
>
}
Когда ты пишешь yield* self, TypeScript рассуждает так: “self имеет тип Effect<A, E, R>, у его итератора TReturn = A, значит yield* self тоже имеет тип A”. Каст спрятан внутри итератора, тип вытекает из его объявления, пользователь ничего не кастует руками.
Как это работает в рантайме. [Symbol.iterator] возвращает одноразовый генератор: один раз отдаёт через yield сам self, потом завершается тем значением, которое ему передадут через next():
class SingleShotGen<T, A> implements Iterator<T, A> {
private done = false
constructor(private readonly value: T) {}
next(input: A): IteratorResult<T, A> {
if (this.done) return { done: true, value: input }
this.done = true
return { done: false, value: this.value }
}
}
// Effect.prototype получает Symbol.iterator:
Effect.prototype[Symbol.iterator] = function () {
return new SingleShotGen(this)
}
Что происходит на yield* self:
- Делегирующий генератор зовёт
self[Symbol.iterator]()->SingleShotGen - Первый
.next(undefined)->{ done: false, value: self } - Это значение всплывает наружу, в интерпретатор
Effect.gen Effect.genвидит эффект, выполняет его, получает результат типаA- Передаёт
Aобратно через внешнийgen.next(a) SingleShotGen.next(a)возвращает{ done: true, value: a }yield*в твоём коде разворачивает это вaс типомA
Поведение в рантайме точно такое же, как с адаптером _(io): тот тоже оборачивал эффект, отдавал его через yield, забирал ответ. Просто теперь обёрткой служит сам Effect через Symbol.iterator.
Историческая сноска про обёртку. В третьей версии Effect итератор отдавал наружу не сам эффект, а непрозрачный newtype YieldWrap<T> с фантомным полем. Причина была в вариантности: собирая объединение всех отданных через yield эффектов (особенно союз ошибок E1 | E2 | E3), TypeScript схлопывал его неправильно, и обёртка заставляла считать каждый эффект самостоятельным кирпичом объединения. В четвёртой версии обёртку убрали: вывод типов переделали так, что эффекты и другие yieldable-значения летят через итератор напрямую. Приём стоит знать, он пригодится в любом своём DSL на генераторах.
class YieldWrap<T> {
readonly _Y!: () => T // phantom для invariance
constructor(readonly value: T) {}
}
Перенесём приём в наш IO. Обёртку брать не будем, у нас один тип эффекта и вариантность не кусается:
// Каждый smart-конструктор делает результирующий объект Iterable.
// Удобнее всего сделать общую обёртку:
const mkIO = <A>(io: IO<A>): IO<A> => {
;(io as any)[Symbol.iterator] = function* () {
return (yield this) as A // каст спрятан здесь
}
return io
}
const pure = <A>(value: A): IO<A> => mkIO({ tag: 'pure', value })
const readLine: IO<string> = mkIO({ tag: 'readLine', next: pure })
const writeLine = (text: string): IO<void> =>
mkIO({ tag: 'writeLine', text, next: pure(undefined) })
// ... и так для каждого конструктора
doIO забирает отданный эффект и связывает его с продолжением:
type IOGen<A> = Generator<IO<any>, A, any>
const doIO = <A>(genFn: () => IOGen<A>): IO<A> => {
const gen = genFn()
const step = (value: unknown): IO<A> => {
const result = gen.next(value)
if (result.done) return pure(result.value)
return bind(result.value, step) // result.value это отданный IO
}
return step(undefined)
}
Использование, без _, без as. Тот же myProgram в финальной идиоматичной форме:
const myProgram = doIO(function* () {
yield* writeLine('What is your name?')
const name = yield* readLine // name: string, автоматически
yield* writeLine(`Hello, ${name}! How old are you?`)
const age = yield* readLine // age: string
yield* writeLine('Loading greeting of the day...')
const body = yield* fetchUrl('https://httpbin.org/uuid') // body: string
yield* writeLine(`Wow, ${name}, ${age}! Token: ${body}`)
})
Это и есть do-notation промышленного уровня на TypeScript. Никаких функций-адаптеров в API, типы выводятся корректно, единственный каст спрятан внутри Symbol.iterator и пользователю не виден.
Сравни три варианта:
| Подход | Как пишется | Минус |
|---|---|---|
Наивный (as A снаружи) | const x = (yield io) as A | Каст в коде пользователя |
Адаптер (_(io)) | const x = yield* _(io) | Лишняя сущность в API |
Symbol.iterator (Effect-TS) | const x = yield* io | Сложнее реализация |
8.5. Однократность генератора, suspend
В реализациях doIO из 8.1 и 8.4 спрятан тонкий баг. Посмотри ещё раз:
const doIO = <A>(genFn: () => IOGen<A>): IO<A> => {
const gen = genFn() // ← вызвали один раз, при сборке IO
const step = (value: unknown): IO<A> => {
const result = gen.next(value)
if (result.done) return pure(result.value)
return bind(result.value.value, step)
}
return step(undefined)
}
genFn() зовётся один раз: в момент построения IO, а не в момент его исполнения. Возвращённое значение замыкается на этот единственный экземпляр gen. Первый runIO(myProgram, world) пройдёт от начала до конца и оставит gen в done: true. Второй прогон того же myProgram против другого мира войдёт в первую инструкцию (она уже зашита в дерево bind), а на следующем шаге step(value) получит от исчерпанного генератора { done: true } и тихо выйдет через pure(undefined). Снаружи это выглядит так: программа “отработала”, промис разрешился, никто не упал. Но world.outputs короче, чем должен быть, ровно на длину сценария минус один шаг.
Вспомни девиз из Части 1: построение IO, чистые данные, эффекты, только в интерпретаторе. Здесь мы его дважды нарушили. Узлы дерева, ок, eagerly строить их можно. Но состояние генератора оказалось привязано к самому дереву намертво. После первого прохода интерпретатор обходит уже не описание программы, а её останки.
Лекарство, новый узел Suspend:
type IO<A> =
| { tag: 'pure'; value: A }
| { tag: 'readLine'; next: (s: string) => IO<A> }
| { tag: 'writeLine'; text: string; next: IO<A> }
| { tag: 'fetch'; url: string; options?: RequestInit; next: (body: string) => IO<A> }
| { tag: 'suspend'; thunk: () => IO<A> } // ← новое
Suspend(thunk), обещание “когда дойдёшь сюда, дёрни функцию и продолжай уже её результатом”. Конструктор тривиален:
const suspend = <A>(thunk: () => IO<A>): IO<A> => ({ tag: 'suspend', thunk })
Один новый кейс в bind (приклеиваем f уже к свежему результату внутри будущего thunk-а):
case 'suspend':
return suspend(() => bind(io.thunk(), f))
Один новый кейс в runIO:
case 'suspend':
current = current.thunk()
break
И сам doIO переезжает внутрь suspend. Генератор теперь создаётся в момент интерпретации, а не сборки:
const doIO = <A>(genFn: () => IOGen<A>): IO<A> =>
suspend(() => {
const gen = genFn()
const step = (value: unknown): IO<A> => {
const result = gen.next(value)
if (result.done) return pure(result.value)
return bind(result.value.value, step)
}
return step(undefined)
})
Снаружи ничего не изменилось: тот же const myProgram = doIO(function* () { ... }), тот же runIO(myProgram, world). Внутри второй прогон стартует со свежего генератора, программа стала по-настоящему переиспользуемой. Одно описание, любое количество интерпретаций против разных миров.
Перекличка с Effect-TS. В
Effect.genлогически тот же приём:Effect.suspend(и его тонкая обёрткаEffect.sync) откладывают сборку поддерева до момента, когдаFiberк нему дойдёт. Когда полезешь читать исходники Effect, узнаешь там тот жеsuspend. Идея общая: ленивая граница между описанием и исполнением.
Трассировка: пробежим механизм шаг за шагом
Дальше, как в debugger. На каждом шаге подсвечена конкретная строка doIO, bind или runIO, а справа видно, что эта строка только что вернула и в какую функцию мы проваливаемся дальше. В этой версии doIO каждое продолжение тоже завёрнуто в маленький suspend(() => step(v)). Внешний suspend решает проблему свежего generator-а, как в коде выше, а внутренний переносит саму рекурсию из JS-стека в дерево IO. Тот же приём, который сделает doIO стэк-безопасным, об этом сразу в 8.6.
8.6. Безопасность стека в doIO
Внутри doIO ты вызываешь bind(result.value, step) рекурсивно. На длинных генераторах это рискует переполнить стек. Для учебных программ на пять шагов всё в порядке, но если играешь всерьёз, копай в сторону Codensity transform или техники Reflection without remorse. Подробный разбор обоих подходов, с кодом и графиками сложности, ждёт тебя в Части 12. Для текущей части достаточно знать, что проблема существует и у неё есть стандартные лекарства.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.