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

Описание против исполнения

middle~25 мин

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

Утро. На ступенях Академии сидит молодой маг в круглых очках и что-то пишет в тетрадь. Из кармана его мантии торчит стеклянная трубка, в ней бегает муравей. Это Пондер Стиб, единственный в Академии, кто понимает, как устроен Гекс. Сундук останавливается у нижней ступени и ждёт.

Вопрос кажется детским, а от ответа зависит всё, что было вчера. Если работа начинается при создании, то к моменту, когда ты решаешь, ждать её или отменить, она уже идёт. Повторить её нельзя, она уже случилась. Отмена вынуждена догонять работу по проводам. В этом уроке ты увидишь, чем записанная работа отличается от начатой, и почему Академия начинает с записи.

Промис уже летит

Проверим на трёх строках:

const sending = new Promise<void>((resolve) => {
  console.log('ставни открыты');
  resolve();
});
console.log('я ещё ничего не решил');
await sending;
console.log('дождался');
ставни открыты
я ещё ничего не решил
дождался

Ставни открылись раньше, чем ты что-то решил. Функция, которую ты отдаёшь в new Promise, выполняется сразу, внутри конструктора. await ничего не запускает, он только ждёт того, что уже идёт.

С async-функциями то же самое. Вызов send('T-2') выполняет тело функции до первого await прямо на месте и возвращает промис уже начатой работы. Это и есть скоропись из первого урока: заклинание срывается с языка в тот миг, когда его произнесли.

Промис это результат, а не действие

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

let calls = 0;
const send = async (tower: string): Promise<string> => {
  calls += 1;
  if (calls === 1) throw new Error(`туман у ${tower}`);
  return 'принято';
};

const sending = send('T-2');
console.log(await sending.catch(String));
console.log(await sending.catch(String));
console.log(calls);
Error: туман у T-2
Error: туман у T-2
1

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

Рецепт, а не обед

Теперь возьмём такого же соседа заново, со счётчиком на нуле, и обернём отправку в функцию без аргументов:

const recipe = () => send('T-2');
console.log(calls);
console.log(await recipe().catch(String));
console.log(await recipe().catch(String));
console.log(calls);
0
Error: туман у T-2
принято
2

Пока recipe не вызвали, счётчик ноль: ничего не отправлено. Каждый вызов это новая отправка, поэтому вторая попытка прошла. Такую функцию без аргументов, которая откладывает работу до вызова, называют thunk. В Академии говорят проще: рецепт. Работу, которая ждёт вызова, называют ленивой, а промис, который начинает сразу, жадным.

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

Над аркой входа выбит девиз Академии: «Настоящий маг почти никогда не колдует сразу». Ты проходил под ним три дня и не прочитал.

Что даёт рецепт

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

  • повторить значит вызвать рецепт ещё раз;
  • ограничить время решают заранее: таймаут становится частью записи, а не таймером, который догоняет уже идущую работу;
  • не начинать ничего не стоит: невызванный рецепт ничего не трогает и отменять нечего;
  • обернуть рецепт в другой рецепт можно сколько угодно раз: «повтори три раза» это тоже рецепт, который вызывает исходный.

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

Чего рецепту не хватает

У () => Promise<string> есть беда: рецепт закрыт. Его можно вызвать, но нельзя прочитать. Тип молчит о том, чем он провалится и что ему нужно, ровно как молчал Promise<number> в первом уроке. А когда рецепт уже вызван, в руках снова промис, со всеми его бедами.

Effect это рецепт, который можно прочитать. Его тип записывает, что выйдет при успехе, чем всё может провалиться и что нужно для работы. Исполняет его не вызов, а рантайм, и рантайм ведёт работу шаг за шагом, поэтому может остановить её на любом шаге. Тот же сосед в тумане:

import { Effect } from 'effect';

let calls = 0;
const send = async (tower: string): Promise<string> => {
  calls += 1;
  if (calls === 1) throw new Error(`туман у ${tower}`);
  return 'принято';
};

