Сводка башни из прошлого урока на flatMap уезжает вправо на каждом шаге: следующий шаг вложен в функцию предыдущего, потому что ему нужны все прошлые значения. В уроке 28 ту же лестницу в котле разгладил генератор: рецепт пишется сверху вниз, каждый yield* ждёт значения из коробки. Здесь сделаем то же для свитка, но с двумя отличиями. Адаптера не будет: свиток сам пойдёт в yield*, как в Effect 4. И крутить генератор будет не gen, а муравей Гекса, поэтому рецепт научится ждать колокольчика.
Почему коробке нужен был адаптер
yield* x работает с любым значением, у которого есть метод [Symbol.iterator]: генератор отдаёт наружу всё, что выдаёт итератор x, а значением выражения становится то, чем итератор кончился. Коробки котла это простые объекты { _tag: 'Some', value }, и форму их закрепили тесты этапа 8. Метод к ним не добавить, поэтому котёл брал адаптер: генератор open(box) из одной строки, который отдаёт коробку наружу и возвращает то, что прислали назад.
У свитка такой беды нет. Снаружи у него нет полей, тип закрывает начинку, и никто, кроме модуля IO.ts, не знает, из чего свиток сделан. Значит, модуль может сам дать каждому свитку метод [Symbol.iterator], и для этого не нужно менять ни одного теста.
Свиток сам идёт в yield*
Итератор у всех свитков одинаковый, поэтому его кладут в общий прототип, а конструкторы создают инструкции от этого прототипа. Сам итератор это генератор из одной строки, тот же, что open в котле, только он отдаёт наружу this, то есть сам свиток:
const scroll = IO.succeed(41);
const steps = scroll[Symbol.iterator]();
console.log(steps.next());
console.log(steps.next(41));
{ value: { _op: 'Succeed', value: 41 }, done: false }
{ value: 41, done: true }
Первый next() выдаёт сам свиток: console.log показывает его начинку, карточку Succeed, потому что печать не знает про закрытый тип. Второй next(41) заканчивает итерацию тем, что ему прислали. Внутри рецепта это и есть yield* scroll: свиток уходит наружу, тому, кто крутит генератор, а назад приходит его успех.
Чтобы компилятор знал, что приходит назад, тип свитка получает метод с честным итератором:
export interface IO<out A, out E = never, out R extends string = never> {
readonly '~geks': { readonly A: A; readonly E: E; readonly R: R };
[Symbol.iterator](): IOIterator<A, E, R>;
}
export interface IOIterator<A, E, R extends string> {
next(...args: ReadonlyArray<unknown>): IteratorResult<IO<A, E, R>, A>;
}
IteratorResult<IO<A, E, R>, A> читается по уроку 09: пока итерация идёт, наружу выходит свиток, а кончается она значением A. Поэтому const wire = yield* IO.service(Wire) получает тип сервиса провода, а не unknown.
Перевод с Ук
Ринцвальд пробует отдать в yield* промис: так же удобно, а провод у него на промисах.
import * as IO from './IO';
const ask = (): Promise<string> => Promise.resolve('T-7');
const scroll = IO.gen(function* () {
const tower = yield* ask();
return `спросил ${tower}`;
});
ook.ts(6,24): error TS2488: Type 'Promise<string>' must have a '[Symbol.iterator]()' method that returns an iterator.
Строка 6, колонка 24: то, что стоит после yield*. TS2488 говорит ровно то, что мы разобрали: yield* требует метода [Symbol.iterator], а у промиса его нет. Это не досадная мелочь. Свиток отдаёт наружу описание, которое исполнит Гекс; промис уже летит, и описывать в нём нечего. Ответ провода, который придёт потом, записывают колокольчиком из прошлого урока, IO.callback, и тогда в yield* идёт свиток.
Рецепт крутит Гекс
gen в котле сам гонял генератор в цикле while. Для свитка так нельзя: если шаг рецепта ждёт колокольчика, while внутри gen ждать не умеет, а останавливать подвал мы не хотим. Поэтому gen ничего не крутит, он строит свиток. Рецепт вызывается, когда свиток запускают, а каждый шаг превращается в flatMap: «исполни то, что вышло из yield*, и отдай успех обратно в генератор через next». Дальше работает муравей: он ждёт колокольчика, держит стопку и снимает рамки, как в прошлом уроке. Рецепт на сто тысяч шагов стопку не растит, потому что каждый шаг кладёт одну рамку и сразу её снимает.
Вот сводка башни рецептом:
import * as IO from './IO';
import { Clock, Wire } from './tower';
const checkTower = IO.gen(function* () {
const wire = yield* IO.service(Wire);
const frames = yield* wire.read;
const clock = yield* IO.service(Clock);
const now = yield* clock.now;
return `${now}: кадров ${frames.length}`;
});
// IO.IO<string, WireError, 'Wire' | 'Clock'>
const wire = { read: IO.succeed(['a', 'b']), send: () => IO.succeed(undefined) };
const clock = { now: IO.succeed(1030) };
console.log(IO.runSync(IO.provideService(IO.provideService(checkTower, Wire, wire), Clock, clock)));
1030: кадров 2
Четыре шага, ни одного уезда вправо. Если wire.read упадёт, рецепт остановится на этой строке: gen получит провал вместо успеха, рамка flatMap его пропустит, и код ниже не исполнится. Ловить ошибку можно снаружи, обычным IO.catch вокруг всего свитка.
Тип под рецептом никто не писал: его вывел компилятор. Ошибка WireError пришла из wire.read, нужды 'Wire' | 'Clock' из двух IO.service.
Под капотом
Тип генератора у рецепта Generator<Eff, A, unknown>, где Eff это объединение всех свитков, которые он отдаёт в yield*. В рецепте выше это IO<WireService, never, 'Wire'> | IO<readonly string[], WireError> | IO<ClockService, never, 'Clock'> | IO<number>. Достать из такого объединения ошибки и нужды помогают два условных типа:
type ErrorOf<T> = T extends IO<infer _A, infer E, infer _R> ? E : never;
type ContextOf<T> = T extends IO<infer _A, infer _E, infer R> ? R : never;
Параметр T голый, поэтому условный тип распределяется по объединению: ErrorOf вынимает E из каждого свитка и складывает их. Свитки без ошибки дают never, и never из объединения пропадает. Остаётся WireError. С ContextOf то же самое даёт 'Wire' | 'Clock'. Это тот самый ErrorOf из урока 11, только задача теперь настоящая и исполняется в подвале.
Вот куда придёшь
Та же сводка на настоящем Effect. Сервисы там это классы-ключи Context.Service, и в yield* отдают сам ключ, без Effect.service:
import { Context, Effect } from 'effect';
class Wire extends Context.Service<Wire, { readonly read: Effect.Effect<readonly string[]> }>()(
'Wire',
) {}
class Clock extends Context.Service<Clock, { readonly now: Effect.Effect<number> }>()('Clock') {}
const checkTower = Effect.gen(function* () {
const wire = yield* Wire;
const frames = yield* wire.read;
const clock = yield* Clock;
const now = yield* clock.now;
return `${now}: кадров ${frames.length}`;
});
// Effect.Effect<string, never, Wire | Clock>
const ready = checkTower.pipe(
Effect.provideService(Wire, { read: Effect.succeed(['a', 'b']) }),
Effect.provideService(Clock, { now: Effect.succeed(1030) }),
);
console.log(Effect.runSync(ready));
1030: кадров 2
Рецепт тот же строка в строку. Разница в том, что лежит под ним: у Effect ключи сервисов это классы, а не строки, и муравьёв в подвале много. До них дойдём в EF5 и EF7.
Где тут дыра
import * as IO from './IO';
let calls = 0;
const steps = (function* () {
calls++;
const n = yield* IO.succeed(41);
return n + 1;
})();
const scroll = IO.gen(() => steps);
console.log(IO.runSync(scroll));
console.log(IO.runSync(scroll));
console.log(calls);
42
undefined
1
Первый запуск честный: 42. Второй отвечает undefined, хотя тип говорит number, и компилятор об этом не узнает. Генератор одноразовый: после первого прохода он стоит на return, и следующий next() сразу отвечает { done: true, value: undefined }. Свиток, который можно запустить один раз, а дальше он молча врёт, это не описание, а промис в другой одежде. Поэтому gen принимает функцию, которая создаёт генератор, и зовёт её при каждом запуске свитка.
Гекс, этап 5
Свиток учится сам идти в yield*, а gen строит из рецепта свиток, который исполняет муравей. Компилятор сам собирает ошибки и нужды всех шагов. В башне появляется relayAll: передача кадров рецептом, которая останавливается на первом обрыве.
Практика
Глубже в архиве
Рецепт на генераторе это do-нотация: шаги пишутся сверху вниз, а под ними та же цепочка flatMap. В Haskell она встроена в язык, и как она разворачивается в >>=, разбирает урок «Monad: зависимые шаги и do-нотация».
Дальше
Сводка башни читается сверху вниз, передача кадров тоже. Вечером в подвал спускается Рыдкулл и смотрит, как муравей стоит у колокольчика.
Гекс умеет начать свиток и довести его до конца. Остановить его посреди ожидания и прибраться за ним при любом исходе он пока не умеет. Это заказ раздела, и он в следующем уроке.