KokaでAlgebraic Effectsに触れる
Algebraic Effectsをざっくり言うと、
プログラムの副作用をEffectとして表現し、
Effectの発生箇所と処理箇所を分離するような抽象化の手法
Algebraic Effectsを扱える言語にはいくつかあるが、今回は
Kokaを触ってみた
Effect TypesとEffect Handlersを用いてEffectを制御する
関数型言語だが、気持ち的にはあまりビビることなく手続き型っぽく書ける
Algebraic Effectsを理解したいという動機で触れたが正解だった

転がっている文書を読むのも良いが、実際に動くものに触れるのが一番早い
疑似Haskellとか疑似JavaScriptのコードを読む必要はない
最初からそのためにデザインされた言語に触れたほうがノイズが少ない
あと、基本的なことを抑えるだけならKokaってかなり難易度低いと思う
覚えることが少ない
docsに「Minimal but General」と書かれてるけど本当にそのとおり
使いこなせるかどうかはさておき、「Algebraic Effectsを知りたい」だけならコスパが良い
Kokaの基本文法を抑えた後に、具体例をいくつか見ればイメージを掴める
KokaはAlgebraic Effects以外にもすごい点がいくつかあって面白いが、ここでは触れない
以下ではKoka v2.4.0を使っている
まずTypeScriptで例外をthrowする関数を書いてみる
tsfunction safeDivide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
これは例外が発生しうる関数だが、そのことが関数の型に表れない
そのため、関数の使用者は内部を把握しないと、その仕様の存在に気づけない
また、例外が送出された場合、どこかでそれを捕捉する必要があるが、気付けない場合はそもそも捕捉のしようがない
更に、一度、例外がthrowされると、この関数の実行は中断されてしまう
そのため、例外が発生した地点から処理を再開することができない
次にHaskellを用いた除算関数を見る
hssafeDivide :: Float -> Float -> Either String Float
safeDivide a b =
if b == 0 then
Left "Division by zero"
else
Right (a / b)
TypeScriptの例とは異なり、失敗する可能性があることを Either
型で表現できた
型があることで、失敗時のhandlingを強制することができる
しかし、Eitherという文脈が値に付くことで、通常の値との互換性がなくなってしまった
この関数の返り値は Either String Float
型であり、通常の Float
型とはそのまま計算することができない
計算する場合は、値を取り出したり何なりする必要がある
更に、複数のモナドを組み合わせる時はモナド変換子を使うことが多いが、これもまた複雑になりがち
hs{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Writer (MonadWriter, WriterT, tell, runWriterT)
import Control.Monad.Trans.Class (lift)
type Log = [String]
safeDivide :: (Monad m, MonadWriter Log m) => Float -> Float -> m (Either String Float)
safeDivide a b = do
if b == 0 then do
tell ["Division by zero attempted"]
return $ Left "Division by zero"
else do
tell ["Performing division"]
return $ Right (a / b)
main :: IO ()
main = do
(result, log) <- runWriterT (safeDivide 1 0)
putStrLn $ "Result: " ++ show result
putStrLn $ "Log: " ++ show log
これはWriterとEitherを組み合わせた例
モナドをネストさせたモナドスタックを構成することになるが、
順序を入れ替えたいときに型の構成をごちゃごちゃ入れ替えないといけなかったり、
組み合わせ自体が面倒だったりする
ここで、Kokaで除算関数を書くと以下のようになる
koka(js)// raiseというEffectの定義
effect raise
ctl raise(msg: string): int
// 除算する関数の定義
fun safe-divide(x: int, y: int): raise int
if y==0 then raise("div-by-zero") else x / y
以下のようにして使う
koka(js)fun raise-const(): int
with handler
ctl raise(msg) 42
8 + safe-divide(1,0)
以下で1箇所ずつ順に見ていく
Kokaの関数の型を見る
上記の safe-divide
関数の型は以下の通り
fun safe-divide(x: int, y: int): raise int
引数が2つあって、両方 int
返り値も int
そして、Effectとして raise
を持つ
これを「Effect Type」と呼んだりする
これら3つはいずれも型推論の対象になる

そのため、省略しても良い
Effect Typeはbuilt-inで用意されているものもあれば、自分で定義することもできる
total
Effectが存在しないことを表す
exn
例外が発生する可能性がある
div
関数が終了しない可能性がある
console
consoleに書き込む可能性がある
etc.
また、これらの複数の組み合わせに対してaliasをつけることもできる
pure = <exn,div>
純粋関数であることを表す
io = <exn,div,ndet,console,net,fsys,ui,st<global>>
io
はEffectもりもりであることがわかる

etc.
具体的な関数の型を見てみてもわかりやすいかも
以下は repeat
関数と、 while
関数の型
fun repeat(n :int, action: () -> e ()): e ()
ref fun while(predicate: () -> <div|e> bool, action: () -> <div|e> ()): <div|e> ()
ref repeat
は実行する回数が決まっており必ず停止するため div
が含まれてないが、
while
の方は無限ループする可能性があるので div
が含まれている
自分でEffect Typeを定義することもできる
refkoka(js)effect raise // ①
ctl raise(msg: string): a // ②
①で raise
という名前のeffect typesを定義してる
②でそのoperationとして raise(msg: string): a
を定義している
これらは異なる名前でも良いし、複数のoperationを持っても良い
このeffectを使った関数を定義
koka(js)fun safe-divide(x: int, y: int): raise int
if y==0 then raise("div-by-zero") else x / y
safe-divide
という、安全に除算する関数を定義している
effectとして raise
を持っている
型シグネチャに表れている方がeffect type(①)で、
関数内に書かれているのがoperaion(②)ということ
この時点では、
raise()
を呼んだときに、何を行うのかは定義されていない

interfaceのみ示されており、実装はまだ決まっていないということ
Reactユーザ向けの説明だと、感覚的には、 useContext()
を呼んでるが、その中身はこの時点では決定できないのに近い
safe-divide()
を使用する
Effect Handlerとともに使用する
Effect Handlerは、内部でEffectが呼ばれたときに、どういう挙動をするかを定義する場所
内部で用意されたInterfaceに、実装をInjectionするイメージ
koka(js)fun raise-const(): int
with handler // ③
ctl raise(msg) 42
8 + safe-divide(1,0) // ④
③でhandlingの仕方を定義した上で、④で実際に関数を呼んでいる
handlerの内容は、 raise()
が呼ばれたら常に 42
を返す、というもの
従って、④を実行がされると、 1 ÷ 0
でraiseが呼ばれ、結果は 42
を返す
TypeScriptのtry/catchを順序を逆にして書いてると捉えれば良い
tstry {
return safeDivide(1, 0); // ④に相当
} catch (msg: any) { // ③に相当
return 42
}
もう1つのポイントは、 raise-const()
自体の返り値の型が
raise int
ではなく、 int
になっている点
raise
というeffectを消化しているのでその文脈が型からも消える
Handleされた時点から処理を再開することもできる
例えば上記のコードを以下のように書き換える
koka(js)fun raise-const()
with ctl raise(msg) resume(42) // resumeしながら42
8 + safe-divide(1,0)
resume
はhandler内で使えるbuilt-inの関数のようなもの
このコードを実行すると、 8 + 42
の結果として 50
が得られる
また、複数のEffectを扱う関数も簡単に書ける
koka(js)effect ctl raise(msg: string): a
effect ctl log(msg: string): ()
fun safe-divide(x: int, y: int): <log,raise> int
if y==0
then
log("Division by zero attempted")
raise("div-by-zero")
else
log("Performing division")
x / y
fun raise-const(): console int
with ctl raise(msg) 42 // ①
with fun log(msg) // ②
println(msg)
8 + safe-divide(1,0)
logを表示する log
というEffectをを追加した
safe-divide
では2つのEffectを含んでいることが型 <log,raise>
を見ればわかる
raise-const
の方を見ると、
①では raise
に対して、②では log
に対してhandlerを書いている
また、 raise-const
内ではprintlnを使っているので、 raise-const
自体のEffectは console
になっている
更に、handleする順序の入れ替えも、行を入れ替えるだけで良いのでかなり楽
koka(js)fun raise-const(): console int
with ctl raise(msg) 42
with fun log(msg)
println(msg)
...
koka(js)fun raise-const(): console int
with fun log(msg)
println(msg)
with ctl raise(msg) 42
...
今回のコード例では順序を入れ替えても挙動は変わらないが、組み合わせるEffectによっては変化するものもある
このように、Effectの組み合わせや、handlingの順序の変更などがかなり簡易にできる
具体例をいくつか
継続
listモナド?
たぶん違う
というか非決定を表すndetがあるのでそれを用いた例を書いたほうがおもろそう
以下は、resumeを2回呼んでる例として良さそう
kokaeffect ctl choice(): bool
fun xor(): choice bool
val p = choice() // ①
val q = choice() // ②
if p then !q else q
fun choice-all(action: () -> <choice|e> a): e list<a>
with handler
return(x) [x]
ctl choice() resume(False) ++ resume(True)
action()
// xor.choice-all()
実行順番
①でhandleされて、1つめのreusmeにより①にFalseが入る
②で再びhandlerされて、1つめのreusmeにより②にFalseが入る
2つめのreusmeにより②にTrueが入る
①でhandleされて、2つめのreusmeにより①にFalseが入る
②で再びhandlerされて、1つめのreusmeにより②にFalseが入る
2つめのreusmeにより②にTrueが入る
つまり
(p,q)
が、 (False,False)
, (False,True)
, (True,False)
, (True,True)
の順で呼ばれる
resume(False) ++ resume(True)
の意味
安直に考えると以下で良さそうだが
koka(js)ctl choice()
resume(False) // A
resume(True)
これだと、Aで再開されてそのまま終わってreturnに行ってしまう
++
のようにすることで結果を結合することを意味するため、逐次的に両方実行されることになる
ってことかな