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

Вариантность

middle-senior~35 мин

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

В уроке 05 ты видел, что Ping стоит на полке Message: каждый пинг это сообщение, поэтому пинг подходит везде, где ждут сообщение. Ринцвальд делает следующий шаг, и выглядит он безобидно: раз пинг это сообщение, то всё, что «про пинги», это и «про сообщения». Очередь пингов это очередь сообщений, отправитель пингов это отправитель сообщений.

Для очереди это правда. Для отправителя нет. Разница в том, куда движется сообщение: из коробки наружу или снаружи в коробку. Правило, по которому подстановка Ping вместо Message переносится (или не переносится) на тип вокруг, называется вариантностью.

В этом уроке ты разберёшь, почему получателя пингов можно отдать вместо общего, а отправителя нельзя, почему ошибки двух задач из урока 11 складываются в объединение, и почему never подходит куда угодно. В конце ты прочтёшь первую строку настоящего объявления Effect и поймёшь в ней каждое слово.

Коробка, которая отдаёт

Представь полку в Библиотеке, с которой книги можно только брать. Тебе нужна полка, с которой снимают «какую-нибудь книгу». Полка, где стоят одни атласы, подходит: что бы ты с неё ни снял, это книга. Ты просил книгу и получил книгу.

Сообщения, с которыми будем работать весь урок:

export type Text = { readonly _tag: 'Text'; readonly body: string };
export type Ping = { readonly _tag: 'Ping' };
export type Message = Text | Ping;

export const ping: Ping = { _tag: 'Ping' };
export const text = (body: string): Text => ({ _tag: 'Text', body });

Это файл messages.ts, остальные примеры его импортируют. Получатель умеет только одно: отдать следующее сообщение или undefined, если очередь пуста.

import { type Message, type Ping, ping } from './messages';

type Receiver<M> = { readonly receive: () => M | undefined };

const pings: Receiver<Ping> = { receive: () => ping };
const inbox: Receiver<Message> = pings;

const next = inbox.receive();
if (next !== undefined) console.log(next._tag);

Компилятор молчит. Тот, кто держит inbox, ждёт от receive значение Message | undefined. Получает пинг, а пинг это сообщение. Обещание Receiver<Message> выполнено, хотя внутри очередь только пингов.

Подстановка прошла в ту же сторону, что у параметра: Ping подходит вместо Message, и Receiver<Ping> подходит вместо Receiver<Message>. Такой тип ковариантен по M. Слово длинное, а правило короткое: кто только отдаёт, тот ковариантен. Тип результата функции, Promise<M>, readonly M[]: все они отдают M наружу, и Promise<Ping> подходит вместо Promise<Message> по той же причине.

Коробка, которая берёт

Теперь ящик для возврата книг. Тебе нужен ящик, куда можно бросить любую книгу. Ящик «только для атласов» не подходит: ты бросишь туда роман, и тот, кто разбирает ящик, полезет искать в нём карты.

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

import { type Message, type Text, ping } from './messages';

type Sender<M> = { readonly send: (message: M) => void };

const shout: Sender<Text> = {
  send: (message) => console.log(message.body.toUpperCase()),
};

const broadcast: Sender<Message> = shout;
broadcast.send(ping);

Если бы девятая строка прошла, десятая отдала бы пинг в функцию, которая лезет в message.body. У пинга body нет, и вызов toUpperCase у undefined уронил бы башню. Библиотекарь ловит это раньше.

Перевод с Ук

sender.ts(9,7): error TS2322: Type 'Sender<Text>' is not assignable to type 'Sender<Message>'.
  Type 'Message' is not assignable to type 'Text'.
    Property 'body' is missing in type 'Ping' but required in type 'Text'.

Читаем снизу вверх. Последняя строка: у Ping нет поля body, а Text его требует. Выше: Message не подходит вместо Text, потому что среди сообщений есть пинг. Верхняя строка: значит, Sender<Text> не подходит вместо Sender<Message>.

Теперь посмотри на направление. Ты присваивал Sender<Text> туда, где ждут Sender<Message>, то есть шёл от Text к Message. А вторая строка ошибки проверяет обратное: подходит ли Message вместо Text. Чтобы сравнить двух отправителей, компилятор сравнил их параметры задом наперёд. Поэтому Библиотекарь и стучал по второй строке: если в ошибке направление перевернулось, параметр стоит там, где тип что-то берёт.

Обратная подстановка безопасна:

import { type Message, type Text, text } from './messages';

type Sender<M> = { readonly send: (message: M) => void };

const journal: Sender<Message> = {
  send: (message) => console.log(message._tag),
};

const texts: Sender<Text> = journal;
texts.send(text('гроза у моста'));

Журнал башни принимает любое сообщение, значит, примет и текст. Подстановка пошла против параметра: Text подходит вместо Message, а Sender<Message> подходит вместо Sender<Text>. Такой тип контравариантен. Кто только берёт, тот контравариантен.

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

