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

Match: полное сопоставление

middle~30 мин

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

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

Десять switch

Вот журнал Ринцвальда. Он пишет строку про каждое сообщение:

export const logMessage = (message: Message): void => {
  switch (message._tag) {
    case 'Text': {
      console.log(`текст: ${message.body}`);
      break;
    }
    case 'Ack': {
      console.log(`подтверждён ${message.of}`);
      break;
    }
    case 'Ping': {
      console.log('пинг');
      break;
    }
  }
};

logMessage({ _tag: 'Lost', since: 1 });
console.log('после лога Lost');
после лога Lost

Собирается без единого замечания, и на Lost молчит. Почему компилятор не заметил? switch это инструкция, а не выражение: ему не нужно ничего вернуть, и никто не требует, чтобы он разобрал все случаи. Функция возвращает void, и «ничего не сделал» для неё такой же законный исход, как «записал строку».

Ты знаешь лекарство из урока 07: default: return absurd(message). В default попадают варианты, которые никто не разобрал, а absurd принимает только never, пустую полку. Забыл Lost, и вызов absurd не соберётся. Лекарство работает, но его надо не забыть в каждом из десяти switch. Рыдкулл нашёл два, где его забыли.

Беда не в самом switch, а в том, что полнота у него держится на дисциплине. Хочется, чтобы она была свойством по умолчанию.

Сопоставление как значение

В effect для этого есть модуль Match. Вот что башня делает с сообщением, записанное через него:

import { Match } from 'effect';

export const outcomeFor: (message: Message) => Outcome = Match.type<Message>().pipe(
  Match.tag('Text', () => 'acked' as const),
  Match.tag('Ping', () => 'ponged' as const),
  Match.tag('Ack', () => 'confirmed' as const),
  Match.tag('Lost', () => 'noted' as const),
  Match.exhaustive,
);

Читаем по строкам.

  • Match.type<Message>() заводит сопоставитель для входа типа Message. Пока в нём нет ни одного случая.
  • .pipe(...) пропускает его через функции по очереди, как pipe из урока 37.
  • Match.tag('Text', обработчик) добавляет случай: если _tag равен 'Text', позвать обработчик. Обработчик получает уже суженный вариант, с полем body. Главное происходит в типе: Text вычитается из того, что осталось разобрать.
  • Match.exhaustive закрывает сопоставитель и превращает его в функцию (message: Message) => Outcome. Закрыть он соглашается, только если разбирать больше нечего.

Получилось не действие, а значение. outcomeFor это обычная функция: её можно передать, положить в объект, позвать в Effect.map. И она не умеет забыть вариант.

const planned = Effect.map(decodeFrame('T-3->T-1|2|Lost|1'), (frame) => outcomeFor(frame.message));

console.log(Effect.runSync(planned));
noted

Сопоставитель ничего не знает ни про квитанции, ни про провод. Это вычисление в смысле урока 17: на одно и то же сообщение он всегда отвечает одно и то же и ничего не трогает снаружи. Поэтому его зовут внутри Effect.map, а проверяют без башни и без запуска, одной строкой на вариант: outcomeFor({ _tag: 'Ping' }) даёт 'ponged'. Так устроены и тесты этапа. А решение «отвечать ли на пинг пингом» стоит в одном месте, а не в десяти.

Несколько тегов с одним ответом пишутся в одном случае: Match.tag('Ping', 'Lost', () => 'служебный'). Так же устроен catchTag с массивом тегов из урока 39: там ловят по тегу ошибки, здесь сопоставляют по тегу любые значения.

Перевод с Ук

Убери из outcomeFor строку с Lost:

