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

Option: коробка, которая может быть пустой

middle~25 мин

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

Ринцвальд считает, сколько молчит башня. Для T-1 всё верно. Для T-9, которой на доске нет, отчёт пишет «молчит 60000 мс», и это читается как «давно молчит», а не «такой башни нет». Никто не ошибся в арифметике, ошибка в том, что отсутствие ответа превратилось в число.

В прошлом уроке тип судьбы кадра перестал пускать невозможные состояния. Здесь та же мысль для самого частого случая: значения может не быть. В TypeScript для этого есть undefined, и урок о том, почему его мало и что взять вместо.

Ноль вместо ответа

Первая версия Ринцвальда даже не собирается:

import type { Board } from './journal';

const silentFor = (board: Board, code: string, now: number): number => {
  const lastAt = board[code]?.lastAt;
  return now - lastAt;
};
undef.ts(5,16): error TS18048: 'lastAt' is possibly 'undefined'.

Компилятор прав: с noUncheckedIndexedAccess поиск по доске даёт Tower | undefined, и lastAt тоже может не быть. Ринцвальд чинит самым коротким способом:

const silentFor = (code: string, now: number): string =>
  `${code} молчит ${now - (board[code]?.lastAt ?? 0)} мс`;
T-1 молчит 59000 мс
T-9 молчит 60000 мс

Компилятор доволен, а отчёт врёт. ?? 0 не проверил отсутствие, а спрятал его: дальше по коду ноль ничем не отличается от настоящего момента. Решение «что значит нет ответа» принято посреди вычисления, где о нём ничего не известно, и никто дальше не узнает, что оно было принято.

Неопределённость, которая схлопывается

У undefined есть беда похуже. Пусть Ринцвальд хранит моменты в Map, и башня T-4 на доске есть, но её ещё ни разу не слышали:

const heardAt = new Map<string, number | undefined>([
  ['T-1', 1000],
  ['T-4', undefined],
]);

console.log(heardAt.get('T-4'), heardAt.get('T-9'));
undefined undefined

Два разных ответа, «башня есть, но молчит с рождения» и «башни нет», слиплись в один. Тип number | undefined | undefined это тот же number | undefined: объединение не умеет вкладывать одно «нет» в другое. Чтобы их различить, нужен второй вызов has, а его легко забыть.

Коробка

Матушка предлагает держать ответ в коробке. Коробка бывает двух видов: пустая или с чем-то внутри. Это сумма из прошлого урока, размеченная по _tag:

export type None = { readonly _tag: 'None' };
export type Some<A> = { readonly _tag: 'Some'; readonly value: A };
export type Option<A> = None | Some<A>;

export const none = <A = never>(): Option<A> => ({ _tag: 'None' });
export const some = <A>(value: A): Option<A> => ({ _tag: 'Some', value });

Такой тип называют Option. Мощность у него 1 + |A|: один вариант «пусто» и столько непустых, сколько значений у A. Коробка в коробке это 1 + 1 + |A|, и два «нет» уже не слипаются: Some(None) и None разные значения. Поэтому Option<Option<number>> честно хранит «башня есть, но молчит» отдельно от «башни нет».

Из мира undefined в мир коробок ведёт fromNullable: null и undefined становятся пустой коробкой, всё остальное кладётся внутрь, в том числе 0, '' и false. Это важно: «ноль» и «ничего» больше не путаются.

export const fromNullable = <A>(value: A): Option<NonNullable<A>> =>
  value === null || value === undefined ? none() : some(value);

Котёл держит Option отдельным модулем, как сам Effect, и тесты подключают его пространством имён: import * as Option from './Option'. Поэтому тип читается Option.Option<number>, а функции Option.map, Option.getOrElse. Так же выглядит код на Effect.

Работать, не открывая

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

export const map: {
  <A, B>(f: (a: A) => B): (self: Option<A>) => Option<B>;
  <A, B>(self: Option<A>, f: (a: A) => B): Option<B>;
} = dual(
  2,
  <A, B>(self: Option<A>, f: (a: A) => B): Option<B> =>
    self._tag === 'Some' ? some(f(self.value)) : none(),
);

Это dual из урока 22: Option.map(box, f) и pipe(box, Option.map(f)) работают оба. Теперь отчёт о молчании выглядит так:

