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

Назвать узор: функтор и монада

middle-senior~45 мин

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

За три урока котёл обзавёлся двумя коробками. В уроке 24 Option научился быть пустым и не открываться зря, в уроке 25 Result пустил ошибку по второму пути, в уроке 26 ошибки научились копиться. У обеих коробок есть map и flatMap, и написаны они почти одинаково. У массива, с которым ты работаешь каждый день, тоже есть map и flatMap.

Матушка спрашивает не про имя, а про доверие. Тест с тремя примерами говорит, что map верен на трёх примерах. Этот урок о том, какие обещания должен держать честный map, как эти обещания записать и как заставить компьютер проверить их на сотнях примеров, которых ты не придумал. Имя узору дадим в конце, когда станет понятно, что за ним стоит.

Три коробки, один узор

Положи рядом три сигнатуры:

import type * as Option from './Option';
import type * as Result from './Result';

type OptionMap = <A, B>(self: Option.Option<A>, f: (a: A) => B) => Option.Option<B>;
type ResultMap = <A, E, B>(self: Result.Result<A, E>, f: (a: A) => B) => Result.Result<B, E>;
type ArrayMap = <A, B>(self: readonly A[], f: (a: A) => B) => readonly B[];

Читай их, как учил Библиотекарь в уроке 09: что приходит, что уходит, что остаётся. Приходит коробка с A внутри и функция из A в B. Уходит та же коробка, но уже с B. Коробка не меняется: Option остаётся Option, у Result даже ошибка E та же самая, массив остаётся массивом той же длины. Меняется только содержимое, и меняет его только f.

Если назвать коробку буквой F, все три сигнатуры станут одной: из F<A> и A => B получается F<B>. В TypeScript так прямо не написать, параметр типа не может сам быть шаблоном вроде Option. Обход есть, и мы его соберём, когда у узора появится имя. Пока хватит того, что узор виден глазами.

Честный map

Что может пойти не так у функции, которая всего лишь применяет f к содержимому? Многое. Вот map для массива, который заодно выкидывает пустые строки, «чтобы не мусорить»:

const map = <A, B>(self: readonly A[], f: (a: A) => B): readonly B[] =>
  self.filter((item) => item !== '').map((item) => f(item));

console.log(map(['гроза', 'туман'], (s) => s.length));
console.log(map(['', 'гроза'], (s) => s));
[ 5, 5 ]
[ 'гроза' ]

На первом примере всё верно. На втором map с функцией, которая ничего не делает, вернул не то, что ему дали. Это и есть первое обещание честного map: если f ничего не меняет, то и map ничего не меняет.

map(box, x => x)  равно  box

Второе обещание про два шага подряд. Сначала прибавить единицу, потом удвоить, или сразу сделать «прибавить и удвоить» одной функцией: ответ должен совпасть.

pipe(box, map(f), map(g))  равно  map(box, x => g(f(x)))

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

Коробку, у которой есть map с этими двумя законами, называют функтором. Вот и первое имя: функтор это коробка с честным map. Option, Result и массив из сигнатур выше это функторы. А map с фильтром пустых строк делает массив чем-то другим, и верить ему нельзя.

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

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

import * as Option from './Option';

const half = (n: number): Option.Option<number> =>
  n % 2 === 0 ? Option.some(n / 2) : Option.none();

const quarter: Option.Option<number> = Option.map(Option.some(8), half);

Перевод с Ук

boxbox.ts(6,7): error TS2322: Type 'Option<Option<number>>' is not assignable to type 'Option<number>'.
  Type 'Some<Option<number>>' is not assignable to type 'Option<number>'.
    Type 'Some<Option<number>>' is not assignable to type 'Some<number>'.
      Type 'Option<number>' is not assignable to type 'number'.
        Type 'None' is not assignable to type 'number'.

Читаем снизу вверх. Последняя строка: None не помещается в number. Строкой выше: целый Option<number> не помещается в number. Ещё выше компилятор сравнивает Some<Option<number>> с Some<number> и находит, что у первой внутри не число, а снова коробка. Верхняя строка собирает всё: справа от = стоит Option<Option<number>>, коробка в коробке. map честно положил результат half в коробку, а результат half сам был коробкой.

Для этого у тебя есть flatMap: он применяет шаг, который возвращает коробку, и не заворачивает его ответ ещё раз. Для flatMap тоже есть законы, их три. Нужен ещё конструктор, который кладёт значение в коробку без лишнего: у Option это some, у Result это succeed.

flatMap(some(a), f)  равно  f(a)                         конструктор слева ничего не добавляет
flatMap(box, some)  равно  box                           конструктор справа ничего не добавляет
pipe(box, flatMap(f), flatMap(g))
  равно  flatMap(box, x => flatMap(f(x), g))             скобки не важны

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

