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

Первая ошибка или все сразу

middle~25 мин

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

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

Дело не в том, что дорога плохая. Внутри одной строки она права: если вид записи не прочитан, разбирать номер кадра незачем. Но строки журнала друг от друга не зависят, и ошибка в пятой ничего не говорит о двадцатой. Этот урок о том, когда останавливаться на первой ошибке, а когда собирать все, и почему это два разных инструмента, а не настройка одного.

Как Promise.all

Сначала соберём результаты всех строк в один так, как подсказывает привычка:

const lines = ['sent 1 10', 'lost 2 20', 'sent x 30', 'acked 1 40'];

console.log(Result.all(lines.map(parseEntry)));
{ _tag: 'Failure', failure: { _tag: 'BadKind', kind: 'lost' } }

all превращает список результатов в результат со списком: если всё удалось, внутри все записи по порядку, иначе первая ошибка. Так же ведёт себя Promise.all: первое отклонение решает исход, остальные никто не смотрит. Такое поведение называют коротким замыканием, как в &&: если левая часть ложна, правую не считают.

/** Как `Promise.all`: все успехи по порядку или первая ошибка, дальше не смотрим. */
export const all = <A, E>(results: readonly Result<A, E>[]): Result<readonly A[], E> => {
  const values: A[] = [];
  for (const result of results) {
    if (result._tag === 'Failure') return result;
    values.push(result.success);
  }
  return succeed(values);
};

Внутри цикла массив values меняется на месте, и это нормально: снаружи его никто не видит, пока функция не вернула результат. Чистота, как в уроке 18, говорит о том, что видно снаружи.

Корзина

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

/** Все успехи по порядку или все ошибки по порядку: проверяем до конца. */
export const validateAll = <A, E>(
  results: readonly Result<A, E>[],
): Result<readonly A[], readonly E[]> => {
  const values: A[] = [];
  const errors: E[] = [];
  for (const result of results) {
    if (result._tag === 'Failure') errors.push(result.failure);
    else values.push(result.success);
  }
  return errors.length > 0 ? fail(errors) : succeed(values);
};
{ _tag: 'Failure', failure: [
  { _tag: 'BadKind', kind: 'lost' },
  { _tag: 'BadNumber', field: 'frame', raw: 'x' }
] }

Это накопление ошибок. Посмотри на тип: у all ошибка E, одна, а у validateAll ошибка readonly E[], список. Сигнатура сама говорит, сколько ошибок ты получишь. И ещё одно: успех у validateAll бывает только целиком. Если хоть одна строка битая, записей не будет вовсе, только ошибки. Почему это важно, видно в «Где тут дыра».

Ошибки без адреса Рыдкуллу мало помогут: «где-то есть lost» хуже, чем «строка 2». Номер строки добавляет mapError из прошлого урока, у каждой строки до того, как результаты пойдут в кучу:

export type LineError = { readonly line: number; readonly error: EntryError };

export const parseJournal = (
  text: string,
): Result.Result<readonly Entry[], readonly LineError[]> =>
  Result.validateAll(
    text
      .split('\n')
      .map((line, index) => ({ line, number: index + 1 }))
      .filter(({ line }) => line.trim() !== '')
      .map(({ line, number }) =>
        pipe(
          parseEntry(line),
          Result.mapError((error): LineError => ({ line: number, error })),
        ),
      ),
  );

На журнале sent 1 10, lost 2 20, пустая строка, sent x 30, acked 1 40 получается ровно то, что просил Рыдкулл:

{ _tag: 'Failure', failure: [
  { line: 2, error: { _tag: 'BadKind', kind: 'lost' } },
  { line: 4, error: { _tag: 'BadNumber', field: 'frame', raw: 'x' } }
] }

Номера строк считаются до того, как выброшены пустые: человек открывает журнал в редакторе, и там пустая строка тоже занимает номер. Поэтому sent x 30 это строка 4, а не 3.

