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

Генераторы: рецепт без лестницы

middle-senior~35 мин

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

В прошлом уроке у цепочки flatMap появилось имя и законы. Законы обещают, что цепочка честная. Но читать её трудно: каждый шаг, которому нужны значения прошлых шагов, вкладывается внутрь предыдущего, и код растёт вправо, а не вниз.

Этот урок о том, как вернуть коду обычную форму. Понадобится то, что есть в JavaScript давно, но в повседневном коде встречается редко: функция, которая умеет остановиться посередине и подождать. На этой же штуке стоит Effect.gen, который ты будешь видеть на каждой странице курса начиная с раздела про первые чары.

Лестница

Сколько ждали подтверждения кадра: от первой отправки до первого подтверждения. Если кадр не отправляли или ответа не было, задержки нет.

import { pipe } from './Function';
import type { Entry } from './journal';
import * as Option from './Option';

const ackDelay = (journal: readonly Entry[], frame: number): Option.Option<number> =>
  pipe(
    Option.fromNullable(journal.find((e) => e.kind === 'sent' && e.frame === frame)),
    Option.flatMap((sent) =>
      pipe(
        Option.fromNullable(journal.find((e) => e.kind === 'acked' && e.frame === frame)),
        Option.map((acked) => acked.at - sent.at),
      ),
    ),
  );

Второй шаг вложен в первый не по прихоти. В последней строке нужны и sent, и acked, а sent видно только внутри функции, которую получил flatMap. Каждое новое значение, нужное дальше, это ещё одна вложенная функция и ещё один отступ. В разборе строки журнала из урока 25 таких ступенек уже три.

Хочется написать так:

sent  = открыть коробку с отправкой
acked = открыть коробку с подтверждением
вернуть acked.at - sent.at

Трудность в слове «открыть». Если коробка пустая, весь рецепт должен остановиться, а код после этой строки не выполняться. Обычная функция не умеет остановить того, кто её позвал. Умеет только throw, и про него мы ещё поговорим. Нужна функция, которая сама умеет вставать на паузу.

Функция, которая умеет ждать

function* shift(): Generator<string, string, string> {
  console.log('  внутри: начал');
  const first = yield 'кадр 1';
  console.log(`  внутри: получил ${first}`);
  const second = yield 'кадр 2';
  console.log(`  внутри: получил ${second}`);
  return 'смена закрыта';
}

const steps = shift();
console.log('снаружи:', steps.next());
console.log('снаружи:', steps.next('подтверждён'));
console.log('снаружи:', steps.next('потерян'));
console.log('снаружи:', steps.next());
  внутри: начал
снаружи: { value: 'кадр 1', done: false }
  внутри: получил подтверждён
снаружи: { value: 'кадр 2', done: false }
  внутри: получил потерян
снаружи: { value: 'смена закрыта', done: true }
снаружи: { value: undefined, done: true }

Звёздочка после function делает её генератором. Посмотри, что произошло по порядку.

Вызов shift() не напечатал ничего. Тело не начало исполняться, вызов вернул объект steps, рецепт, а не обед, как в уроке 03. У этого объекта есть метод next, и каждый вызов возвращает пару { value, done }. Объект с таким методом называют итератором.

Первый next() исполнил тело до первого yield и остановился. Значение справа от yield вышло наружу как value. Второй вызов, next('подтверждён'), продолжил тело с того же места, и само выражение yield 'кадр 1' вернуло строку, которую передали в next. Так значение ходит в обе стороны: наружу через yield, внутрь через аргумент next. Когда тело дошло до return, его значение вышло с done: true, и дальше генератор только отвечает, что кончился.

Тип Generator<string, string, string> читается по тем же правилам, что сигнатуры в уроке 09: первый параметр это то, что выходит через yield, второй то, что возвращает return, третий то, что приходит внутрь через next.

Пошагай сам и следи, что уходит наружу через yield и что приходит внутрь через next.

Отдать слово другому

У yield есть сестра со звёздочкой. yield* other запускает внутри генератора другой генератор и на время отдаёт ему слово: всё, что other отдаёт через yield, выходит наружу, всё, что приходит в next, уходит в other. Когда other кончился, его return становится значением всего выражения yield* other.

На этом и стоит «открыть коробку». Котлу нужен маленький генератор Option.open(box): он отдаёт наружу саму коробку и возвращает то, что ему пришлют назад.

import * as Option from './Option';

const steps = Option.open(Option.some(1000));
console.log(steps.next());
console.log(steps.next(1000));
{ value: { _tag: 'Some', value: 1000 }, done: false }
{ value: 1000, done: true }

Сам open коробку не открывает. Он показывает её тому, кто крутит next, и ждёт ответа. Решать, что делать с пустой коробкой, будет этот кто-то.

Рецепт без лестницы