Что тип делает с MКуда идёт подстановкаКак называется
только отдаётв ту же сторонуковариантен
только берётв обратнуюконтравариантен
и берёт, и отдаётникудаинвариантен
не трогает M вовсев обебивариантен

Ошибки складываются

Вернись к своей ленивой задаче из урока 11:

type Outcome<A, E> =
  { readonly ok: true; readonly value: A } | { readonly ok: false; readonly error: E };
type Task<A, E> = () => Promise<Outcome<A, E>>;

declare const lostOnly: Task<number, 'Lost'>;
declare const safe: Task<number, never>;

const wider: Task<number, 'Lost' | 'Garbled'> = lostOnly;
const anywhere: Task<number, 'Lost'> = safe;
const narrower: Task<number, never> = lostOnly;

Где в Task стоит E? Внутри Outcome, внутри Promise, в результате функции. Задача ошибку только отдаёт, никто не передаёт ошибку в задачу. Значит, Task ковариантен по E, и первые две подстановки проходят.

  • wider: задача, которая может упасть только с 'Lost', подходит туда, где готовы к 'Lost' или 'Garbled'. Тот, кто держит wider, разберёт оба случая, а придёт только один. Это пара из урока 05: больше значений, меньше гарантий. Про ошибку wider известно меньше, чем про ошибку lostOnly, но ничего не сломалось.
  • anywhere: задача с ошибкой never подходит туда, где ждут ошибку 'Lost'. never это пустая полка из урока 05, она вложена в любую другую. Задача, которая не падает никогда, выполняет любое обещание о том, как она упадёт.
  • narrower не проходит:
task.ts(10,7): error TS2322: Type 'Task<number, "Lost">' is not assignable to type 'Task<number, never>'.
  Type '"Lost"' is not assignable to type 'never'.

Задача, которая может потеряться, не выдаст себя за ту, что не падает. В пустую полку не поставить ни одной книги.

Теперь понятно, почему andThen из урока 11 возвращает Task<B, E | E2>. Цепочка из двух задач может упасть ошибкой первой или ошибкой второй. Результату нужен тип ошибки, в который помещаются обе: тогда и E, и E2 по ковариантности встают на его место. Самая узкая такая полка это объединение E | E2. А succeed возвращает Task<A, never>, потому что задача без ошибки встаёт в любую цепочку и ничего к ошибкам не добавляет: E | never это E.

Вот первая строка объявления Effect из пакета effect@4.1.0:

export interface Effect<out A, out E = never, out R = never> extends Pipeable, Inspectable {

Три параметра, у каждого слово out, и у ошибки по умолчанию never. Это та же квитанция, что у твоего Task, только с третьим полем. A и E ведут себя так же, как в Task: эффект с меньшим набором ошибок подходит туда, где готовы к большему, и ошибки двух эффектов складываются объединением. Что такое R, разберём позже в курсе. Пока запомни, что и он out. Осталось выяснить, что это за out.

Аннотации in и out

Компилятор умеет сам вычислить, как дженерик ведёт себя по параметру. Но с TypeScript 4.7 это можно сказать явно: out у параметра, который тип только отдаёт, in у того, который только берёт, и in out у того, который тип и берёт, и отдаёт.

import type { Message, Ping } from './messages';

interface Sender<in M> {
  readonly send: (message: M) => void;
}

interface Receiver<out M> {
  readonly receive: () => M | undefined;
}

interface Slot<in out M> {
  value: M;
}

declare const journal: Sender<Message>;
declare const pings: Receiver<Ping>;
declare const pingSlot: Slot<Ping>;

const forPings: Sender<Ping> = journal;
const inbox: Receiver<Message> = pings;
const slot: Slot<Message> = pingSlot;

Первые две подстановки проходят, последняя нет: в Slot<Message> можно записать текст, и он окажется в ячейке, где ждут только пинги. Аннотация даёт три вещи.

  1. Компилятор проверяет тебя. Ошибся в аннотации, и он скажет. Пометь получателя как in:

    import type { Message, Ping } from './messages';
    
    interface Receiver<in M> {
      readonly receive: () => M | undefined;
    }
    
    declare const pings: Receiver<Ping>;
    const inbox: Receiver<Message> = pings;
    annot.ts(3,20): error TS2636: Type 'Receiver<super-M>' is not assignable to type 'Receiver<sub-M>' as implied by variance annotation.
      The types returned by 'receive()' are incompatible between these types.
        Type 'super-M | undefined' is not assignable to type 'sub-M | undefined'.
          Type 'super-M' is not assignable to type 'sub-M | undefined'.

    Ошибка стоит на самой аннотации, третья строка, двадцатый столбец: in обещает, что тип только берёт M, а receive его отдаёт. Загадочные super-M и sub-M разберём в «Под капотом».

  2. Компилятор быстрее. Ему не нужно выяснять вариантность опытом, о котором ниже: направление ты указал сам. На больших рекурсивных типах, вроде тех, из которых собран Effect, это заметно.

