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

Босс: Гекс, который умеет остановиться

middle-senior~90 мин

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

Ночь в подвале. Муравей бегает по трубкам, колокольчик над проводом звенит после каждого кадра, Сундук стоит у лестницы. У входа Пондер прикрутил к стене рычаг. Пока он ни к чему не подключён.

За пять этапов Гекс научился многому: свиток на потом, ошибка в типе, Сундук с сервисами, цикл со стопкой продолжений и колокольчиком, рецепт на генераторе. Свиток он доводит до конца, успехом или провалом. Остановить его снаружи и прибраться за ним он не умеет.

Что нужно Гексу

Проверяется всё, что Гекс умеет с первого этапа: тесты накопительные. Сверху три новые вещи.

  • Муравей, которого можно остановить. IO.runFork запускает свиток и сразу отдаёт файбер, муравья с двумя ручками: await() дождаться Exit, interrupt() дёрнуть рычаг. Прерывание видно перед следующей инструкцией, а если муравей ждёт колокольчика, Гекс отменяет ожидание отменялкой из прошлого урока. У Exit появляется третья причина провала, Interrupt, и catch её не ловит, как не ловит дефект: рычаг не ошибка свитка, а решение снаружи.
  • Уборка при любом исходе. IO.ensuring(self, finalizer) исполняет финализатор после self, кончился тот успехом, провалом или прерыванием. Вложенные финализаторы идут изнутри наружу. Финализатор по типу не падает, у него E = never, и рычаг его не прерывает: уборку доводят до конца.
  • Ресурс. IO.acquireUseRelease(acquire, use, release) берёт ресурс, работает с ним и отпускает при любом исходе работы. Пока ресурс берут, рычаг ждёт: иначе ставни могли бы открыться, а закрыть их было бы некому.

И ночная передача в башне, relayAtNight: ставни это ресурс, кадры уходят по одному с паузой, ставни закрываются всегда.

Сигнатуры, порядок финализаторов и сценарии, на которых ловят тесты, описаны в заказе до символа.

Чек-лист перед сдачей

  1. Тесты всех прошлых этапов зелёные: сто тысяч flatMap, колокольчик, gen на ста тысячах шагов.
  2. Рычаг, дёрнутый дважды, зовёт отменялку ожидания один раз. Ответ колокольчика, пришедший после рычага, никуда не идёт.
  3. catch вокруг прерванного свитка не превращает прерывание в успех.
  4. У ensuring(ensuring(body, a), b) при любом исходе сначала a, потом b, и каждый ровно один раз.
  5. Финализатор со sleep внутри доводится до конца, даже если рычаг дёрнули до него.
  6. Рычаг во время медленного acquire: ресурс берётся, use не начинается, release исполняется.
  7. runSyncExit на ждущем свитке не только отвечает дефектом, но и прерывает свиток: его продолжение после ожидания не исполнится никогда.
  8. ensuring(IO.succeed(1), IO.fail('x')) не собирается. Проверь это под @ts-expect-error, а не на глаз.
  9. Сломай Гекс нарочно, например проверяй рычаг только у колокольчика, и убедись, что тест про рассвет покраснел. Потом верни.

Гекс, этап 6

Последний заказ раздела: Гекс, который умеет остановиться и прибраться. После него свиток можно запустить на ночь и быть уверенным, что на рассвете ставни будут закрыты.

Ринцвальд

Пока ты пишешь, Ринцвальд кладёт на стол свою ночную передачу. Он считает, что Гекс для этого не нужен.

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const log: string[] = [];

const relayAtNight = async (frames: readonly string[]): Promise<number> => {
  log.push('открыты');
  try {
    for (const frame of frames) {
      log.push(frame);
      await sleep(20);
    }
    return frames.length;
  } finally {
    log.push('закрыты');
  }
};

const main = async () => {
  const dawn = sleep(30).then(() => 'рассвет');
  console.log(await Promise.race([relayAtNight(['a', 'b', 'c']), dawn]));
  console.log(log);
  await sleep(80);
  console.log(log);
};
void main();
рассвет
[ 'открыты', 'a', 'b' ]
[ 'открыты', 'a', 'b', 'c', 'закрыты' ]

Рассвет победил, и main пошла дальше. А передача нет: Promise.race выбирает, чей ответ взять, но никого не останавливает. После рассвета ушёл кадр c, и ставни закрылись, когда передача сама кончилась. finally честно сработал, только поздно. Промис уже летит, и отменить его нечем: у него нет рычага, а AbortController пришлось бы протянуть через каждый await руками, как в уроке 02.

Практика

Итог

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

Пондер достаёт из шкафа остроконечную шляпу, мятую, с вышитым словом «ВАЛШЕБНИК». Так её носят бакалавры: с ошибкой, чтобы помнили, с чего начинали. Ринцвальд смотрит на шляпу, потом на свою.

Ранг бакалавр за этот раздел, и шляпа с ним: её можно надеть в гардеробе.