export const outcomeFor: (message: Message) => Outcome = Match.type<Message>().pipe(
  Match.tag('Text', () => 'acked' as const),
  Match.tag('Ping', () => 'ponged' as const),
  Match.tag('Ack', () => 'confirmed' as const),
  Match.exhaustive,
);
outcome.ts(15,3): error TS2345: Argument of type '<I, F, A, Pr, Ret, Args extends Array<any>>(self: Matcher<I, F, never, A, Pr, Ret, Args>) => [Pr] extends [never] ? [Args] extends [[]] ? (u: I) => Unify<...> : (...args: Args) => Unify<...> : Unify<...>' is not assignable to parameter of type '(_: Matcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, { readonly _tag: "Lost"; readonly since: number; }, "acked" | ... 1 more ... | "confirmed", never, any, []>) => (u: Message) => "acked" | ... 1 more ... | "co...'.
  Types of parameters 'self' and '_' are incompatible.
    Type 'Matcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, { readonly _tag: "Lost"; readonly since: number; }, "acked" | ... 1 more ... | "confirmed", never, any, []>' is not assignable to type 'Matcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, never, "acked" | "ponged" | "confirmed", never, any, []>'.
      Type 'TypeMatcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, { readonly _tag: "Lost"; readonly since: number; }, "acked" | ... 1 more ... | "confirmed", any, []>' is not assignable to type 'TypeMatcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, never, "acked" | "ponged" | "confirmed", any, []>'.
          Type '{ readonly _tag: "Lost"; readonly since: number; }' is not assignable to type 'never'.

Пять строк, и каждая длиннее экрана. Пугаться не надо: читаем снизу, и хватит последней. { readonly _tag: "Lost"; readonly since: number; } не помещается в never. Это та же жалоба, что у absurd в уроке 07: в пустую полку положили вариант, а у варианта есть значения.

Где эта полка? Поднимись на строку выше. Там два типа TypeMatcher<...>, и различаются они третьим параметром. У твоего сопоставителя третьим стоит { _tag: "Lost"; ... }, у того, которого ждёт exhaustive, стоит never. Третий параметр сопоставителя это «что осталось разобрать». Второй, Without<Text | Ack | Ping>, это «что уже разобрано», он у обоих одинаковый.

Самая верхняя строка называет место: строка 15 (выше в файле импорт и объявления Message и Outcome), аргумент pipe, то есть Match.exhaustive. Перевод целиком: «exhaustive закрывает только сопоставитель, у которого ничего не осталось, а у тебя остался Lost». Чинится строкой Match.tag('Lost', ...). Если Магистраль придумает пятый тег и он появится в Message, такая же ошибка встанет в каждом сопоставителе, который о нём не знает.

Обработчики объектом

Когда у каждого тега свой ответ, короче записать их объектом. Строка журнала про ошибку башни:

export const explain: (error: TowerError) => string = Match.type<TowerError>().pipe(
  Match.tags({
    GarbledFrame: (error) => `мусор на проводе: ${error.line}`,
    BadRoute: (error) => `нет такого маршрута: ${error.route}`,
    UnknownTag: (error) => `неизвестный тег: ${error.tag}`,
    BadPayload: (error) => `битые данные: ${error.payload}`,
    NotForUs: (error) => `кадр для ${error.to}`,
    PhantomAck: (error) => `призрак: подтверждён кадр ${error.of}`,
    SendFailed: (error) => `провод не принял: ${error.reason}`,
  }),
  Match.exhaustive,
);

Match.tags берёт объект «тег → обработчик», и каждый обработчик получает свой вариант: у GarbledFrame есть line, у PhantomAck есть of, и компилятор это знает. Ключ, которого нет среди тегов, не соберётся: опечатка BadRout вместо BadRoute даст Type '() => string' is not assignable to type 'never' на строке с опечаткой. Забытый тег поймает exhaustive, как в «Переводе с Ук».

Есть и сокращение Match.tagsExhaustive({ ... }), которое сразу требует все теги и закрывает сопоставитель. На вход сопоставитель берёт не только тип: Match.value(x) сопоставляет одно значение здесь и сейчас, а Match.when проверяет произвольный образец или предикат. Для башни хватит type и tag.

Литералы