  3. Читатель видит договор. Ты открыл объявление Effect, увидел out E и знаешь, куда пойдёт подстановка, не разбирая тело интерфейса.

Аннотация может сделать тип строже, чем вышло бы из его устройства, но не свободнее: out у того, кто берёт, компилятор не пропустит. Строже и нужно у Slot: без in out компилятор пропустил бы Slot<Ping> вместо Slot<Message>, почему, увидишь ниже.

Попробуй сам. Ниже три коробки с аннотациями: «только отдаёт», «только берёт» и коробка, из которой можно и достать, и положить. Подставь Ping вместо Message и обратно и посмотри, где подстановка разрешена и что сломалось бы там, где нет.

Под капотом

Когда компилятору впервые нужно сравнить Receiver<X> с Receiver<Y>, он берёт два служебных типа-метки, sub-M и super-M, и договаривается, что первый подходит вместо второго. Потом подставляет их в Receiver и структурно проверяет обе стороны: проходит ли Receiver<sub-M> вместо Receiver<super-M> и наоборот. Прошло в ту же сторону, значит ковариантен; в обратную, контравариантен; в обе, бивариантен; ни в одну, инвариантен. Ответ запоминается на весь тип, и дальше Receiver<Ping> против Receiver<Message> сводится к сравнению Ping с Message. Аннотация проверяется тем же опытом, поэтому в ошибке TS2636 и видны метки.

У опыта есть две слабости. Первая: изменяемые поля. Поле value: M и читают, и пишут, честный ответ здесь «инвариантен». Но TypeScript сравнивает свойства объектов по типу чтения и пропускает { value: Ping } вместо { value: Message }. Отсюда in out у Slot.

Вторая слабость: методы. Запиши отправителя не свойством, а методом:

import { type Message, type Text, ping } from './messages';

type Sender<M> = { send(message: M): void };

const shout: Sender<Text> = {
  send: (message) => console.log(message.body.toUpperCase()),
};

const broadcast: Sender<Message> = shout;
broadcast.send(ping);

Компилятор молчит, а в рантайме:

TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Флаг strictFunctionTypes из strict проверяет параметры функций контравариантно, но только у свойств с функциональным типом. Параметры методов он сравнивает в обе стороны. Это сделано нарочно, в TypeScript 2.6: у Array<T> есть методы push и indexOf, которые берут T. Будь их параметры строгими, Ping[] перестал бы подходить вместо Message[], и сломался бы почти любой код с массивами. Поэтому в шифре отправитель записан свойством, readonly send: (message: M) => void, а не методом, и помечен in.

Где тут дыра

import { type Message, type Ping, ping, text } from './messages';

const pings: Ping[] = [ping, ping];

const count = (queue: Message[]): number => queue.length;
const urgent = (queue: Message[]): void => {
  queue.unshift(text('гроза у моста'));
};

count(pings);
urgent(pings);
const first = pings[0];
if (first !== undefined) console.log(first._tag);

На count у Ринцвальда всё работает: функция только читает, а чтение ковариантно. Соседняя функция urgent вставляет в очередь срочный текст. Компилятор пропускает и её, а последняя строка печатает Text из массива, который по типу содержит одни пинги. Массив и берёт, и отдаёт, честно он инвариантен. TypeScript считает его ковариантным ради удобства, а unshift это метод, и его параметр проверяется в обе стороны.

Заплатка: там, где функция только читает, бери readonly Message[], а данные, которые должны остаться пингами, держи как readonly Ping[]:

import { type Message, type Ping, ping, text } from './messages';

const pings: readonly Ping[] = [ping, ping];

const count = (queue: readonly Message[]): number => queue.length;
const urgent = (queue: Message[]): void => {
  queue.unshift(text('гроза у моста'));
};

count(pings);
urgent(pings);
arr-fix.ts(11,8): error TS2345: Argument of type 'readonly Ping[]' is not assignable to parameter of type 'Message[]'.
  The type 'readonly Ping[]' is 'readonly' and cannot be assigned to the mutable type 'Message[]'.

У readonly массива нет unshift, он только отдаёт, и ковариантность у него честная. count принимает его без вопросов, а urgent получает «Ук».

Шифр, этап 8

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

Практика

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

Что значит «система типов честна» строго, разбирает урок «Безопасность: продвижение и сохранение типа» из курса логики. Массив Ринцвальда нарушает ровно второе обещание: значение в рантайме перестало быть того типа, который у него в коде. А как устроен тип функции, у которой вход и выход, показывает урок «Простое типизированное λ-исчисление».

Дальше

Ночью башня T-1 стоит закрытой: ставни на засове, в журнале ни одного кадра. А утром на проводе Магистрали лежит кадр с подписью T-1, и соседняя башня уже ответила на него подтверждением. Кто-то говорит голосом Академии, и твой протокол ему не мешает: 'T-1' может написать от руки кто угодно. В следующем уроке делаем номера, которые нельзя подделать.