А теперь настоящий Effect

Свой Гекс уместился в один модуль, и каждая его часть есть в Effect. Вот соответствия и то, где наш Гекс упрощает:

Свой ГексEffect 4Что у Effect иначе
IO.IO<A, E, R>Effect.Effect<A, E, R>в R ключи-классы Context.Service, а не строковые имена
IO.succeed, IO.sync, IO.failEffect.succeed, Effect.sync, Effect.failто же
IO.catch, IO.mapErrorEffect.catch, Effect.mapErrorу нас только data-first, у Effect два вида вызова и pipe
IO.service(Wire), IO.provideServiceyield* Wire или Effect.service(Wire), Effect.provideServiceключ сервиса сам идёт в yield*
IO.callback, IO.sleepEffect.callback, Effect.sleepregister получает ещё AbortSignal и возвращает не отменялку, а эффект очистки
IO.genEffect.genто же
Exit, Cause: Fail, Die, InterruptExit, Causeу Cause массив reasons: причин бывает несколько сразу, например провал и упавший финализатор
IO.ensuring, IO.acquireUseReleaseEffect.ensuring, Effect.acquireUseReleaserelease получает ещё Exit работы; есть Scope, Effect.acquireRelease и Effect.scoped, это EF6
IO.runSync, IO.runSyncExit, IO.runPromise, IO.runPromiseExitEffect.runSync, Effect.runSyncExit, Effect.runPromise, Effect.runPromiseExitто же
IO.runFork, fiber.await(), fiber.interrupt()Effect.runFork, Fiber.await, Fiber.join, Fiber.interruptу Effect это эффекты, их запускают, а не методы с промисом
один муравеймного файберов и планировщикмуравей уступает трубку другим, а fork запускает муравья изнутри свитка; это EF7

Та же ночная передача на Effect 4, с рассветом через полминуты:

import { Context, Data, Effect, Exit, Fiber } from 'effect';

class WireDown extends Data.TaggedError('WireDown')<{ readonly tower: string }> {}
class Wire extends Context.Service<
  Wire,
  { readonly send: (frame: string) => Effect.Effect<void, WireDown> }
>()('Wire') {}
class Shutters extends Context.Service<
  Shutters,
  { readonly open: Effect.Effect<void>; readonly close: Effect.Effect<void> }
>()('Shutters') {}

const relayAtNight = (frames: readonly string[]) =>
  Effect.gen(function* () {
    const shutters = yield* Shutters;
    return yield* Effect.acquireUseRelease(
      shutters.open,
      () =>
        Effect.gen(function* () {
          const wire = yield* Wire;
          for (const frame of frames) {
            yield* wire.send(frame);
            yield* Effect.sleep('20 millis');
          }
          return frames.length;
        }),
      () => shutters.close,
    );
  });

const log: string[] = [];
const night = relayAtNight(['a', 'b', 'c']).pipe(
  Effect.provideService(Wire, { send: (frame) => Effect.sync(() => void log.push(frame)) }),
  Effect.provideService(Shutters, {
    open: Effect.sync(() => void log.push('открыты')),
    close: Effect.sync(() => void log.push('закрыты')),
  }),
);

const main = async () => {
  const fiber = Effect.runFork(night);
  await new Promise((resolve) => setTimeout(resolve, 30));
  await Effect.runPromise(Fiber.interrupt(fiber));
  const exit = await Effect.runPromise(Fiber.await(fiber));
  console.log(Exit.hasInterrupts(exit), log);
};
void main();
true [ 'открыты', 'a', 'b', 'закрыты' ]

Свиток читается почти строка в строку как твой relayAtNight. Кадр c не ушёл, ставни закрыты, Exit кончился прерыванием. Разница в том, что лежит под ним, и её ты теперь знаешь изнутри.

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

acquireUseRelease это старый приём bracket из Haskell: взять, использовать, отпустить, даже если посередине исключение или асинхронная отмена. Как он устроен там и почему отпускание тоже защищено от прерываний, разбирает урок «Исключения и ресурсы».

Дальше

Утром Рыдкулл приходит в подвал не один. Под мышкой у него распечатка, которую ночью вынесли из конторы Магистрали.

declare const relay: (frame: string) => Promise<void>;

export const send = async (frame: string): Promise<string> => {
  try {
    await relay(frame);
  } catch {}
  return 'успешно';
};

Пустой catch и «успешно» после него, что бы ни случилось с кадром. Ошибки на Магистрали не исчезали: их прятали. Гекс своё дело сделал, ты знаешь, как устроен рантайм. Дальше настоящий Effect и настоящая башня.

Следующий раздел курса это EF4 «Первые чары». Рыдкулл берёт тебя в работу над башней T-1, и вы переписываете её обработчик кадров на Effect: квитанция Effect<A, E, R>, Effect.gen и pipe, ожидаемые ошибки и дефекты, Cause и Exit. Начинается он с урока 36.