Рыдкулл принёс журнал Магистрали на пятьдесят строк. Битых в нём около десяти, а разбор из прошлого урока показывает одну: дорога в два пути останавливается на первой ошибке. Рыдкулл чинит строку, запускает снова, получает следующую. Десять кругов ради того, что можно было сказать за один.
Дело не в том, что дорога плохая. Внутри одной строки она права: если вид записи не прочитан, разбирать номер кадра незачем. Но строки журнала друг от друга не зависят, и ошибка в пятой ничего не говорит о двадцатой. Этот урок о том, когда останавливаться на первой ошибке, а когда собирать все, и почему это два разных инструмента, а не настройка одного.
Как 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 честный, разберём в следующем уроке.