Состояние соседней башни это не объект с тегом, а строка: 'ok' | 'busy' | 'down'. Для строковых и числовых литералов в Effect 4.1 появился Match.literals:

Нестабильно. Match.literals и Match.literalsExhaustive помечены в исходнике effect меткой @stability unstable и появились в версии 4.1.0. Имя, форма объекта и правила вывода типа могут поменяться в минорной версии. Урок написан на effect@4.1.0; если у тебя версия новее, сверь сигнатуру с документацией.

export type Status = 'ok' | 'busy' | 'down';

export const statusWord: (status: Status) => string = Match.type<Status>().pipe(
  Match.literals({ ok: () => 'на связи', busy: () => 'занята' }),
  Match.orElse(() => 'молчит'),
);

console.log(statusWord('busy'));
console.log(statusWord('down'));
занята
молчит

Match.orElse закрывает сопоставитель запасным ответом для всего, что осталось. Здесь он честный: «всё, что не на связи и не занято, молчит» это правило Академии, а не забывчивость. Чем orElse опасен, увидишь ниже.

Под капотом

В типах сопоставитель несёт параметры, которые ты видел в ошибке: вход, «что разобрано», «что осталось» и объединение ответов. Каждый Match.tag вычитает свой вариант из «что осталось», почти как Exclude из урока 11, и добавляет тип ответа обработчика к объединению. exhaustive требует, чтобы «что осталось» было never. Вся проверка полноты происходит при компиляции.

В рантайме сопоставитель это список случаев: проверка и обработчик. Готовая функция проходит их по порядку, и первый подошедший отвечает. Если не подошёл ни один, а по типам такого быть не могло, exhaustive бросает ошибку с текстом effect/match/Match/exhaustive: absurd. Так бывает, когда в функцию попали непроверенные данные, например строка с провода, приведённая через as. Поэтому сопоставление стоит после разбора кадра в decodeFrame, а не вместо него: Match верит типам и проверять вход не обещал.

Отсюда и ответ на вопрос «а почему не switch с absurd?». Полнота у них одинаковая, и switch чуть быстрее: он сравнивает строки сам, а сопоставитель вызывает функцию-проверку на каждый случай. Match выигрывает в другом. Он выражение, а не инструкция, поэтому всегда что-то возвращает. Сопоставитель это значение, его собирают из частей и передают. И полнота в нём по умолчанию: чтобы её выключить, надо написать orElse, а не забыть default.

Где тут дыра

type TowerError = GarbledFrame | BadRoute | SendFailed | UnknownTower;

export const explainLoose = Match.type<TowerError>().pipe(
  Match.tag('GarbledFrame', (error) => `мусор на проводе: ${error.line}`),
  Match.tag('BadRoute', (error) => `нет такого маршрута: ${error.route}`),
  Match.tag('SendFailed', (error) => `провод не принял: ${error.reason}`),
  Match.orElse(() => 'что-то сломалось'),
);

console.log(explainLoose(new UnknownTower({ code: 'T-9' })));
что-то сломалось

На его трёх ошибках всё работает. Но UnknownTower из прошлого урока прибавилась в TowerError, и сопоставитель её проглотил: собрался без замечаний и пишет в отчёт «что-то сломалось» вместо «нет в книге: T-9». Код не пришлось трогать, когда ошибок прибавилось, ровно как Ринцвальд и хотел. Поэтому никто и не узнал, что их прибавилось.

orElse выключает проверку полноты целиком: всё, что осталось, теперь и впредь, уходит в один ответ. Это тот же catch {} Магистрали, только в отчёте: ошибка есть, а её имени не видно. Где варианты закрыты и значат разное, пиши exhaustive, и новый тег сам покажет все места, где про него надо подумать. orElse оставь для случаев, где «всё остальное» это правило, как у statusWord.

Башня, этап 8

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

Практика

Дальше

Сопоставители собраны, Lost больше не пропадает, а новый тег покажет все места, где о нём надо подумать. Под утро Рыдкулл приносит журнал провода.

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