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

Башня, которая молчит

middle~35 мин

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

* В Анкморе говорят, что быстрее всего по городу расходятся три вещи: слух, пожар и заклинание, которое кто-то произнёс, не дочитав до конца. Первые две хотя бы можно потушить.

Ночь. Над рекой лежит туман, в котором вязнут фонари. По Семафорной Магистрали бегут кадры: башня открывает ставни, соседняя читает и открывает свои, и сообщение пересекает Анкмор быстрее любого гонца. На краю города стоит башня Тихой Академии, T-1. В полночь ей пришёл кадр для трёх соседей. До утра она не сказала ни слова.

Скоропись этой башни писал ты. Утром за тобой присылают из Академии, прямо в кабинет Аркканцлера.

Врут все трое понемногу, и никто не нарочно. Твой код не упал, не бросил исключение, не написал в журнал ни одной ошибки. Он отработал и доложил неправду. Упавшую программу видно сразу, а такую можно искать неделю.

В этом уроке ты найдёшь в скорописи башни пять мест, где она молчит, поймёшь, почему TypeScript ни одно из них не заметил, и увидишь, что Академия называет эффектом. А в конце будет карта всего курса: от теории типов до кластера башен.

Журнал, в котором пусто

Вот функция, которую башня T-1 выполнила в полночь. Ей дают кадр, список соседей и два инструмента: send открывает ставни в сторону башни, log пишет строку в журнал.

// Скоропись башни T-1: разослать кадр соседним башням и записать в журнал, сколько дошло.
export type Deps = {
  readonly send: (tower: string, frame: string) => Promise<void>;
  readonly log: (line: string) => void;
};

export async function relayFrame(
  frame: string,
  towers: readonly string[],
  deps: Deps,
  timeoutMs = 1000,
): Promise<number> {
  let sent = 0;
  towers.forEach(async (tower) => {
    try {
      await deps.send(tower, frame);
      sent += 1;
    } catch {
      // башня потом догонит
    }
  });
  deps.log(`relayed ${sent}/${towers.length}`);
  void timeoutMs;
  return sent;
}

Код выглядит честно. Для каждой башни отправка, счётчик удач, итог в журнал. Запустим его на ночных соседях: T-2 отвечает через пять миллисекунд, T-3 бросает new Error('ставни заклинило'), T-4 не отвечает никогда. Функция вернула 0, а в журнале одна строка:

relayed 0/3

Через пять миллисекунд T-2 получила кадр, и переменная sent стала единицей. Только функция к тому времени уже вернула ноль, а журнал уже записан. Единицу никто не прочитал.

Пять мест, где скоропись молчит

Чинить функцию будешь сам, в разминке ниже. Сейчас только найдём, где она врёт. Мест пять.

Первое: forEach не ждёт. forEach вызывает колбэк для каждой башни и выбрасывает то, что колбэк вернул. Колбэк async, значит, вернул он промис, и этот промис никто не ждёт. forEach закончился раньше, чем ответила хоть одна башня. В функции нет ни одного await на её собственном пути, поэтому она проходит от первой строки до return за один заход.

Второе: журнал пишется раньше, чем кончилась работа. Строка с deps.log идёт сразу за forEach. Счётчик в этот момент ноль, и в журнал уходит ноль. Это не ошибка счёта: функция честно записала то, что знала в ту секунду.

Третье: catch {} глотает ошибку. У T-3 заклинило ставни, send бросил исключение, catch его поймал и выбросил. Комментарий «башня потом догонит» обещает, что кто-то когда-то разберётся. В журнал не попало ничего, и утром некому даже сказать, что T-3 вообще пыталась.

Четвёртое: нет таймаута. Функция принимает timeoutMs и тут же выбрасывает его строкой void timeoutMs. T-4 молчит, и её промис не закончится никогда. Сейчас это незаметно, потому что функция ничего не ждёт. Но стоит починить первое место и начать ждать все отправки, как башня T-1 повиснет вместе с T-4 навсегда. Одна починка открывает следующую дыру.

Пятое: бросают не только Error. В JavaScript throw принимает что угодно: строку, число, undefined, объект без message. Когда ты начнёшь писать причину неудачи в журнал, error.message от брошенной строки окажется undefined, и журнал снова соврёт, только аккуратнее.

Почему TypeScript промолчал