Коробку с конструктором и flatMap, которые подчиняются этим трём законам, называют монадой. Вот и второе имя, то самое страшное слово. Монада это коробка с честным конструктором и flatMap без коробки в коробке. Больше в этом слове ничего нет.

Цена скобок

Третий закон обещает одинаковый ответ, но не одинаковую цену. Это видно на коробке, внутри которой лежит не значение, а рецепт действий: такую мы соберём сами в разделе про Гекс. В виджете ниже она называется IO, а flatMap по старой традиции зовётся bind. Сравни две группировки одной и той же цепочки: в левой каждый новый шаг идёт от корня по всем прошлым, в правой встаёт сверху.

Как растёт IO-дерево
lambdaзамыкание, runIO ещё не дёрнул
f
дерево пустое: только Pure

Ответ у обеих форм один, закон это гарантирует. А шагов у левой формы растёт как квадрат длины цепочки. Раз ответ от группировки не зависит, исполнитель цепочки вправе обойти её так, как ему дешевле, а ты пишешь так, как удобнее читать. Без закона такая свобода была бы опасной. Как исполнитель обходит цепочку, увидишь, когда соберёшь свой Гекс.

Одна буква на все коробки

Начнём с того, что хочется написать:

interface Functor<F> {
  readonly map: <A, B>(self: F<A>, f: (a: A) => B) => F<B>;
}

Перевод с Ук

functor.ts(2,30): error TS2315: Type 'F' is not generic.
functor.ts(2,55): error TS2315: Type 'F' is not generic.

Ошибка одна, но стоит на двух местах: колонка 30 это F<A>, колонка 55 это F<B>. «Тип F не обобщённый»: параметр типа в TypeScript пробегает только по готовым типам вроде number или Option<string>. А Option без угловых скобок это не тип, а конструктор типа, и передать его параметром нельзя. Приписывать скобки к F не к чему.

Обход такой: передавать не конструктор, а интерфейс, который его описывает. Слот Target держит то, что положат в коробку, а слот type говорит, какая коробка получится:

import * as Option from './Option';

interface TypeLambda {
  readonly Target: unknown;
  readonly type: unknown;
}

type Kind<F extends TypeLambda, A> = (F & { readonly Target: A })['type'];

interface OptionLambda extends TypeLambda {
  readonly type: Option.Option<this['Target']>;
}

interface ArrayLambda extends TypeLambda {
  readonly type: readonly this['Target'][];
}

Kind пересекает лямбду с объектом, где в Target уже лежит A, и читает type. Внутри пересечения this['Target'] смотрит на этот A, поэтому Kind<OptionLambda, number> разворачивается в Option<number>. Буква F стала обычным параметром типа: в неё передают не Option, а OptionLambda.

Теперь сигнатура из начала урока пишется один раз:

interface Functor<F extends TypeLambda> {
  readonly map: <A, B>(self: Kind<F, A>, f: (a: A) => B) => Kind<F, B>;
}

const OptionFunctor: Functor<OptionLambda> = { map: Option.map };
const ArrayFunctor: Functor<ArrayLambda> = { map: (self, f) => self.map((a) => f(a)) };

const label =
  <F extends TypeLambda>(F: Functor<F>) =>
  (box: Kind<F, number>): Kind<F, string> =>
    F.map(box, (frame) => `кадр ${frame}`);

console.log(label(OptionFunctor)(Option.some(7)));
console.log(label(ArrayFunctor)([1, 2]));
{ _tag: 'Some', value: 'кадр 7' }
[ 'кадр 1', 'кадр 2' ]

label ничего не знает про Option и массив, только то, что у коробки есть map. Оба инстанса компилятор проверил без единого as: Option.map котла совпал с сигнатурой, развёрнутой для Option. У Result параметров два, и ошибку лямбда берёт своим параметром: ResultLambda<E> с type: Result.Result<this['Target'], E>.

Возможность абстрагироваться над конструктором типа называют HKT. В Effect она устроена так же: effect/HKT экспортирует TypeLambda и Kind, только слотов у лямбды четыре, чтобы поместились ещё ошибка и требования. Ссылка на разбор в конце урока.

А на кухне?

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

type Shift = 'утро' | 'день' | 'ночь';
type Watch<S> = readonly [opened: S, closed: S];

declare const shift: Shift;

const watch = [shift, shift] as const;
// readonly [Shift, Shift]

Обе отметки взяты из одной переменной, но тип кортежа помнит каждый слот отдельно. readonly [Shift, Shift] пускает и ['утро', 'ночь']: смену открыло утро, а закрыла ночь. Нужен не кортеж из объединений, а объединение кортежей: Watch<'утро'> | Watch<'день'> | Watch<'ночь'>.

