Континуации: одна бизнес-логика, любые эффекты
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Континуации: одна бизнес-логика, любые эффекты
Бонусная ката после 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: промышленная версия сегодняшней идеи, где у описания-вычисления есть рантайм, спаны, ресурсы и планировщик.