Раздел 27 · Gleam на практике

Континуации: одна бизнес-логика, любые эффекты

senior~40 мин

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

Континуации: одна бизнес-логика, любые эффекты

Бонусная ката после DDD-серии. В уроках 16-33 мы отделяли домен от инфраструктуры через порты: логика не знает, где живут данные. Сегодня шаг глубже: логика не будет знать даже как данные приходят. Синхронно, с ошибкой, через Promise, всё это решит вызывающая сторона. Инструмент называется континуация, и весь его код помещается в двадцать строк.

Сцена · одна функция, три копии

Задача из практики: есть список ключей, по каждому надо сходить за значением и вернуть длины найденных строк. Бизнес-логика: нормализовать ключ, запросить, посчитать длину. Пишем:

import gleam/list
import gleam/string

pub fn simple_func(fetch: fn(String) -> String) -> List(Int) {
  let keys = ["a", "b"]
  list.map(keys, fn(key) {
    let key = string.uppercase(key)
    let value = fetch(key)
    string.length(value)
  })
}

fetch уже впрыснут снаружи, как порт из урока 22. Но подпись fn(String) -> String обещает, что значение всегда есть. В реальном хранилище ключа может не быть, и появляется вторая версия:

import gleam/result

pub fn fallible_func(
  fetch: fn(String) -> Result(String, Nil),
) -> Result(List(Int), Nil) {
  let keys = ["a", "b"]
  list.try_map(keys, fn(key) {
    let key = string.uppercase(key)
    use value <- result.map(fetch(key))
    string.length(value)
  })
}

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

import gleam/javascript/promise.{type Promise}

pub fn async_func(fetch: fn(String) -> Promise(String)) -> Promise(List(Int)) {
  let keys = ["a", "b"]
  list.map(keys, fn(key) {
    let key = string.uppercase(key)
    use value <- promise.map(fetch(key))
    string.length(value)
  })
  |> promise.await_list
}

Присмотрись: во всех трёх функциях бизнес-логика одна и та же. Ключи, uppercase, fetch, length. Меняется только обвязка: map против try_map, result.map против promise.map. А теперь вопрос на засыпку: что делать, когда хранилище медленное и ключа может не быть? Правильно, четвёртая копия с Promise(Result(String, Nil)). Каждый новый эффект удваивает число версий, а логика в них всё та же.

Карта урока · что заберёшь через полчаса

  • Прочитаешь тип Continuation(t, a) и поймёшь, почему в нём две стрелки.
  • Соберёшь модуль continuation: return, then, each, двадцать строк.
  • Напишешь task один раз и прогонишь её тремя раннерами: чистым, с Result, с Promise.
  • Увидишь, как ошибка обрывает вычисление без исключений: продолжение просто не вызывают.
  • Узнаешь результат Филинского 1994 года: континуации выражают любую монаду.

Концепт · тип, который ничего не обещает

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

// src/continuation.gleam

pub type Continuation(t, a) =
  fn(fn(a) -> t) -> t

Читаем изнутри наружу. fn(a) -> t это колбэк-продолжение: получит значение a, доведёт дело до конца и вернёт t. Вся континуация это функция, которая принимает такой колбэк и возвращает t. Два параметра типа делят обязанности: a это полезное значение (строка из хранилища), t это детали вычисления, о которых логика ничего не знает. Чем окажется t, решит вызывающая сторона: голым List(Int), Result(List(Int), Nil) или Promise(List(Int)).

Сравни с портами из DDD-серии: там мы прятали где живут данные, а тип эффекта (Result) всё равно торчал в сигнатуре. Континуация прячет и его.

Языковые механики · use это и есть CPS

В уроке 20 мы упоминали, что use это сахар над CPS-нотацией. Сегодня это не примечание, а фундамент. Строка:

use value <- continuation.then(fetch(key))
continuation.return(string.length(value))

разворачивается компилятором в:

continuation.then(fetch(key), fn(value) {
  continuation.return(string.length(value))
})

Всё, что стоит после use ... <-, становится колбэком, который передаётся последним аргументом. Поэтому наш then обязан принимать продолжение вторым параметром: тогда цепочка континуаций записывается плоско, без лесенки вложенных fn.

Задача · сигнатуры

Собери модуль src/continuation.gleam и одну функцию бизнес-логики:

pub fn return(value: a) -> Continuation(t, a)
pub fn then(first: Continuation(t, a), next: fn(a) -> Continuation(t, b)) -> Continuation(t, b)
pub fn each(items: List(a), run: fn(a) -> Continuation(t, b)) -> Continuation(t, List(b))

