* Муравьи не знают, что они думают. Каждый несёт свою крошку из одной трубки в другую и кладёт её туда, куда велит надпись на трубке. Если надписи сделаны правильно, думает весь подвал. Если неправильно, подвал тоже думает, только неправильно и очень старательно.
В разделе EF2, начиная с урока 17, ты отделил вычисления от действий и оставил действия снаружи, в тонкой оболочке. Это работало, пока действий было мало. У башни их много: отправить, повторить, подождать, закрыть ставни. Им нужно то же, что получили вычисления: чтобы их можно было записать, передать, проверить и запустить, когда скажут.
В уроке 03 Пондер уже показывал рецепт вместо обеда: функция без вызова ждёт, промис уже летит. Этот урок делает из той мысли первую деталь Гекса: значение, которое хранит действие и исполняет его только по команде.
Скоропись башни
Вот как башня шлёт кадры сейчас:
const wire = {
send: (frame: string): void => {
console.log(`ушёл ${frame}`);
},
};
const relay = (frames: readonly string[]): void => {
for (const frame of frames) wire.send(frame);
};
relay(['T-1->T-7|1|Ping|']);
ушёл T-1->T-7|1|Ping|
Функция relay это действие в смысле Матушки: позвал, и кадр ушёл. Отдать её Гексу «на потом» нельзя, можно только позвать. Обернуть вызов в стрелку, () => relay(frames), уже лучше: стрелка ждёт. Но стрелку нельзя продолжить, не позвав. Чтобы сказать «отправь, а потом посчитай отправленное», придётся писать новую стрелку, внутри которой всё уже исполняется подряд: () => { relay(frames); return frames.length; }. Каждый следующий шаг прячет предыдущие внутрь. Из стрелок не собрать свиток, который можно читать по шагам.
Свиток
Нужен объект, который держит отложенное действие и умеет приставлять к нему следующие шаги, ничего не исполняя. Назовём его свитком, а тип IO<A>:
/** Записанный свиток: исполнится, только когда его запустят, и даст `A`. */
export type IO<A> = { readonly run: () => A };
Внутри свитка лежит отложенное вычисление run, тот самый thunk. Снаружи свиток собирают и запускают пятью функциями:
export const succeed = <A>(value: A): IO<A> => ({ run: () => value });
export const sync = <A>(thunk: () => A): IO<A> => ({ run: thunk });
export const map = <A, B>(self: IO<A>, f: (a: A) => B): IO<B> => ({
run: () => f(self.run()),
});
export const flatMap = <A, B>(self: IO<A>, f: (a: A) => IO<B>): IO<B> => ({
run: () => f(self.run()).run(),
});
export const runSync = <A>(self: IO<A>): A => self.run();
succeed кладёт в свиток значение, которое уже есть. sync кладёт действие, которое исполнится при запуске. map приставляет к свитку вычисление над его результатом, flatMap приставляет следующий свиток, который выбирают по результату. runSync запускает. Заметь, что map и flatMap возвращают новые свитки и ни одного run не зовут: они только пишут, что делать потом.
Отправка кадров на свитках:
import * as IO from './IO';
const send = (frame: string): IO.IO<void> =>
IO.sync(() => {
console.log(`ушёл ${frame}`);
});
const relay = IO.flatMap(send('T-1->T-7|1|Ping|'), () =>
IO.map(send('T-1->T-7|2|Text|гроза'), () => 2),
);
console.log('свиток записан');
console.log(IO.runSync(relay));
console.log(IO.runSync(relay));
свиток записан
ушёл T-1->T-7|1|Ping|
ушёл T-1->T-7|2|Text|гроза
2
ушёл T-1->T-7|1|Ping|
ушёл T-1->T-7|2|Text|гроза
2
relay это значение. Его можно положить в переменную, отдать другой функции, запустить дважды или не запускать вовсе. Сказать «отправь, потом посчитай» теперь можно, не исполняя ни того, ни другого. Отсюда и всё, что Гекс будет уметь дальше: раз свиток можно запустить второй раз, его можно повторить; раз его можно не запускать, можно и отменить.
Край мира в такой программе один: вызов runSync. Всё, что до него, это запись. Пондер называет это правилом подвала: свитки пишут где угодно, а исполняет их только Гекс.
Когда считается значение
succeed и sync похожи, но разница между ними ровно та, ради которой всё затевалось:
import * as IO from './IO';
let tick = 0;
const now = (): number => ++tick;
const early = IO.succeed(now());
const late = IO.sync(() => now());
console.log(IO.runSync(early), IO.runSync(early), IO.runSync(late), IO.runSync(late));
1 1 2 3
IO.succeed(now()) получает аргумент, а аргументы считаются до вызова функции. Часы спросили в момент, когда свиток писали, и свиток навсегда запомнил ответ 1. IO.sync(() => now()) получает стрелку и зовёт её при каждом запуске: 2, потом 3. Правило простое: succeed для того, что уже посчитано, sync для действия.
Перевод с Ук
Свиток, который читает кадры с провода и отправляет первый, а в ответ даёт число кадров:
import * as IO from './IO';
const read: IO.IO<readonly string[]> = IO.sync(() => ['T-1->T-7|1|Ping|']);
const send = (frame: string): IO.IO<void> => IO.sync(() => console.log(`ушёл ${frame}`));
const relayed: IO.IO<number> = IO.map(read, (frames) =>
IO.map(send(frames[0] ?? ''), () => frames.length),
);
nested.ts(6,7): error TS2322: Type 'IO<IO<number>>' is not assignable to type 'IO<number>'.
Type 'IO<number>' is not assignable to type 'number'.
Читаем снизу вверх. Нижняя строка: IO<number> не помещается туда, где ждут number. Верхняя объясняет, откуда это: справа получился IO<IO<number>>, свиток, который даёт свиток. Функция в map вернула свиток, и map честно положил его в результат, ничего не запуская. Так выглядит та же беда, что «коробка в коробке» из урока 27: когда функция сама возвращает свиток, нужен flatMap, который его и развернёт, и исполнит.
С типом IO<void> компилятор бы промолчал. В TypeScript функция, которая что-то возвращает, подходит туда, где ждут () => void: результат выбросят. Поэтому const relayFirst: IO.IO<void> = IO.map(read, (frames) => send(frames[0] ?? '')) собирается, runSync проходит без ошибок, а кадр не уходит: внутренний свиток вернули и выбросили, не запустив. Если в ответе свитка ничего полезного нет, тем внимательнее смотри, map у тебя или flatMap.
Под капотом
runSync(relay) зовёт relay.run(). Это run из flatMap: он зовёт self.run() у первого свитка, получает результат, зовёт f и у полученного свитка снова run(). Каждый flatMap это вызов функции внутри вызова функции. Свиток из трёх шагов при запуске ныряет на три вызова вглубь стека JavaScript, свиток из тысячи шагов на тысячу. Запомни это: у стека вызовов есть дно, и в уроке 33 Гекс в него упрётся.
Пока же замыкание даёт главное: запись отделена от исполнения, и между ними можно встать. Всё, что Гекс научится делать со свитками, он будет делать в этом промежутке.
Где тут дыра
import * as IO from './IO';
const sendPing = (): string => {
console.log('пинг ушёл');
return 'T-1->T-7|1|Ping|';
};
const ping = sendPing();
const scroll = IO.sync(() => ping);
console.log('свиток записан');
IO.runSync(scroll);
IO.runSync(scroll);
пинг ушёл
свиток записан
Пинг ушёл до того, как свиток записали, ровно один раз, а два запуска не отправили ничего. sendPing() позвали в строке const ping = ..., когда Гекс о свитке ещё не знал, а в sync завернули готовый результат. Ленивым стал ответ, а действие исполнилось сразу. С промисом выйдет то же самое: IO.sync(() => promise) вокруг промиса, созданного строкой выше, не отложит запрос, потому что промис уже летит.
Лениво то, что вызывается внутри стрелки, а не то, что лежит рядом с ней. Чинится переносом вызова внутрь: IO.sync(sendPing).
Вот куда придёшь
В Effect та же отправка выглядит почти так же:
import { Effect } from 'effect';
const send = (frame: string) => Effect.sync(() => console.log(`ушёл ${frame}`));
const relay = Effect.flatMap(send('T-1->T-7|1|Ping|'), () =>
Effect.map(send('T-1->T-7|2|Text|гроза'), () => 2),
);
console.log(Effect.runSync(relay));
Effect.sync, Effect.map, Effect.flatMap и Effect.runSync делают то же, что твои функции, только Effect<A, E, R> знает ещё про ошибки и нужды. Эти две буквы Гекс получит в следующих двух уроках.
Гекс, этап 1
Гекс начинается с модуля src/IO.ts. Ты напишешь тип свитка и пять функций: два способа записать действие, два способа приставить следующий шаг и запуск. Тесты проверяют, что до runSync не исполнилось ничего, а каждый запуск исполняет свиток заново.
Практика
Глубже в архиве
Та же мысль, действие как значение, лежит в основе ввода и вывода в Haskell: там IO встроен в язык, и по-другому сделать что-то с миром нельзя. Как это устроено, разбирает урок «IO». А что такое эффект и контекст исполнения в обычном JavaScript, без библиотек, разбирает урок «Контекст и эффекты».
Дальше
Свиток отправки записан, Гекс исполняет его, когда скажут. Ринцвальд смотрит на трубки с подозрением.
Как сделать провал частью свитка, а не исключением, которое летит мимо типов, разберём в следующем уроке.