Этот кто-то называется Option.gen. Он получает функцию-генератор, рецепт, и исполняет его шаг за шагом:

import { type Board, lastHeard } from './journal';
import * as Option from './Option';

/** На сколько разошлись последние голоса двух башен. */
const gap = (board: Board, left: string, right: string): Option.Option<number> =>
  Option.gen(function* () {
    const a = yield* Option.open(lastHeard(board, left));
    const b = yield* Option.open(lastHeard(board, right));
    return Math.abs(a - b);
  });

const board: Board = {
  'T-1': { heard: 3, lastAt: 1000 },
  'T-2': { heard: 1, lastAt: 4000 },
};

console.log(gap(board, 'T-1', 'T-2'));
console.log(gap(board, 'T-1', 'T-9'));
{ _tag: 'Some', value: 3000 }
{ _tag: 'None' }

Код читается сверху вниз. a и b обычные числа, не коробки, и последняя строка их вычитает. Башни T-9 на доске нет, lastHeard вернул пустую коробку, и весь рецепт ответил None: до вычитания дело не дошло. Ни одного flatMap руками, ни одного отступа на шаг.

Такую запись называют do-нотацией. Она ничего не добавляет к flatMap, это та же цепочка, переписанная в удобную форму. Поэтому законы из прошлого урока здесь тоже работают: рецепт можно разрезать на два, и ответ не изменится.

Под капотом

  1. gen вызывает рецепт. Тело не исполняется, есть только объект с next.
  2. gen зовёт next() без аргумента. Тело бежит до первого yield*, тот отдаёт слово open, и open выдаёт наружу коробку Some(1000).
  3. gen видит Some и зовёт next(1000). Внутри open возвращает 1000, выражение yield* Option.open(...) становится числом, и a равно 1000. Тело бежит до второго yield*, наружу выходит None.
  4. gen видит None и сразу возвращает Option.none(). Больше next никто не зовёт, и строка с Math.abs не исполнится никогда: генератор брошен.
  5. Если бы все коробки оказались полными, тело дошло бы до return, next отдал бы done: true, и gen положил бы этот результат в some.

Шаг 4 и есть короткое замыкание из урока 26, только без throw. Остановить чужой код без исключения можно ровно потому, что генератор исполняется по кусочкам и продолжается, только если его позвали.

У брошенного генератора есть одна тонкость: он не доедает свой код, даже блок finally. Для котла это неважно, в рецепте одни вычисления. Когда в рецепте окажутся открытые файлы, это станет важно, и раздел про ресурсы научит закрывать их честно.

Перевод с Ук

Самая частая ошибка: забыть звёздочку у yield*.

import type { Entry } from './journal';
import * as Option from './Option';

const ackDelay = (journal: readonly Entry[], frame: number): Option.Option<number> =>
  Option.gen(function* () {
    const sent = yield Option.open(
      Option.fromNullable(journal.find((entry) => entry.kind === 'sent' && entry.frame === frame)),
    );
    const acked = yield* Option.open(
      Option.fromNullable(journal.find((entry) => entry.kind === 'acked' && entry.frame === frame)),
    );
    return acked.at - sent.at;
  });
noyield.ts(5,14): error TS2345: Argument of type '() => Generator<Option.Option<Entry> | Generator<Option.Option<Entry>, Entry, unknown>, number, unknown>' is not assignable to parameter of type '() => Generator<Option<unknown>, number, unknown>'.
  Call signature return types 'Generator<Option.Option<Entry> | Generator<Option.Option<Entry>, Entry, unknown>, number, unknown>' and 'Generator<Option<unknown>, number, unknown>' are incompatible.
    The types returned by 'next(...)' are incompatible between these types.
      Type 'IteratorResult<Option.Option<Entry> | Generator<Option.Option<Entry>, Entry, unknown>, number>' is not assignable to type 'IteratorResult<Option<unknown>, number>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
        Type 'IteratorYieldResult<Option.Option<Entry> | Generator<Option.Option<Entry>, Entry, unknown>>' is not assignable to type 'IteratorResult<Option<unknown>, number>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
          Type 'IteratorYieldResult<Option.Option<Entry> | Generator<Option.Option<Entry>, Entry, unknown>>' is not assignable to type 'IteratorYieldResult<Option<unknown>>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
            Type 'Option.Option<Entry> | Generator<Option.Option<Entry>, Entry, unknown>' is not assignable to type 'Option<unknown>'.
              Type 'Generator<Option.Option<Entry>, Entry, unknown>' is not assignable to type 'Option<unknown>'.
noyield.ts(12,23): error TS18046: 'sent' is of type 'unknown'.