pub fn task(fetch: fn(String) -> Continuation(t, String)) -> Continuation(t, List(Int))

Контракт:

  • return заворачивает готовое значение: продолжение вызывается сразу.
  • then склеивает два шага: когда первый доставит значение, стартует второй.
  • each прогоняет каждый элемент списка через run и собирает результаты, сохраняя порядок.
  • В task не должно остаться ни Result, ни Promise, ни list.map: только continuation.*, uppercase и length.

Подсказки

  • return(value) это функция, которая берёт продолжение и немедленно вызывает его со значением. Одна строка.
  • then(first, next): верни новую континуацию, которая запускает first, а в качестве его продолжения подсовывает функцию, запускающую next(a) с внешним продолжением. Звучит как скороговорка, но кода две строки. Рисуй стрелки.
  • each пишется рекурсией по списку, как свёртки в уроке 5: пустой список это return([]), иначе use на голову, use на хвост, собрать cons.
  • Если типы не сходятся, не подгоняй аннотациями: выпиши на бумаге, чем обязан быть t в каждом месте. Обычно ошибка в том, что продолжение вызвано не с тем значением.

Разбор · двадцать строк модуля

// src/continuation.gleam

pub type Continuation(t, a) =
  fn(fn(a) -> t) -> t

pub fn return(value: a) -> Continuation(t, a) {
  fn(then) { then(value) }
}

pub fn then(
  first: Continuation(t, a),
  next: fn(a) -> Continuation(t, b),
) -> Continuation(t, b) {
  fn(k) { first(fn(a) { next(a)(k) }) }
}

pub fn each(
  items: List(a),
  run: fn(a) -> Continuation(t, b),
) -> Continuation(t, List(b)) {
  case items {
    [] -> return([])
    [first, ..rest] -> {
      use value <- then(run(first))
      use values <- then(each(rest, run))
      return([value, ..values])
    }
  }
}

return и then это та самая пара, которую ты уже видел у Result (Ok и result.try) и у Promise (promise.resolve и promise.await). Free-монада из урока про эффекты в Haskell строила такую же пару через дерево конструкторов; континуация обходится без дерева, само вычисление и есть функция.

each заменяет и list.map, и list.try_map, и promise.await_list разом: он не знает, придётся ли останавливаться на ошибке или ждать сеть, он просто склеивает шаги через then.

Теперь бизнес-логика. Один раз:

// src/kata.gleam
import continuation.{type Continuation as K}
import gleam/string

pub fn task(fetch: fn(String) -> K(t, String)) -> K(t, List(Int)) {
  let keys = ["a", "b"]
  continuation.each(keys, fn(key) {
    let key = string.uppercase(key)
    use value <- continuation.then(fetch(key))
    continuation.return(string.length(value))
  })
}

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

Разбор · раннеры решают, чем был t

task описывает вычисление, но не запускает его. Запускает раннер: он даёт конкретный fetch и финальное продолжение. Простейший, без всяких эффектов:

// test/kata_test.gleam
import continuation
import gleam/dict.{type Dict}
import gleam/javascript/promise
import kata.{task}

fn run_simple(task) -> List(Int) {
  let fetch = fn(_key) { continuation.return("yes") }
  task(fetch)(fn(x) { x })
}

pub fn run_simple_test() {
  let assert [3, 3] = run_simple(task)
}

task(fetch) возвращает континуацию, финальное продолжение fn(x) { x } говорит: с готовым списком ничего не делай. Здесь t унифицировался с List(Int).

Раннер с ошибками интереснее:

fn run_fallible(keys: Dict(String, String), task) -> Result(List(Int), Nil) {
  let fetch = fn(key) {
    fn(then) {
      case dict.get(keys, key) {
        Ok(value) -> then(value)
        Error(Nil) -> Error(Nil)
      }
    }
  }
  task(fetch)(Ok)
}