const report = (code: string, now: number): string =>
  pipe(
    lastHeard(board, code),
    Option.map((lastAt) => `${code} молчит ${now - lastAt} мс`),
    Option.getOrElse(() => `${code} нет на доске`),
  );
T-1 молчит 59000 мс
T-9 нет на доске

lastHeard отвечает коробкой. Вычитание живёт внутри map и случается только для настоящей башни. В самом конце getOrElse решает, что написать, если коробка пуста. Это решение принято один раз и там, где о нём есть что сказать: в тексте отчёта. Для двух веток сразу есть match, с полями onNone и onSome.

Коробка в коробке

Иногда функция, которую хочется применить внутри, сама отвечает коробкой. Пусть код башни приходит строкой, и сначала его надо проверить:

const tower = (input: string): Option.Option<string> =>
  /^T-\d+$/.test(input) ? Option.some(input) : Option.none();

const nested = pipe(
  tower('T-1'),
  Option.map((code) => lastHeard(board, code)),
); // Option<Option<number>>
const flat = pipe(
  tower('T-1'),
  Option.flatMap((code) => lastHeard(board, code)),
); // Option<number>

map честно положил в коробку то, что вернула функция, а функция вернула коробку. Получилась коробка в коробке, и чтобы достать число, её придётся открывать дважды. flatMap делает другое: если коробка полная, отдаёт значение функции и возвращает её коробку, без обёртки. Пустая на любом шаге даёт пустую в конце. Читается так: «если код верный, найди башню; если и башня есть, вот момент».

Правило выбора простое. Функция возвращает значение, бери map. Функция возвращает коробку, бери flatMap.

Перевод с Ук

Ринцвальд торопится и лезет в коробку напрямую:

import * as Option from './Option';

const box = Option.some(1000);
console.log(box.value);
peek.ts(4,17): error TS2339: Property 'value' does not exist on type 'Option<number>'.
  Property 'value' does not exist on type 'None'.

Читаем снизу. Нижняя строка называет вариант, в котором поля нет: None. Верхняя переводит это на весь тип: у Option<number> поля value нет, потому что оно есть не у всех вариантов. Компилятор не смотрит, что ты только что положил в коробку 1000: some возвращает Option<number>, а не Some<number>, и это нарочно. Коробку нельзя открыть, не спросив, полная ли она. Спросить можно через Option.isSome, через _tag в switch или, лучше всего, не открывать вовсе и работать через map, flatMap и getOrElse.

Куда придёшь

В Effect Option устроен так же: те же теги Some и None, то же поле value, те же map, flatMap, getOrElse в двух видах. Тот же отчёт на настоящем effect:

import { Option, pipe } from 'effect';

const board: Readonly<Record<string, { readonly lastAt: number }>> = { 'T-1': { lastAt: 1000 } };

const report = (code: string, now: number): string =>
  pipe(
    Option.fromNullishOr(board[code]),
    Option.map((tower) => `${code} молчит ${now - tower.lastAt} мс`),
    Option.getOrElse(() => `${code} нет на доске`),
  );

Одно отличие в имени: в Effect 4 наш fromNullable называется fromNullishOr, рядом есть fromUndefinedOr и fromNullOr для случаев, когда пустым считается только одно из двух.

Где тут дыра

const silence = (code: string, now: number): number => {
  const lastAt = Option.getOrElse(lastHeard(board, code), () => 0);
  return now - lastAt;
};

console.log(silence('T-1', 60_000), silence('T-9', 60_000));
59000 60000

Коробка прожила одну строку. getOrElse(() => 0) это тот же ?? 0, только длиннее: пустоту опять заменили нулём посреди вычисления, и T-9 снова молчит шестьдесят секунд. Коробка помогает, пока её не открыли. Открывать стоит на краю, там, где ответ превращается в текст отчёта или в решение, а не там, где он только что появился.

Котёл, этап 8

Котёл получает свою коробку. Ты напишешь модуль Option с none, some, fromNullable, проверками isSome и isNone и четырьмя функциями в двух видах через dual: map, flatMap, getOrElse и match. Затем журнал научится отвечать коробкой на вопрос, когда башню слышали в последний раз. Модуль src/Option.ts в заготовке пустой, импорт в journal.ts допишешь сам.

Практика

Дальше

Отчёт о молчании честный: T-9 нет на доске. Ринцвальд приносит следующую беду. Журнал башни приходит с провода строками, sent 41 1030, и строка бывает битой.

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