const sending = Effect.tryPromise(() => send('T-2'));
console.log(calls);
console.log(await Effect.runPromise(sending.pipe(Effect.retry({ times: 2 }))));
console.log(calls);
0
принято
2

Effect.tryPromise получает рецепт, а не промис: стрелку () => send('T-2'). Значение sending имеет тип Effect<string, UnknownError, never>, и счётчик после его создания ноль. Effect.retry пристёгивает к записи «повтори до двух раз», и это снова запись. Произносит её только Effect.runPromise, в последней строке, и рантайм вызывает рецепт столько раз, сколько нужно. Разбирать эти функции будем в разделе EF4, а свой маленький рантайм, чтобы всё это не казалось магией, соберёшь в EF3.

Мифы

На слове «Effect» на ступени поднимается Ринцвальд. Он, оказывается, слушал.

Три мифа, и у каждого есть зерно правды. Разберём по одному.

«Это же RxJS». Общее действительно есть: Observable в RxJS тоже ленивый и ничего не делает, пока на него не подписались. Но Observable описывает поток: много значений во времени, как кадры, которые идут по Магистрали всю ночь. Effect описывает одну работу с одним итогом. И у Observable один параметр типа, для значений: обработчик ошибки в подписке получает any, и тип о ней молчит. В Effect<A, E, R> ошибка и требования записаны поимённо. Для потоков в Effect есть отдельный модуль Stream, до него дойдём в разделе EF10.

«Он тяжёлый». Effect это библиотека с рантаймом, и рантайм попадает в бандл. Но сборщик выкидывает модули, которые ты не импортировал, и задача на Effect.retry не тянет за собой HTTP-сервер. Четвёртую версию команда Effect пересобрала с нуля, и в анонсе Effect 4.0 показывает замеры: бандл минимальной программы стал в разы меньше, чем на третьей версии. Чужим цифрам верить не обязательно: собери свой бандл и посмотри сам.

«Он медленный». Шаг рантайма дороже голого await, это правда. Но башня большую часть времени ждёт соседа, и ожидание сети обычно намного дольше любого шага рантайма. А горячий вычислительный цикл, например разбор тысяч кадров в памяти, никто не мешает написать обычной функцией и позвать изнутри Effect. Если сомневаешься, меряй на своём коде, а не на чужих спорах.

Пондер не отвечает. Он смотрит на Ринцвальда так, будто записывает его в тетрадь.

Где тут дыра

Ринцвальд всё-таки решает попробовать рецепты и тут же показывает свой.

const sending = send('T-2');
const later = () => sending;

На здоровой башне всё сходится: await later() отдаёт 'принято', и Ринцвальд сияет. Возьми соседа в тумане из начала урока. Первый await later() получает туман. Второй получает тот же туман, а счётчик calls стоит на единице. Отправка случилась в первой строке, когда Ринцвальд вызвал send('T-2'), а later каждый раз возвращает один и тот же промис. Это не рецепт, это обед в коробке из-под рецепта.

Правило одно: рецепт создаёт работу внутри своего тела. () => send('T-2') рецепт, () => sending нет. Та же ловушка прячется в аргументах: retry(send('T-2'), 3) отправляет кадр раньше, чем retry успеет что-то решить, потому что аргументы вычисляются до вызова. Ровно так написан retry Ринцвальда в разминке.

Разминка

retry Ринцвальда принимает промис и поэтому ждёт одну и ту же неудачу три раза. Перепиши его на рецепт: пусть принимает () => Promise<A> и возвращает новый рецепт, который сам ничего не запускает, пока его не вызовут.

Практика

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

Почему исполнитель new Promise вызывается сразу и что ещё обещает контракт промиса, разбирает урок «Promise, thenable и async/await». Как идея «эффект как значение» выглядит рядом с другими моделями, показывает урок «Контекст и модели».

Дальше

Пондер закрывает тетрадь и поднимается.

Кто такой Библиотекарь и почему он отвечает одним словом, узнаешь в следующем уроке.