Алгебраические эффекты на Javascript
- пятница, 14 марта 2025 г. в 00:00:09
Первоначально в этой статье я хотел рассказать об интересном подходе к построению программ, описанному в книге Sandy Maguire, Algebra-Driven Design. Подход позволяет строить программы на основе абстрактных математических структур и законов. Это позволяет разработать обобщенные подходы к их созданию и тестированию. Но потом я понял, что в этом мало смысла без объяснения, почему такой подход в принципе имеет право на существование. В книге для примеров используется Haskell - ленивый, чистый функциональный язык, имеющий мало отношения к языкам, которые широко применяются на практике. Распространено мнение, что приемы, используемые в Haskell, существуют в основном для преодоления его же недостатков и в других языках не нужны. Например, про монады пишут, что это оторванная от реальной жизни абстракция, которую не встретить в повседневной работе. Нет ничего более далекого от истины.
Монады - это не костыль, а мощная абстракция, которая позволяет выявить связь между такими непохожими языками, как C и Haskell, и свести к одному знаменателю такие далекие друг от друга концепции как асинхронные функции и глобальные переменные. Так что в этой статье я ограничусь описанием алгебраического программирования на примере монады. Я по шагам пройду путь от классической, сравнительно бесполезной в динамически типизированных языках монады, до Freer монады, которая обладает настолько удивительными свойствами, что может найти применение даже в императивном языке. Вы убедитесь, что алгебраический подход применим в обычных языках и дает превосходные результаты.
Для понимания алгебраического программирования не обойтись без этих сущностей. Монады имеют репутацию чего-то непонятного и нужного только высоколобым хаскелистам. Перед тем, как написать статью, я еще раз просмотрел обучающие пособия и, кажется, понял в чем проблема. Авторы пособий пытаются как можно больше облегчить понимание монад и начинают с самых простейших примеров - списка, Maybe и т.п. тривиальных монад. В результате создается впечатление, что монада - это такой странный тип данных с не менее странной операцией композиции. В результате непонятно, зачем это нужно, если есть столько других простых и полезных типов данных.
Мне кажется, это неверный путь, начинать надо с самого сложного. Монады по своей сути реализуют самое базовое пронятие программирования - вычисление (точнее вычислительный эффект, но в обычных языках чистые вычисления и эффекты так тесно перемешаны, что их трудно различить). Что такое вычисление - это функция. Т.е. монаду определенного типа нужно воспринимать как функцию реализующую определенный тип вычисления, а операцию композиции (bind), как связывание двух вычислений A и B, где B зависит от результата A.
Давайте попробуем понять монаду State в терминах вычислений. Итак, монада - это функция. Какие у нее должны быть аргументы и результат? Поскольку нас интересует состояние, то, видимо, State должна принимать, менять и возвращать состояние. Нужны ли еще аргументы? Вообще говоря нет, потому что в современных языках мы и так можем захватить их из контекста. Дополнительный результат же не помешает. Зафиксируем тип данных State:
type State { st: fn('s) -> ('s,'r) }
Здесь и далее для примеров я буду использовать упрощенный функциональный язык. Для удобства я буду иногда приводить типы аргументов: 's означает некий тип s. В конце статьи есть реализация на JavaScript, но для примеров он слишком многословен.
Далее нам нужны функции, чтобы связывать изменения состояния друг с другом. Напомню, что State уже описывает изменения состояния, но сами по себе изменения изолированы друг от друга. Для этого нужны монадические функции:
pure: 'x -> State('s,'x)
bind: fn(State('s,'x),fn('x) -> State('s,'y)) -> State('s,'y)
// или fn(state,fn(result)-> state)->state
pure (в Haskell она называется return, я решил использовать pure - чистый, поскольку return возвращает результат из функций в других языках и это может запутать) нужна, чтобы инициализировать наш объект State обычным значением. bind нужна, чтобы связать два изменения состояния. bind по сути является оператором ";" только в функциональном исполнении:
bind(a,f) ~ { r = a(..); return f(r) } // a - функция!
Как реализовать pure? Итак, State содержит функцию преобразования состояния:
fn(s) { ....; return (s,???)}
pure принимает аргумент x и возвращает State:
pure(x) { State { st: fn(s) { ...; return (s,???) } } }
Поскольку pure ничего не делает, то логично просто вернуть x и s как есть:
pure(x) { State { st: fn(s) { return (s,x) } } }
// pure(x) -> fn(s) { return (s,x) }
Т.е. pure создает функцию изменения состояния, которая ничего с состоянием не делает, только добавляет в качестве результата, переданное ей значение.
Теперь определим функцию bind. Эта функция передает результат одного вычисления в другое, поэтому для начала надо провести первое вычисление. Напомню также, что результатом bind должен быть State, т.е. функция:
bind(state_obj, f) {
State {
st: fn(input_state) {
(s,r) = state_obj.st(input_state);
...
}
}
}
Если первый шаг более-менее понятен, то следующий, скорее всего, самое сложное, что есть в монадах. Поэтому сначала еще раз посмотрим, что происходит. bind - это функция, которая не проводит никаких вычислений, она их только связывает, как ";" связывает выражения в C/Java, вычисления спрятаны в функции в State. Функция f - второй параметр bind - тоже не проводит никаких вычислений с состоянием. Это своебразный конструктор, который использует результат предыдущего вычисления и другие переменные в области видимости, чтобы создать функцию, которая и будет проводить вычисления с состоянием на самом деле. При этом f может быть сложной и даже проводить вычисления с какими-то другими монадами, но это не будет иметь никакого отношения к нашему состоянию. Поэтому следующие две строчки не должны вас удивлять:
bind(state_obj, f) {
State {
st: fn(input_state) {
(s,r) = state_obj.st(input_state); // evaluate arg1
next_state_obj = f(r); // constructor
return next_state_obj.st(s); // evaluate new obj
}
}
}
f не нуждается в параметре s, потому что она только создает следующее вычисление, которое выполняется в следующей строчке. В целом, как вы видите, bind абсолютно ничего не делает, это всего лишь оператор композиции функций на стероидах. Результатом цепочки bind будет мегакомпозиция кучи функций в объекте State. Чтобы выполнить вычисления на самом деле, нужно извлечь функцию и передать ей начальное состояние:
state.st(initial_state) // результат будет (final_state,return_value)
Наша State монада готова, но есть одна проблема. Поскольку f не зависит от текущего состояния, то она не может его использовать при конструировании следующего вычисления. Для решения этой проблемы для монад типа State определяют специальные функции типа get/set, чтобы работать со скрытыми переменными:
get: State('s,'s)
get = State {
st: fn(in_state) { return (in_state,in_state) }
}
set: 'x -> State('x,())
set(new_s) { State{
st: fn(_) { return (new_s,()) } }
}
Напомню, State - это вычисление над состоянием, т.е. функция. Соответственно, get - это функция, которая возвращает текущее состояние как результат, а set, используя параметр new_state, создает функцию, которая возвращает new_state как новое состояние. Если вас смущает то, что get/set так вольно обращаются с возвращаемым значением и могут "затереть" предыдущее значение - не пугайтесь. Функция конструктор f может использовать любые предыдущие результаты, если они в ее зоне видимости (эта фича в наше время обычно есть даже в императивных языках).
Вот пример небольшого вычисления со State:
get `bind`
fn(x) { return pure(x*x)`bind`
fn(x) {
return
if x>10 { return pure(()) } else { return set(x) } `bind0`
get `bind`
fn(y) { return pure(x+y) }
}
// bind0 - вспомогательная функция к bind для композиции
// двух независимых вычислений
bind0(a,b) { bind(a,fn(_) { b } }
Или используя синтаксический сахар из Haskell:
do
x <- get
x <- pure(x*x)
if x > 10 { pure(()) } else { set(x) }
y <- get
pure(x+y)
У нашей State монады есть свои плюсы - нам не нужно передавать состояние в функции в явном виде. Но цена за это слишком высока, особенно без "do" нотации. Главная проблема таких монад в том, что их нельзя свободно сочетать. Если мы хотим использовать одновременно несколько монад, то придется писать убер монаду (если это вообще возможно) или использовать сложную технику трансформеров.
К счастью, наука не стоит на месте и нам на помощь приходит Free монада, которая в значительной степени лишена этих недостатков. Free (свободный) в ее названии относится к процедуре популярной в математике, когда берутся некие начальные элементы, операции над ними и неограниченным применением операций генерируется множество всех возможных результатов. Например, все натуральные числа можно сгенерировать так:
One: N - начальный элемент
Add1: N->N - операция
Add1 One ~ 2
Add1 Add1 One ~ 3
..
В случае Free монады начальными элементами будут, грубо говоря, простые типы, а операциями - рекурсивные типы данных, являющиеся функторами (не будем вдаваться в подробности, поскольку для дальнейшего это не имеет значения). Поскольку функторы обладают приятным свойством ассоциативности (не важны скобки в композиции), то их можно спокойно комбинировать, а значит получать монадические свойства на халяву (free в другом смысле).
С другой стороны, если вы посмотрите на пример выше, то увидите главную проблему такого подхода. Free объекты - это деревья, где узлы - это операции, а листья - начальные значения. Математиков это не волнует, поскольку они не различают эквивалентные (изоморфные) объекты, но для программиста разница существенна - число 3 гораздо проще обрабатывать, чем дерево из Add1.
Поэтому Free монада предполагает, что вы определяете не только правило композиции (bind), но и правило интерпретации (run). Давайте, посмотрим как это реализуется в динамическом языке и какие забавные результаты можно получить.
Примеры ниже взяты из статьи Freer Monads, More Extensible Effects.
Суть Free монады в том, что есть pure значения, которые не содержат эффектов и impure значения с эффектами. Реализуем для примера два эффекта: 1) запросить число у внешнего мира и 2) записать строку в лог:
pure(x) { Pure(x) }
get(f) { Get(f) } // f: fn(int) -> FreeObject
put(v,self) { Put(v,self) } // self: FreeObject
Все эффекты, включая отутствие эффекта (Pure), реализуются как отдельные типы данных. Для удобства я использую алгебраические типы, но тоже самое можно легко реализовать в виде структур:
type Put { value, next }
put(x,self) { Put { value:x, next: self } }
Эффект Get - это функция, которая запрашивает число у внешнего мира и затем конструирует из него новое вычисление. Эффект Put получает в качестве аргумента строку и что-то с ней делает. Как видите, у всех эффектов есть одна общая черта - они содержат продолжение или функцию, результат которой Free объект, или просто сам Free объект. Этот объект содержит информацию о том, что должно происходить дальше.
По сравнению со State мы повысили уровень абстракции. Теперь мы можем определить bind сравнительно механически, применяя простую процедуру к продолжениям:
bind(a,f) {
match a {
Pure(x) => f(x),
Get(g) => get(kleisli(f,g)),
Put(str,b) => put(str,bind(b,f)),
}
}
// kleisli - вспомогательная функция с необычным названием, вариант bind
// kleilsi(f,g) -> h, f:x->Free(y), g:y->Free(z), h:x->Free(z)
// bind(a,f) -> b, a: Free(x), f: x->Free(y), b:Free(y)
// bind0(a,b) -> c, a: Free(x), b: Free(y), c: Free(y)
kleisli(f,g) { fn(x) { bind(f(x),g) } }
Т.е. в эффектах, которые содержат функцию, возвращающую Free объект, мы заменяем функцию на композицию функций, а в эффектах, которые содержат сам Free объект, мы используем композицию через bind. Те типы, где так можно сделать, и есть, грубо говоря, функторы. Не стоит пытаться понять, что именно получается в результате. Я написал эту функцию, но и сам не знаю. Главное, если вы следуете правилам, то в итоге получится то, что нужно.
Как видите, bind во Free монаде еще менее содержателен, чем в State. Он просто формально манипулирует функциями, перекомпоновывая их. Чтобы придать смысл получившемуся выражению, мы должны реализовать интерпретатор. Для начала реализуем простой интерпретатор, который в Get передает все время одно число, а в Put сохраняет все строки в одну большую строку:
run1(i,log,free_obj) {
match free_obj {
Pure(x) => (log,x),
Get(f) => run1(i,log,f(i)),
Put(str,b) => run1(i,concat(log,str),b),
}
}
Как видите, интепретатор реализуется тоже сравнительно в лоб. Главное, не забывать рекурсивно вызывать функцию-продолжение.
Теперь мы можем создать выражение и выполнить его в интерпретаторе. Для удобства зададим несколько вспомогательных функций и Free объектов:
// запросить число
ask: FreeObject
ask = get(pure) // ~ get(fn(i) { return pure(i) })
// запросить число, затем(bind) сложить его с параметром
addGet: i -> FreeObject
addGet(i) { bind(ask,fn(j) { return pure(i+j) }) }
// запросить i чисел и сложить
addN: i -> FreeObject; fold: (fn,x,list) -> x
addN(i) { fold(kleisli,pure,repeat(i,addGet))(0) }
tell: string -> FreeObject
tell(str) { put(str,pure(())) }
ask реализует наипростейшую Get функцию, которая запрашивает int у окружения и оборачивает его в Pure.
addGet и addN определяются через ask. addGet запрашивает int и складывает его с параметром. addN запрашивает i целых и складывает их на манер sum. fold в addN создает композицию из i функций addGet с pure в начале. Иначе это можно записать так:
pure(0) `bind`
addGet `bind`
...
addGet
Из развернутого представления понятно, почему нужно вызвать результат fold с 0.
tell просто обертка над Put.
Теперь можно вычислить выражение:
do
tell "start"
v <- addN 10
tell "-end"
pure v
run1(10,"",
bind0(tell("start"),
bind(addN(10),fn(v) {
return bind0(tell("-end"),pure(v))
})))
В явном виде выражение не очень понятно, поэтому привожу do нотацию.
На каждый вызов ask будет возвращаться 10, поэтому run1 вернет:
("start-end",100)
Такая монада выглядит уже привлекательнее State. Хотя bind выражение и несколько коряво (но не более коряво чем генераторы или async), оно дает нам возможность вложить какой угодно смысл в эффекты. Переопределим, например, интерпретатор так, чтобы Get использовала значения из списка:
runLst(lst,log,free_obj) {
match free_obj {
Pure(x) => (log,x),
Get(f) => {
match lst {
h::t => runLst(t,log,f(h)),
_ => <error>
}
},
Put(str,b) => runLst(lst,concat(log,str),b),
}
}
Теперь, если применить runLst со списком к выражению выше:
runLst([1,..,10],"",<expr>)
("start-end",55)
Можем возвращать случайное число:
runRnd(log,free_obj) {
match free_obj {
Pure(x) => (log,x),
Get(f) => runRnd(log,f(rand(10))),
Put(str,b) => runRnd(concat(log,str),b),
}
}
Наконец, можем сделать ask асинхронным:
runAsync(log,free_obj) {
match free_obj {
Pure(x) => (log,x),
Get(f) => (log,f),
Put(str,b) => runAsync(concat(log,str),b),
}
}
runCont(i,(log,f)) { runAsync(log,f(i)) }
Теперь при запуске runAsync попросит нас ввести значение:
c = runAsync("",<expr>)
c = runCont(1,c)
c = runCont(2,c)
...
Как видите, Free монада дает нам безграничные возможности, которые компенсируют общую корявость bind выражений. Самое главное, она дает нам единый интерфейс, основанный на одном принципе, а не 10 различных интерпретаций - async, generators, exceptions, global variables и т.п. Но у Free монады есть и серьезные недостатки. Во-первых, пресловутое ограничение функторности эффектов и необходимость реализовывать эту функторность. Во-вторых, если вы добавите в bind счетчик вызовов, то обнаружите, что он увеличивается квадратично в зависимости от числа bind в цепочке композиции. Руками, конечно, столько bind не создать, чтобы это было проблемой, но addN показывает, что это легко сделать генератором функций.
В такой форме Free монада получается несколько ущербной. К счастью, наука не стоит на месте и дарит нам новые возможности, а именно Freer монаду, свободную от этих ограничений.
Повысим уровень упоротости, т.е. абстракции и реализуем Freer монаду следующим образом:
pure(x) { Pure(x) } // без изменений
impure(data,f) { Impure(data,f) } // f: data -> Freer
bind(a,f) {
match a {
Pure(x) => f(x),
Impure(data,g) => Impure(data,kleilsi(g,f)),
}
}
Т.е. разделим все эффекты на две части - данные и продолжение. Как результат нам не нужно больше обрабатывать эффекты в bind индивидуально и нам больше не важна функторность типов эффектов. Impure обработчик автоматически сочетает все продолжения. Правда, мы еще не избавились от квадратичной сложности bind. Для этого не будем создавать композицию функций, а просто сохраним их в список:
impure(data,flist) { Impure(data,flist) }
bind(a,f) {
match a {
Pure(x) => f(x),
Impure(data,g) => Impure(data,concat(g,[f])),
}
}
Далее нам понадобится функция app для применения списка функций в Impure ([f: data->Freer]) к конкретному аргументу:
app(fns,x) {
(h::t) = fns; // assert(len(fns) > 0)
r = h(x);
if len(fns) == 1 { return r }
match r {
Pure(x) => app(t,x),
Impure(data,fs) => Impure(data,concat(fs,t)),
}
}
app концептуально проста, она пытается выполнить все функции в списке, пока есть возможность. Если выполнить функции невозможно, она откладывает их на потом. Еще одна функция - это помощник для создания эффектов:
runX(type,ePure,eFn,expr) {
match expr {
Pure(x) => ePure(x),
Impure(data,fns) => {
nxt = fn(a) { runX(type,ePure,eFn,app(fns,a)) }
if data is type {
eFn(data,nxt)
} else {
impure(data,[nxt])
}
}
}
}
Понять, как работает runX, довольно сложно. Ее задача выполнить свой эффект, представленный функциями ePure и eFn, если есть возможность, а если нет, то отложить себя на потом. Давайте посмотрим, как через нее реализуются Get и Put:
ask = impure(Get,[pure])
// addGet, addN не меняются, они определены через ask
// Get теперь пустой тип, у Get нет данных
tell(x) { impure(Put(x),[pure]) }
Ни ask, ни tell не имеют сами по себе никаких продолжений, поэтому передают в impure отсутствие продолжения - pure.
runG(i,obj) { runX(Get,pure,fn(_,nxt){nxt(i)},obj) }
runW(obj) {
ePure = fn(x) { pure(("",x)) }
eFn = fn(p,nxt) {
Put(str1) = p; // p is Put by definition
bind(nxt(()),
fn((str2,r)) { pure((concat(str1,str2),r)) })
}
runX(Put,ePure,eFn,obj)
}
Чтобы выполнить выражение, теперь нужно вызвать интерпретатор каждого эффекта:
runW(runG(10,<expr>))
Само выражение не меняется, как и результат. Попробуем теперь реализовать runG как runAsync. Теперь эффекты накладываются друг на друга - как нам учесть эффект runW.
Для того чтобы передать число, нам понадобится nxt. Поэтому первая мысль, вероятно, будет вернуть nxt:
runAsync(obj) { runX(Get,pure,fn(_,nxt){pure(nxt)},obj) }
Но это не то. Вернув nxt таким образом, мы вызовем по сути дела оператор "return nxt". Функция прервется, но все остальные эффекты тоже прервутся и мы не сможем их продолжить. Чтобы понять, что нужно сделать, надо посмотреть на функцию app. Если мы вернем pure значение, то она выполнит следующую функцию. Значит надо вернуть impure:
runAsync(obj) {
runX(Get,pure,fn(_,nxt){impure(Async,[nxt])},obj)
}
В качестве типа данных нужно передать нечто, что не может быть интерпретировано. В этом случае runX функции не смогут ничего больше сделать и выражение вернется как есть. Теперь мы можем определить функцию restartA:
restartA(val,obj) {
Impure(Async,nxt) = obj;
nxt[0](val)
}
c = runW(runAsync(<expr>))
c = restartA(100,c)
...
Отлично, с полпинка мы реализовали одну из сложнейших монад - продолжения. Это значит, что мы можем реализовать таким путем все, что угодно.
Для примера реализуем игру в угадай число. В do нотации она выглядит так:
do
y <- pure(rand(10))
loop <- getCC
tell("enter a number")
x <- ask
if x != y
tell("try again")
loop
else
tell("success!")
Для getCC требуется реализовать настоящую монаду продолжений, чтобы не конфликтовать с Async. Надо быть осторожным, чтобы не возникло утечки памяти, ведь CC используется для организации цикла:
getCC = impure(CC(()),[pure])
runCC(obj) {
eFn = fn(data,k) {
k = match data {
CC(()) => k,
CC(cc) => cc
}
k(impure(CC(k),[pure]))
}
runX(CC,pure,eFn,obj)
}
Переопределим runW, чтобы он выводил строку на экран:
runW(str,obj) {
runX(Put,pure,fn(str,nxt){print(str); nxt(())},obj)
}
С использованием bind функция игры выглядит так:
expr = pure(rand(10)) `bind`
fn(y) {
getCC `bind`
fn(loop) {
tell("enter a number") `bind0`
ask `bind`
fn(x) {
if x == y {
tell("success")
} else {
tell("try again") `bind0`
loop
}
}
}
}
Запустить ее нужно так:
runCC(runW(runAsync(expr)))
runW и runAsync независимы, но runCC должна быть первой, иначе она потеряет продолжения остальных интерпретаторов.
Замечу, что expr сам по себе только данные, смысл им мы придаем интерпретаторами. Мы можем реализовать любой другой интерпретатор - писать в файл или сокет, читать с диска, из массива, генерировать случайный ответ и т.п.
То, что мы сделали называется алгебраическими эффектами (думаю, понятно почему алгебраическими). Хотя любой язык использует их неявно - исключения, глобальные переменные, async, генераторы, циклы и т.п. - очень в немногих языках они реализованы в явном виде. Из известных это Lisp (конечно!) и OCaml. Еще есть экспериментальные Koko(.NET) и Eff(JavaScript).
Как видите, абстрактная структура данных и пара общих законов позволяют реализовать нечто, что не имеет аналогов и обладает гигантским потенциалом абстракции. Мы можем менять интерпретацию программы по желанию и выполнять ее по разному в зависимости от обстоятельств. При тестировании, например, мы может скармливать ей фиксированные значения. Можем запустить ее удаленно и пересылать данные по сокету, можем брать или писать данные в консоль или GUI.
Напоследок приведу все функции на JavaScript. Вы можете скопировать их и выполнить прямо в браузере и убедиться, что все работает. Обратите внимание, как мало кода требуется:
bind = (a,f) => {
if (a.typ == "pure") {
return f(a.val)
} else {
return {
typ: "impure",
val: a.val,
cnt: a.cnt.concat([f])
}
}
}
bind0 = (a,b) => bind(a,(x) => b)
kleisli = (f,g) => (x) => bind(f(x),g)
impure = (a,f) => ({ typ: "impure", val: a, cnt: f })
pure = (a) => ({ typ: "pure", val: a })
app = (ops,a) => {
var r = ops[0](a);
if (ops.length == 1) {
return r
}
var fns = ops.slice(1,ops.length)
if (r.typ == "pure") {
return app(fns,r.val)
} else {
return impure(r.val,r.cnt.concat(fns))
}
}
runX = (id,ret,h,m) => {
if (m.typ == "pure") {
return ret(m.val)
};
var nxt = (x) => runX(id,ret,h,app(m.cnt,x));
if (m.val.typ == id) {
return h(m.val,nxt)
} else {
return impure(m.val,[nxt])
}
}
runAsync = (x) => runX("get",pure,(v,k) => impure({typ: "cont"},[k]),x)
rerun = (v,cnt) => cnt.cnt[0](v)
ask = impure({typ: "get"},[pure])
addGet = (x) => bind(ask,(y) => pure(x+y))
addN = (n) => ([...Array(n).keys()].reduce((f,x) => kleisli(f,addGet),pure))(0)
runW = (x) => runX("put",pure,(s,k) => { console.log(s.val); return k(0) },x)
tell = (x) => impure({ typ: "put", val: x},[pure])
getCC = impure({ typ: "CC" },[pure])
runCC = (x) => runX("CC",pure,
(v,k) =>
{
if (v.val !== undefined) {
k = v.val
};
return k(impure({ typ: "CC", val: k },[pure]))
},x)
// Demo1: square(x)
r = runAsync(runW(bind0(tell("enter a number"),bind(ask,(x) => tell("Result is "+(x*x))))))
rerun(10,r)
// Demo2: guess a number
r = runCC(runAsync(runW(
bind(pure(Math.round(Math.random()*10)),(y) =>
bind(getCC,(cc) =>
bind0(tell("enter a number"),
bind(ask,(x) => {
if (x==y) {
return tell("success")
};
return bind0(tell("try again"),cc)})))))))
// это 0?
rerun(0,r)