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

Континуации: колбэк, доведённый до абстракции

senior~60 мин

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

Континуации: колбэк, доведённый до абстракции

Ты пишешь континуации столько же, сколько пишешь на JavaScript. Node-колбэк (err, data) => ..., аргумент resolve в конструкторе Promise, обработчик в addEventListener, всё это одна и та же идея: вместо возврата значения передай его в функцию “что делать дальше”. В уроке про IO-монаду эта функция жила в поле next каждой инструкции, а в Части 12 промелькнул Codensity transform: “перепиши монаду через CPS”. Сегодня разберём сам CPS до основания и получим неожиданный приз: бизнес-логику, которая написана один раз и работает синхронно, с ошибками, асинхронно, и даже во всех вариантах сразу.

План: одна задача в трёх копиях, тип Continuation<T, A> в одну строку, модуль из трёх функций, и раннеры, которые решают, чем была программа. В конце, результат Филинского: этого типа достаточно, чтобы выразить любую монаду.


Часть 0. Разминка: три копии одной функции

Задача: есть список ключей, по каждому нужно сходить за значением и вернуть длины найденных строк. Хранилище впрыснуто снаружи функцией fetch, как World в IO-монаде. Пишем в лоб:

export const simpleTask = (fetch: (key: string) => string): number[] =>
  ['a', 'b'].map((key) => fetch(key.toUpperCase()).length);

Сигнатура (key: string) => string обещает, что значение всегда есть. В реальном хранилище ключ может отсутствовать, и появляется вторая версия, с Result из урока про типы и ошибки:

export type Result<A, E> =
  | { readonly ok: true; readonly value: A }
  | { readonly ok: false; readonly error: E };

export const fallibleTask = (
  fetch: (key: string) => Result<string, string>,
): Result<number[], string> => {
  const lengths: number[] = [];
  for (const key of ['a', 'b']) {
    const found = fetch(key.toUpperCase());
    if (!found.ok) return found;
    lengths.push(found.value.length);
  }
  return { ok: true, value: lengths };
};

А если хранилище на другом конце сети, значение придёт через Promise, и появляется третья:

export const asyncTask = async (
  fetch: (key: string) => Promise<string>,
): Promise<number[]> => {
  const values = await Promise.all(
    ['a', 'b'].map((key) => fetch(key.toUpperCase())),
  );
  return values.map((value) => value.length);
};

Присмотрись: бизнес-логика во всех трёх одна и та же. Ключи, uppercase, fetch, length. Меняется только обвязка: map против цикла с ранним return, против Promise.all. Даже форма кода разъехалась: во второй версии пришлось перейти на императивный цикл, потому что из map не сделаешь досрочный выход.

Вопрос: что делать, когда хранилище медленное и ключа может не быть? Правильно, четвёртая копия с Promise<Result<string, string>>. Каждый новый эффект удваивает число версий, а логика в них не меняется.


Часть 1. Тип, который ничего не обещает

Result говорит: значение либо есть, либо ошибка. Promise говорит: значение будет позже. Континуация не обещает ничего:

export type Continuation<T, A> = (next: (value: A) => T) => T;

Читаем изнутри наружу. (value: A) => T это колбэк-продолжение: получит значение A, доведёт вычисление до конца и вернёт финальный результат T. Вся континуация, функция, которая принимает такой колбэк и возвращает T. Смысл: я не знаю, как ты получишь значение, но когда получишь, вот что с ним делать дальше.

Два параметра типа делят обязанности. A, полезное значение (строка из хранилища). T, детали вычисления, о которых логика ничего не знает: чем окажется T, решит вызывающая сторона. Голым number[], Result<number[], string> или Promise<number[]>.

Этот стиль называется CPS, и в JS он повсюду, просто без имени:

// Node error-first: continuation принимает (err, data)
fs.readFile(path, (err, data) => { ... });

// Конструктор Promise: resolve это continuation
new Promise((resolve) => setTimeout(() => resolve(42), 100));

// then: регистрируешь continuation на будущее значение
promise.then((value) => { ... });

Разница с нашим типом только в дисциплине: у Node-колбэка два канала в одной функции, у Promise продолжение спрятано внутри объекта. Continuation<T, A> это тот же колбэк-стиль, но с честным типом и без привязки к конкретному рантайму.


Часть 2. pure и bind: монада в шесть строк

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

export const pure =
  <T, A>(value: A): Continuation<T, A> =>
  (next) =>
    next(value);

export const bind =
  <T, A, B>(
    first: Continuation<T, A>,
    step: (value: A) => Continuation<T, B>,
  ): Continuation<T, B> =>
  (next) =>
    first((value) => step(value)(next));

pure немедленно вызывает продолжение с готовым значением. bind возвращает новую континуацию: когда её запустят с финальным продолжением next, она запустит first, а продолжением для него сделает функцию, которая передаст значение в step и запустит результат с тем же next. Рисуй стрелки, они короче слов.

Это ровно те же pure и bind, что ты писал для IO-монады, с одной разницей: у IO bind перестраивал дерево инструкций, здесь дерева нет. Вычисление и есть функция, bind просто вкладывает колбэки. И заметь: цепочка bind ассоциируется вправо сама собой, по построению. Та самая причина, по которой Codensity transform из Части 12 воркшопа лечит квадратичную сложность: он перегоняет дерево в этот вид.

Ловушка из продакшена: не называй bind словом then. Соблазн назвать склейку then велик (у Promise же then!), но ESM-модуль с export const then = ... сам становится thenable: import() такого модуля делает await, находит then, вызывает его как промисный и зависает. Vite, vitest и любой код с dynamic import ловят это на ровном месте. Поэтому bind.


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

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

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