Раскладывать объединение по членам умеет условный тип из урока 11: S extends unknown ? Watch<S> : never. Его можно вписать в возвращаемый тип одной функции, но тогда в каждой такой функции появятся свой условный тип и свой каст. Отличается в них только конструктор Watch. Значит, его можно передать параметром, той же лямбдой, только над значением:

interface Lambda {
  readonly In: unknown;
  readonly Out: unknown;
}

type Apply<L extends Lambda, V> = (L & { readonly In: V })['Out'];
type Distribute<L extends Lambda, V> = V extends unknown ? Apply<L, V> : never;

const distribute =
  <L extends Lambda>(make: (value: L['In']) => Apply<L, L['In']>) =>
  <V extends L['In']>(value: V) =>
    make(value) as Distribute<L, V>;

Apply это Kind с другими именами слотов, а Distribute разносит его по членам объединения. distribute пишется один раз на весь котёл, и каст живёт только в нём. Для смены нужна одна лямбда:

interface AsWatch extends Lambda {
  readonly In: Shift;
  readonly Out: Watch<this['In']>;
}

const watchOf = distribute<AsWatch>((s) => [s, s] as const);

const honest = watchOf(shift);
// Watch<'утро'> | Watch<'день'> | Watch<'ночь'>

Теперь ['утро', 'ночь'] в honest не положить, а watchOf('полдник') не скомпилируется: допустимый вход лямбда держит в In. Сам make компилятор сверяет только с Watch<Shift>, где член уже не виден, поэтому стрелка (s) => [s, 'ночь'] as const тоже пройдёт. Связь слотов ты записал один раз, в AsWatch, и держишь её сам, как держишь законы map.

Это не учебная выдумка. В Effect такая лямбда называется Struct.Lambda, а в демо next-midi-demo из репозитория effect-garden тем же приёмом оба элемента перехода между фрагментами воспроизведения строятся из одного объединения таймингов и не путаются друг с другом. Библиотек можно и не писать, а функция над типами всё равно пригодится.

Четвёртая коробка: журнал

Это и есть четвёртая коробка котла. В ней лежит значение и список заметок о том, как его посчитали:

import { pipe } from './Function';
import * as Logged from './Logged';

const verdict = pipe(
  Logged.make(3, 'без ответа: 3 кадра'),
  Logged.flatMap((waiting) => Logged.make(waiting > 2, 'порог: больше двух')),
  Logged.map((alarm) => (alarm ? 'тревога' : 'тихо')),
);

console.log(verdict);
{
  value: 'тревога',
  log: [ 'без ответа: 3 кадра', 'порог: больше двух' ]
}

map меняет значение и не трогает заметки. flatMap берёт следующий шаг вместе с его заметками и дописывает их после заметок первого шага. Никакой консоли: вычисление остаётся вычислением из урока 17, а журнал стал частью ответа. Честный ли тут map и честный ли flatMap? По трём примерам не скажешь. По законам скажешь.

Законы как тесты

Закон говорит «для любых значений». Тест с примерами проверяет три значения. Между ними пропасть, и закрывает её тест на свойство: ты описываешь, какие бывают входы, а библиотека сама придумывает их сотнями. В JavaScript для этого есть fast-check. Вот все законы для Option из этапа 8:

import fc from 'fast-check';
import { expect, it } from 'vitest';

import { pipe } from './src/Function';
import * as Option from './src/Option';

const anyBox = fc.option(fc.integer(), { nil: undefined }).map(Option.fromNullable);
const inc = (n: number) => n + 1;
const double = (n: number) => n * 2;
const half = (n: number) => (n % 2 === 0 ? Option.some(n / 2) : Option.none());
const positive = (n: number) => (n > 0 ? Option.some(n) : Option.none());

it('map(x => x) ничего не меняет', () => {
  fc.assert(
    fc.property(anyBox, (box) => {
      expect(Option.map(box, (x) => x)).toEqual(box);
    }),
  );
});

it('два map подряд равны одному map с композицией', () => {
  fc.assert(
    fc.property(anyBox, (box) => {
      expect(pipe(box, Option.map(inc), Option.map(double))).toEqual(
        Option.map(box, (x) => double(inc(x))),
      );
    }),
  );
});

it('some слева ничего не добавляет', () => {
  fc.assert(
    fc.property(fc.integer(), (n) => {
      expect(Option.flatMap(Option.some(n), half)).toEqual(half(n));
    }),
  );
});

it('some справа ничего не добавляет', () => {
  fc.assert(
    fc.property(anyBox, (box) => {
      expect(Option.flatMap(box, Option.some)).toEqual(box);
    }),
  );
});