Ошибка длинная, и половина её сбивает с толку: три строки советуют добавить undefined из-за exactOptionalPropertyTypes. Совет не про тебя, это компилятор перебирает, чем бы помочь. Читай снизу. Последняя строка первой ошибки: Generator<...> не помещается в Option<unknown>. Строкой выше: наружу из рецепта выходит то коробка, то целый генератор. gen ждёт, что наружу выходят только коробки.

Откуда генератор? Без звёздочки yield отдаёт наружу то, что справа, как есть. А справа объект, который вернул open, то есть генератор, не начавший работу. Со звёздочкой yield* запустил бы его и отдал бы наружу коробку. Вторая ошибка о том же с другой стороны: sent получился unknown, потому что значение, пришедшее через простой yield, типизируется третьим параметром Generator, а у рецепта это unknown. Чинится одной звёздочкой.

Вот куда ты придёшь

В Effect тот же рецепт пишется так:

import { Effect, Option } from 'effect';

const delay = Option.gen(function* () {
  const sent = yield* Option.some(100);
  const acked = yield* Option.some(350);
  return acked - sent;
});
// { _id: 'Option', _tag: 'Some', value: 250 }

const program = Effect.gen(function* () {
  const sent = yield* Effect.succeed(100);
  const acked = yield* Effect.fail('нет подтверждения' as const);
  return acked - sent;
});
// Effect.Effect<number, 'нет подтверждения', never>

Форма та же, но open нет: коробку отдают в yield* саму. Чтобы так было можно, у коробки должен быть метод [Symbol.iterator], который и сделает из неё маленький генератор. Коробки Effect такой метод носят, поэтому в выводе видно поле _id: у настоящего Option полей больше, чем _tag и value. Наши коробки простые объекты, их форму закрепили тесты этапа 8, и добавлять к ним метод значило бы поменять то, что уже сдано. Поэтому котёл берёт адаптер. В ранних версиях Effect рецепт тоже получал такой адаптер, _, и писали yield* _(effect).

Effect.gen во второй половине устроен как наш gen, только шаги в нём не коробки, а эффекты, и ошибка шага не теряется, а попадает в тип: 'нет подтверждения' во втором параметре Effect. Ровно это ты соберёшь для Result в боссе раздела.

Где тут дыра

const get = <A>(box: Option.Option<A>): A => {
  if (box._tag === 'None') throw new Error('пусто');
  return box.value;
};

const attempt = <A>(recipe: () => A): Option.Option<A> => {
  try {
    return Option.some(recipe());
  } catch {
    return Option.none();
  }
};

const journal: readonly Entry[] = [
  { kind: 'sent', frame: 1, at: 104 },
  { kind: 'acked', frame: 1, at: 350 },
];
const find = (kind: Entry['kind'], frame: number) =>
  Option.fromNullable(journal.find((entry) => entry.kind === kind && entry.frame === frame));

const ackDelay = (frame: number) =>
  attempt(() => get(find('acked', frame)).at - get(find('sent', frame)).at);

// Округлить момент отправки до десятков.
const sentAround = (frame: number) => attempt(() => get(find('sent', frame)).at.toFixed(-1));

console.log(ackDelay(1));
console.log(ackDelay(2));
console.log(sentAround(1));
{ _tag: 'Some', value: 246 }
{ _tag: 'None' }
{ _tag: 'None' }

С ackDelay всё честно: первый кадр подтвердили, второго нет. А sentAround(1) ответил, что кадра нет, хотя он есть, в журнале первой строкой. Ринцвальд хотел округлить до десятков и написал toFixed(-1), а toFixed отрицательных разрядов не принимает и бросает RangeError: toFixed() digits argument must be between 0 and 100. Ловушка catch не различает «коробка пустая» и «в рецепте ошибка программиста»: она ловит всё. Ошибка в коде превратилась в спокойное «нет кадра», и никто её не увидит.

Это тот же catch {}, что прячет поломки Магистрали. gen останавливает рецепт только на пустой коробке, а настоящее исключение пролетает мимо него наружу, как и должно.

Котёл, этап 12

Котёл учится писать цепочки сверху вниз. В модуль Option ты допишешь open и gen, а в журнал башни ackDelay на Option.gen: задержку от первой отправки кадра до первого подтверждения. Тесты проверят, что open отдаёт коробку и возвращает присланное, что пустая коробка останавливает рецепт и что типы значений внутри рецепта выводятся сами.

Практика

Глубже в архиве

Итераторы, генераторы и протокол Symbol.iterator на обычных коллекциях JavaScript разбирает урок «Коллекции и протоколы итерации».

Дальше

Матушка заглядывает в рецепт gap через твоё плечо, читает сверху вниз и хмыкает: «Ну вот. Теперь и я понимаю, что ты насчитал». В дверях Рыдкулл, с арбалетом и плохими новостями.

Что для этого нужно котлу целиком и как сделать gen для дороги в два пути, разберём в следующем уроке.