Посмотри на сигнатуру ещё раз: Promise<number>. Тип говорит одно: когда-нибудь будет число. Он не говорит, что функция трогает чужие башни, пишет в журнал, ждёт время и может повиснуть. Не говорит, чем она падает и падает ли вообще: у Promise один параметр типа, для успеха, а для отказа места нет.

Компилятор проверяет то, что записано в типе. Всё остальное для него не существует. Твоя скоропись прошла проверку типов, потому что о провалах в типах не было ни слова.

Что такое эффект

Сравни две функции башни. Первая собирает строку для журнала:

const summary = (sent: number, total: number): string => `relayed ${sent}/${total}`;

Вызови её сто раз с одними аргументами, и сто раз получишь одну и ту же строку. Мир от этого не изменится: ни одна ставня не откроется, ни одна строка не появится в журнале. Вызов summary(1, 3) можно заменить его результатом, 'relayed 1/3', и программа не заметит разницы.

Вторая функция это relayFrame. Вызови её дважды, и соседи получат кадр дважды. Её результат зависит от погоды на ставнях, от часов и от того, проснулся ли смотритель T-4. Заменить вызов результатом нельзя: результат это меньшая часть того, что она делает.

Всё, что функция делает с миром, кроме возврата значения, называется побочным эффектом. В этом курсе слово шире: эффект это любая работа, которая трогает мир, занимает время, может провалиться или требует чего-то извне. Отправка кадра это эффект. Чтение часов тоже: результат зависит не от аргументов, а от того, когда спросили.

Эффекты не беда, без них программа бесполезна: башня, которая ничего не отправляет, никому не нужна. Беда в скорописи. В Анкморе заклинание срывается с языка в ту же секунду, как его произнесли: так ведёт себя Promise, который уже летит, когда ты его создал. Отменить его нельзя, повторить по-другому нельзя, что оно заденет по пути, неизвестно.

Тихая Академия учит обратному: сначала запиши заклинание целиком, потом произноси. Записанное заклинание можно прочитать, проверить, повторить, отменить, снабдить запасным планом и отдать другому магу. Библиотека Effect, ради которой собран этот курс, держит такую запись в значении типа Effect<A, E, R>. В нём три буквы, и у каждой своя работа:

  • A это то, что заклинание вернёт при успехе;
  • E это то, чем оно может провалиться, причём каждым способом поимённо;
  • R это то, что ему нужно для работы: башни, журнал, часы.

Читать такие сигнатуры свободно ты научишься к концу раздела EF1. Пока запомни разницу с Promise<number>: там записан только успех, здесь записано всё.

Взгляд вперёд: та же башня на Effect

Вот как могла бы выглядеть рассылка, если записать её как заклинание. Это не задача и не образец для подражания: синтаксис Effect начнётся в разделе EF4. Сейчас смотри не на слова, а на то, где оказались провалы.

import { Data, Effect, Result } from 'effect';

class SendFailed extends Data.TaggedError('SendFailed')<{
  readonly tower: string;
  readonly cause: unknown;
}> {}

declare const sendToTower: (tower: string, frame: string) => Promise<void>;

const send = (tower: string, frame: string) =>
  Effect.tryPromise({
    try: () => sendToTower(tower, frame),
    catch: (cause) => new SendFailed({ tower, cause }),
  }).pipe(
    Effect.timeout('1 second'),
    Effect.tapError((error) => Effect.logWarning(`failed ${tower}: ${error._tag}`)),
  );

const relayFrame = Effect.fn('relayFrame')(function* (frame: string, towers: readonly string[]) {
  const results = yield* Effect.forEach(towers, (tower) => Effect.result(send(tower, frame)), {
    concurrency: 'unbounded',
  });
  const sent = results.filter(Result.isSuccess).length;
  yield* Effect.log(`relayed ${sent}/${towers.length}`);
  return sent;
});

Что тут происходит, по кусочкам:

  • Effect.tryPromise превращает отправку в запись. Всё, что бросит sendToTower, что угодно, а не только Error, ложится в SendFailed с именем башни.
  • Effect.timeout('1 second') ограничивает отправку секундой и добавляет к возможным провалам ещё один, TimeoutError. Наведи курсор на send в редакторе, и TypeScript покажет, что она возвращает Effect<void, SendFailed | TimeoutError, never>. Оба провала записаны в типе, и компилятор не даст о них забыть.
  • Effect.tapError пишет каждую неудачу в журнал и не мешает ей идти дальше.
  • Effect.forEach с concurrency: 'unbounded' шлёт кадр всем башням сразу и ждёт всех. Effect.result превращает исход каждой отправки в значение, поэтому сломанная T-3 не мешает T-2.
  • yield* внутри генератора читается как await: «дождись этой работы и дай её результат».

