Раздел 46 · Effect: Тихая Академия

Свиток на генераторе

middle-senior~25 мин

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

Сводка башни из прошлого урока на 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-нотация».

Дальше

Сводка башни читается сверху вниз, передача кадров тоже. Вечером в подвал спускается Рыдкулл и смотрит, как муравей стоит у колокольчика.

Гекс умеет начать свиток и довести его до конца. Остановить его посреди ожидания и прибраться за ним при любом исходе он пока не умеет. Это заказ раздела, и он в следующем уроке.