Пирог или корзина

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

ЗадачаШаги зависят?Что брать
разобрать одну строку: вид, потом номер, потом моменттак написан наш разборflatMap, первая ошибка
разобрать весь журнал по строкамнетvalidateAll, все ошибки
прочитать настройки, где адрес собирается из хоста и портадаflatMap
проверить форму регистрации из пяти полейнетнакопление

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

Перевод с Ук

Ринцвальд по привычке собрал журнал через all, а сигнатуру взял из карточки:

const parseJournal = (text: string): Result.Result<readonly Entry[], readonly LineError[]> =>
  Result.all(
    text.split('\n').map((line, index) =>
      pipe(
        parseEntry(line),
        Result.mapError((error): LineError => ({ line: index + 1, error })),
      ),
    ),
  );
uk.ts(6,3): error TS2322: Type 'Result<readonly Entry[], LineError>' is not assignable to type 'Result<readonly Entry[], readonly LineError[]>'.
  Type 'Failure<LineError>' is not assignable to type 'Result<readonly Entry[], readonly LineError[]>'.
    Type 'Failure<LineError>' is not assignable to type 'Failure<readonly LineError[]>'.
      Type 'LineError' is missing the following properties from type 'readonly LineError[]': length, concat, join, slice, and 26 more.

Снизу вверх. Нижняя строка жалуется, что у LineError нет length, concat, join, slice и ещё двадцати шести свойств массива: компилятор пытался сделать из одной ошибки список и не смог. Строкой выше видно, где: Failure<LineError> против Failure<readonly LineError[]>. Самая верхняя строка называет виновника целиком: all возвращает одну ошибку, а сигнатура обещает список. Тип ошибки и есть разница между пирогом и корзиной, и компилятор её заметил раньше Рыдкулла. Чинится заменой all на validateAll.

Под капотом

Посмотри на его сигнатуру: flatMap(self, f), где f: (a: A) => Result<B, E2>. Чтобы позвать f, нужно значение A из self. Если self это Failure, значения нет, и звать f не с чем. Значит, ошибку второго шага узнать невозможно: шаг даже не начался. Короткое замыкание у flatMap не выбор автора, а следствие формы: второй шаг получает на вход результат первого.

Накопление устроено иначе. Все результаты уже посчитаны до того, как validateAll их увидел: lines.map(parseEntry) разобрал каждую строку сам по себе. validateAll ничего не зовёт, он только раскладывает готовое по двум кучкам. Поэтому собирать ошибки можно только у шагов, которые можно посчитать независимо друг от друга. Это свойство задачи, а не библиотеки.

Где тут дыра

const lines = ['snet 41 1000', 'acked 41 1100', 'sent 42 1200'];

const entries = lines
  .map(parseEntry)
  .filter(Result.isSuccess)
  .map((result) => result.success);

console.log(phantomAcks(entries));
[ 41 ]

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

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

В Effect у Result тоже есть all с коротким замыканием, а накопление для эффектов делают Effect.validate и Effect.partition. Первая, как наш validateAll, при хоть одной ошибке отдаёт все ошибки и выбрасывает успехи. Вторая никогда не падает и отдаёт успехи и ошибки двумя кучками: это и есть сознательный частичный результат.

Котёл, этап 10

Котёл учится отвечать на пятьдесят строк один раз. Ты допишешь в Result два способа собрать список результатов: all с коротким замыканием и validateAll с накоплением. Затем журнал научится разбирать весь текст целиком и называть каждую битую строку по номеру, с пустыми строками в счёте, но без ошибок на них.

Практика

Дальше

Рыдкулл получил все десять битых строк с номерами за один раз и ушёл их чинить. Матушка смотрит на три модуля котла, разложенные на столе: Option, Result и обычный массив журнала.

Одинаковый узор в трёх местах это не совпадение. Как его называют, чем это имя полезно и как проверить, что твой map честный, разберём в следующем уроке.