В прошлом уроке у цепочки 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, это та же цепочка, переписанная в удобную форму. Поэтому законы из прошлого урока здесь тоже работают: рецепт можно разрезать на два, и ответ не изменится.
Под капотом
genвызывает рецепт. Тело не исполняется, есть только объект сnext.genзовётnext()без аргумента. Тело бежит до первогоyield*, тот отдаёт словоopen, иopenвыдаёт наружу коробкуSome(1000).genвидитSomeи зовётnext(1000). Внутриopenвозвращает1000, выражениеyield* Option.open(...)становится числом, иaравно1000. Тело бежит до второгоyield*, наружу выходитNone.genвидитNoneи сразу возвращаетOption.none(). Большеnextникто не зовёт, и строка сMath.absне исполнится никогда: генератор брошен.- Если бы все коробки оказались полными, тело дошло бы до
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 для дороги в два пути, разберём в следующем уроке.