Утро. На ступенях Академии сидит молодой маг в круглых очках и что-то пишет в тетрадь. Из кармана его мантии торчит стеклянная трубка, в ней бегает муравей. Это Пондер Стиб, единственный в Академии, кто понимает, как устроен Гекс. Сундук останавливается у нижней ступени и ждёт.
Вопрос кажется детским, а от ответа зависит всё, что было вчера. Если работа начинается при создании, то к моменту, когда ты решаешь, ждать её или отменить, она уже идёт. Повторить её нельзя, она уже случилась. Отмена вынуждена догонять работу по проводам. В этом уроке ты увидишь, чем записанная работа отличается от начатой, и почему Академия начинает с записи.
Промис уже летит
Проверим на трёх строках:
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». Как идея «эффект как значение» выглядит рядом с другими моделями, показывает урок «Контекст и модели».
Дальше
Пондер закрывает тетрадь и поднимается.
Кто такой Библиотекарь и почему он отвечает одним словом, узнаешь в следующем уроке.