pub fn run_fallible_test() {
  let assert Error(Nil) = run_fallible(dict.from_list([#("A", "Apple")]), task)
  let assert Ok([5, 6]) =
    run_fallible(dict.from_list([#("A", "Apple"), #("B", "Banana")]), task)
}

Разгляди ветку Error: она возвращает Error(Nil) и не вызывает then. Всё, что стояло в очереди после этого fetch (остальные ключи, сборка списка), просто не выполнится, ведь остаток вычисления лежал в том колбэке. Это short-circuit без исключений и без специальной логики в each. Финальное продолжение здесь Ok: успешный список заворачивается в Result, и t унифицировался с Result(List(Int), Nil).

Асинхронный раннер фиксирует t в Promise(List(Int)):

fn run_async(task) {
  let fetch = fn(_key) {
    fn(then) {
      use Nil <- promise.await(promise.wait(100))
      then("slow")
    }
  }
  task(fetch)(promise.resolve)
}

pub fn run_async_test() {
  use v <- promise.await(task |> run_async())
  let assert [4, 4] = v
  promise.resolve(Nil)
}

fetch ждёт сто миллисекунд и лишь потом зовёт продолжение. task об ожидании не подозревает: с её точки зрения значение просто пришло. Гоняем всё вместе на JS-таргете:

gleam test --target javascript

Три раннера, одна task, все тесты зелёные. А комбинация из вопроса на засыпку (медленно и может не быть) это ещё один раннер с t = Promise(Result(List(Int), Nil)), без единой правки в task. Она ждёт тебя в ДЗ.

Концепт · Филинский и мать всех монад

То, что мы построили, не трюк из блога. Андржей Филинский в работе Representing Monads (POPL 1994) доказал: континуаций достаточно, чтобы выразить любую монаду. Result, Promise, состояние, недетерминизм, что угодно с парой return и then кодируется через Continuation, поэтому её называют матерью всех монад. Наши раннеры это конструктивная иллюстрация: один и тот же task мы интерпретировали в трёх монадах, меняя только fetch и финальное продолжение.

Ты уже встречал два других способа добиться того же разделения описания и исполнения. Free-монада из эффектов в Haskell записывает программу деревом конструкторов, а интерпретатор его обходит. Effect.ts из раздела 25 делает это промышленно: Effect<A, E, R> тоже описание, которое запускает рантайм. Эффект-хендлеры из обзора эффект-систем достигают того же захватом продолжения рантаймом языка. Континуации в этом ряду самый лёгкий вариант: ни дерева, ни рантайма, ни поддержки в языке, одна функция высшего порядка.

И бонус, которого нет у эффект-хендлеров OCaml с их one-shot ограничением: наше продолжение это чистая функция. Раннер вправе вызвать его ноль раз (short-circuit), один раз (обычный путь) или много раз. Вызови с двумя вариантами значения и собери оба исхода: получишь недетерминизм, вычисление со всеми комбинациями ответов. Это тоже ДЗ.

Критика · что не покрыто

  • Ошибки типов страшные. Пока типы сходятся, всё чудесно. Когда не сходятся, компилятор говорит про несовпадение fn(fn(a) -> t) -> t где-то в глубине, и новичку больно. Это цена абстракции: у midas (продакшен-библиотека Питера Сакстона, где живёт этот тип) те же сообщения.
  • each не хвосторекурсивен. На списке из тысяч элементов наш учебный each съест стек. Продакшен-версия пишется через аккумулятор с разворотом, мы пожертвовали этим ради читаемости.
  • Читаемость для команды. Код на континуациях требует, чтобы каждый в команде удерживал в голове две стрелки типа. Порог входа реальный; Result в сигнатуре понимают все, Continuation придётся объяснять. Взвешивай.
  • Стектрейсы. Ошибка внутри цепочки колбэков даёт след из анонимных функций. У Effect.ts на это есть спаны и своя трассировка, у голых континуаций нет ничего.
  • Мы не кодировали состояние. Филинский обещает и его, но раннер со state требует протащить значение сквозь t, и это отдельная головоломка. Захочешь размяться, начни с t = fn(Int) -> #(Int, r).

Takeaway

Одна фраза:

Континуация fn(fn(a) -> t) -> t говорит: не знаю, как ты добудешь значение, но вот что делать дальше. Бизнес-логика пишется один раз поверх return, then, each, а раннер решает, чем окажется t: списком, Result, Promise или их комбинацией. Не вызвал продолжение, получил short-circuit; вызвал дважды, получил недетерминизм.

ДЗ

Дальше

Серия Gleam на этом закрыта по-настоящему: от gleam new через DDD до матери всех монад. Куда идти за продолжением темы эффектов:

  • Континуации на TypeScript: та же ката в JS-мире, где CPS это родной диалект (колбэки, resolve, thenable-ловушки), плюс раннер недетерминизма и мост к async/await.

  • Эффекты и type-level в Haskell: free-монада строит то же разделение через дерево конструкторов, и там же обсуждаются one-shot продолжения OCaml.

  • Эффект-системы: большая карта подходов, от Koka до Effect.ts, теперь у тебя есть личный опыт с самым лёгким из них.

  • Effect.ts: промышленная версия сегодняшней идеи, где у описания-вычисления есть рантайм, спаны, ресурсы и планировщик.