it('скобки в цепочке flatMap не важны', () => {
  fc.assert(
    fc.property(anyBox, (box) => {
      expect(pipe(box, Option.flatMap(half), Option.flatMap(positive))).toEqual(
        Option.flatMap(box, (x) => Option.flatMap(half(x), positive)),
      );
    }),
  );
});

fc.integer() это источник случайных целых чисел, в fast-check такой источник зовётся arbitrary. fc.option(..., { nil: undefined }) иногда вместо числа даёт undefined, а .map(Option.fromNullable) превращает всё это в коробки: и полные, и пустые. Заметь, у источника тоже есть map, и он тоже честный. fc.property связывает источник с проверкой, fc.assert прогоняет проверку на сотне входов и падает на первом, где она не выполнилась.

Тело проверки стоит в фигурных скобках не случайно. fast-check считает свойство выполненным, если проверка вернула true или ничего. Стрелка без скобок вернула бы то, что вернул expect(...).toEqual(...), и в настоящем vitest это не undefined.

Скрытые тесты этапа 11 проверяют те же законы на Option и Result из этапов 8 и 9 и на новой коробке Logged. Если map котла нечестный, ты узнаешь об этом не от Матушки, а от контрпримера.

Под капотом

const f = (frame: number) => Promise.resolve(frame + 1);
const g = (value: unknown) => typeof value;

const main = async () => {
  const twoSteps = await Promise.resolve(40).then(f).then(g);
  const oneStep = await Promise.resolve(40).then((frame) => g(f(frame)));
  console.log(twoSteps, oneStep);
};
void main();
number object

Два шага подряд дали 'number', один шаг с композицией дал 'object'. Закон нарушен, и вот почему. Когда then получает от функции промис, он не кладёт его в промис, а ждёт и разворачивает. Promise<Promise<number>> в JavaScript не бывает. Поэтому then работает то как map, то как flatMap, смотря что вернула функция, и решает это в рантайме по наличию метода then у результата. В первой строке f вернула промис, и следующий then получил уже число. Во второй промис из f попал в g как есть.

Ничего страшного, пока ты об этом помнишь. Но рефакторинг «склей два then в один» у промиса меняет ответ, а у честного функтора нет. В Effect, как и в котле, map и flatMap это разные функции, и решать за тебя, какая из них нужна, никто не станет.

Где тут дыра

const map = <A, B>(self: Logged.Logged<A>, f: (a: A) => B): Logged.Logged<B> => ({
  value: f(self.value),
  log: [...self.log, `посчитали ${String(self.value)}`],
});

console.log(map(Logged.make(2, 'кадр 2 отправлен'), (n) => n * 10));
{ value: 20, log: [ 'кадр 2 отправлен', 'посчитали 2' ] }

На его примере всё выглядит прилично. Прогони первый закон:

Error: Property failed after 1 tests
{ seed: 20261020, path: "0:0:0", endOnFailure: true }
Counterexample: [{"value":0,"log":[]}]
Shrunk 2 time(s)

Caused by: AssertionError: expected { value: +0, log: [ 'посчитали 0' ] } to deeply equal { value: +0, log: [] }

fast-check упал на первом же входе, а потом дважды упростил его, пока не нашёл самый маленький: значение 0 и пустой журнал. map с функцией, которая ничего не делает, дописал заметку. Это не мелочь. Теперь журнал зависит от того, как код разбит на шаги: склеишь два map в один, и заметок станет меньше, хотя посчитано то же самое. Смотритель, который сравнивает два журнала, увидит разницу там, где её нет. Журнал должен рассказывать о том, что посчитали, а не о том, как программист нарезал функции.

Больше значит громче. Заметки пишет тот шаг, которому есть что сказать, через make внутри flatMap. map молчит.

Котёл, этап 11

Котёл учится записывать заметки рядом с ответом. Ты напишешь модуль Logged с конструкторами of и make и честными map и flatMap, а в журнале башни функцию explain: сводку смены с заметкой на каждый кадр без ответа и на каждый призрак. Законы функтора и монады проверит fast-check, и не только для Logged: для Option и Result тоже.

Практика

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

Те же законы в языке, где они живут в системе типов, разбирают уроки «Функтор» и «Монада» курса Haskell. Лямбду Effect на четыре слота и одну функцию сразу над Effect и Stream разбирает урок «HKT и TypeLambda». Тесты на свойства шире, чем законы, и о них урок «Тесты на свойства и снимки».

Дальше

Матушка кивает: имена так имена, раз за ними стоят законы. Пондер тем временем выписал на доску разбор строки журнала из урока 25 и отступил на шаг.

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