relayFrame возвращает Effect<number, never, never>: на месте E стоит never, пустота, потому что каждый провал уже разобран внутри и наружу не выходит ни один. И главное: вызов relayFrame('42', towers) ничего не отправляет. Он возвращает запись, а произносит её отдельная команда, Effect.runPromise. Почему это так ценно, разберём в третьем уроке.

С теми же соседями, что ночью, журнал теперь говорит:

[13:07:13.047] WARN (#5): failed T-3: SendFailed
[13:07:14.046] WARN (#8): failed T-4: TimeoutError
[13:07:14.047] INFO (#1): relayed 1/3

Три строки правды вместо одной строки неправды. Таймаут сработал через секунду, T-3 записана, итог посчитан после того, как отработали все.

Карта курса

До такого кода сто двадцать уроков, и в первых тридцати пяти Effect почти не встретится. Сначала ты научишься читать типы, потом думать функциями, потом соберёшь свой маленький Effect и только тогда откроешь настоящий. Иначе он покажется магией, а Академия магии без объяснений не терпит.

РазделУрокиО чём
EF0 Скорописец1-4зачем всё это и как читать ошибки TypeScript
EF1 Библиотека5-16теория типов, чтобы читать Effect<A, E, R>; протокол семафора shifr
EF2 Головология17-29функциональное программирование с нуля: действия и вычисления, pipe, Option, Result
EF3 Свой Гекс30-35свой эффект-рантайм на триста строк
EF4 Первые чары36-46ядро Effect: ошибки в типе, генераторы, первая версия башни
EF5 Сундук47-54сервисы и слои: откуда заклинание берёт то, что ему нужно
EF6 Смерть приходит вовремя55-60ресурсы, финализаторы, прерывание
EF7 Муравейник61-71файберы и конкурентность
EF8 Часы на башне72-75время: повторы, таймауты, расписания, тесты с часами
EF9 Печати76-84Schema: проверка и разбор данных с провода
EF10 Магистраль85-89стримы, батчи и повторы без дублей
EF11 Почтамт90-97HTTP API и база данных
EF12 Свет в окнах98-102логи, трассы, метрики
EF13 Витрина103-105Effect во фронтенде
EF14 Башни без хозяина106-112кластер, очереди, долгие процессы
EF15 Голем113-115Effect и языковые модели
EF16 Мастерство116-120финальный босс: всё вместе

За боссов разделов Академия даёт ранги, от студента до профессора Тихих чар. А по дороге выяснится, почему Магистраль теряет сообщения и почему её отчёты всегда зелёные.

Где тут дыра

Пока ты разглядываешь карту, в кабинет заглядывает Ринцвальд и видит на столе твою скоропись. Шляпа с надписью «ВАЛШЕБНИК» съехала набок, в руке пирожок.

export async function relayFrame(
  frame: string,
  towers: readonly string[],
  deps: Deps,
): Promise<number> {
  await Promise.all(towers.map((tower) => deps.send(tower, frame)));
  deps.log(`relayed ${towers.length}/${towers.length}`);
  return towers.length;
}

На двух здоровых башнях, T-2 и T-6, всё сходится: функция ждёт обе отправки, возвращает 2, в журнале relayed 2/2. Ринцвальд доволен.

Добавь в список T-3 со сломанными ставнями. Promise.all отклоняется на первой же ошибке, функция бросает ставни заклинило, и до deps.log дело не доходит: в журнале снова пусто, а что стало с T-2 и T-6, из этой функции уже не узнать. Добавь T-4, которая молчит, и Promise.all будет ждать её вечно. Ринцвальд закрыл одну дыру из пяти и открыл новую: теперь одна сломанная башня роняет рассылку всем.

Ринцвальд не дурак. Ему всегда некогда, поэтому он и колдует скорописью.

Разминка

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

Практика

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

Почему forEach не ждёт и чем Promise.all отличается от Promise.allSettled, подробно разбирает урок «Promise, thenable и async/await». А урок «Контекст и модели» показывает Effect рядом с акторами и STM, если хочется заранее увидеть его место среди других подходов.

Дальше

Рыдкулл захлопывает журнал.

В следующем уроке ты будешь весь день дописывать к скорописи то, о чём она молчала, и смотреть, как функция растёт быстрее, чем польза от неё.