Раздел 2 · Программирование / CS

Typeclass-дисциплина: Functor, Applicative, Monad, HKT в TS

middle-senior~35 мин

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

Typeclass-дисциплина: Functor, Applicative, Monad, HKT в TS

Урок 06 закончил тем, что мы написали map и flatMap для Option, Either и Validation. Если положить эти три реализации рядом, видно, что код почти один и тот же: map берёт функцию и применяет внутри контейнера, flatMap цепляет функцию, которая сама возвращает контейнер.

Этот шаблон не случаен. У Array.map, Promise.then, Option.map, Either.map и десятка других объектов одна форма. Сегодня мы дадим этой форме имя, увидим, как она называется в фп-литературе, и разберём, какие гарантии она несёт. После урока ты прочитаешь существующий воркшоп fp-ts-car-valuation без шифровки: там ровно те же буквы, только в боевом коде.

Сначала закономерность, потом название

Смотри на параллельные реализации:

// Array
const mapArr = <A, B>(xs: A[], f: (a: A) => B): B[] => xs.map(f);

// Option
const mapOpt = <A, B>(o: Option<A>, f: (a: A) => B): Option<B> =>
  o.kind === 'some' ? some(f(o.value)) : o;

// Either
const mapEither = <E, A, B>(e: Either<E, A>, f: (a: A) => B): Either<E, B> =>
  e.kind === 'right' ? right(f(e.value)) : e;

// Promise
const mapPromise = <A, B>(p: Promise<A>, f: (a: A) => B): Promise<B> =>
  p.then(f);

Подпись у всех одинаковая: F<A> -> (A -> B) -> F<B>. Контейнер F, разный, но идея одна: достать значения, применить функцию, положить обратно в ту же форму. Это и есть Functor: любой F<A>, у которого есть map, удовлетворяющий двум законам.

// общий контракт
interface Functor<F> {
  readonly map: <A, B>(fa: F<A>, f: (a: A) => B) => F<B>;
}

Запись F<A> тут проблематична: TypeScript не умеет принимать конструктор типа как параметр. К этому ограничению вернёмся через несколько секций. Сначала понятийная часть.

Functor laws

У map ровно два правила. Они звучат тривиально, но именно благодаря им мы можем рефакторить код без страха.

// 1. identity: маппинг тождества ничего не меняет
map(fa, (x) => x) === fa;

// 2. composition: композиция двух map-ов это один map от композиции
map(map(fa, f), g) === map(fa, (x) => g(f(x)));

Identity говорит: map не имеет права что-то сам по себе менять. Если кладёшь (x) => x, должен получить ровно то, что было.

Composition говорит: map(map(fa, f), g) и map(fa, (x) => g(f(x))) дают одинаковый результат. Это позволяет компилятору и тебе самому оптимизировать pipeline без последствий.

Прокол законов означает, что под map спрятался side effect. Например, “Functor”, который при каждом map ещё пишет в лог, identity-закон нарушит. Это плохой Functor. В fp-ts плохих не бывает: имплементации проверены математически.

Applicative: применить функцию, спрятанную в контейнере

Functor умеет применять функцию (A) -> B. А что если функция тоже лежит в контейнере, в F<(A) -> B>? Это случается чаще, чем кажется: функция получается как результат другого вычисления, которое могло не сработать.

Пример из формы регистрации (урок 06): три парсера, каждый возвращает Either<string, X>. Хотим объединить три значения в одно, применив конструктор (name, dob, ssn) => RegistrationForm. У нас три “обёрнутых” значения и одна “обычная” функция трёх аргументов. Нужно “положить” каждое значение под функцию.

Applicative добавляет к Functor две операции:

interface Applicative<F> extends Functor<F> {
  // поднять обычное значение в контейнер
  readonly of: <A>(a: A) => F<A>;
  // применить функцию-в-контейнере к значению-в-контейнере
  readonly ap: <A, B>(ff: F<(a: A) => B>, fa: F<A>) => F<B>;
}

Конкретно для Either:

const ofEither = <A>(a: A): Either<never, A> => right(a);

const apEither = <E, A, B>(
  ff: Either<E, (a: A) => B>,
  fa: Either<E, A>,
): Either<E, B> =>
  ff.kind === 'left' ? ff : fa.kind === 'left' ? fa : right(ff.value(fa.value));

Поверх ap собирается универсальный liftN, поднимающий функцию n аргументов в контейнер:

const lift2 =
  <F>(F: Applicative<F>) =>
  <A, B, C>(f: (a: A, b: B) => C) =>
  (fa: F<A>, fb: F<B>): F<C> =>
    F.ap(F.map(fa, (a) => (b: B) => f(a, b)), fb);

Самый интересный момент здесь, выбор поведения при ошибке. Для Either ap останавливается на первой left (fail-fast). Для Validation ap копит ошибки (accumulate). Структурно это разные Applicative, формально, разные ap.

Помнишь два режима из виджета <ParseDontValidate /> в уроке 06? Fail-fast и accumulate, это и есть выбор Applicative-инстанса. Один тип Either, один Validation. Структурно они одинаковые, но алгебра разная.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.