Desk言語とAlgebraic Effects and Handlers(代数的エフェクト)
言語実装 Advent Calendar 2022の4日目の記事です。
Desk言語は代数的エフェクト(Algebraic Effects and Handlers)を搭載した言語です。本記事ではDesk言語のeffect
について深ぼって説明します。
Desk言語ではeffect
というものを使って副作用を表現します。逆にeffect
がないと一切の副作用を表現できません。
また、Desk言語だけでは副作用を実現できないため、effect
をシステムコールのように扱って外部リソースとやりとりします。
effect
は型システムによって推論され、型を見るだけで、その式がどのような副作用を持つかがわかります。
代数的エフェクト
代数的エフェクトについてはこちらの論文を読んでください。
……だけだとちょっと不親切なので、ざっくり言うと、エフェクトとはReact Hooksです[1]!
effect
という単一のシンプルな言語機能だけで、例外・モナド・async/await
・コルーチン・非決定的な計算など様々なものを表現できます。
他に簡単な解説記事もありますが、本記事の方が、早く簡単に代数的エフェクトを理解できることを目指したので、この記事だけ読めば大丈夫です[2]。
Effect
effect
は
矢印の左側(上でいう
つまり、Desk言語において、副作用は全て入力と出力を持ちます。また、副作用の区別は入力と出力の型によってなされます。
実際にeffect
を起こすことをperform
と呼びます。以下のように! expr ~> type
がperform
式です。
! 1 ~> 'string
1
を入力にeffect
を発火(perform
)しています。
ちなみに、このeffect
は
いろいろなEffect
例を見る方が早いのでいろいろなeffect
の例を見てみましょう。
print (文字列の表示)
これがprint
です。入力が
()
)です。Rustに近い見た目で書くと以下のようになるでしょう。
print: &str ~> Printed<()>
文字列を受け取って()
を返すのでprintln!
に似ていますね。実際それと同じような動作をします。
get/set (状態の取得と保存)
これがget
で、
これがset
です。
それぞれRustっぽく書くと以下のようになります。
get: () ~> enum { State(T), None }
set: State<T> ~> ()
enum
に相当します。enum
が突然出てきて驚かれたかもしれませんが、Desk言語は型を事前に定義するという概念がないので、Rustに直すと上のようになってしまいます。
get
にしてもset
にしても入力や出力が直感的だと思います。Desk言語のEffectシステムは直感的に扱えそうだなと思っていただけていたら大成功です。
補足ですが、このget
/set
はストレージを選ばず使うことができます。なぜなら「アプリのグローバルステート用のhandler
」や「スレッドローカルストレージ用のhandler
」などを後から自由に指定できるからです(handler
については後述します)。逆に、コード上でストレージを区別したい場合は@"state"
を@"global state"
や@"thread local storage"
と書き換えるだけです。
throw (例外)
これはもう説明なしで読めるのではないでしょうか?例外オブジェクトが入力で
なんとなく直感的な気がすると思いますが、違和感を感じている方もいると思います。
違和感の原因は「出力」があるからだと思います。一般的に例外は投げっぱなしにして、だれかに例外処理を一任します。となると「出力」っぽいものはどこにもないです。
Desk言語のeffect
は必ず出力を持ちます。かつ、Desk言語で例外を表すにはeffect
を使います。「入力」は例外オブジェクトです。では「出力」はなにを表すのでしょうか?この問いについてはあとで解説するので一旦無視してください。
また、まだ例外を処理する話はできていません。throw
とcatch
はセットのはずです。実際、Desk言語でもそれはセットになっていて、前者はeffect
であり、後者はhandler
です。
というわけで、次はhandler
の話をします。
Handler
handler
は特定のeffect
を処理するものです。以下はDesk言語でそれを行うための(コンパイル可能な)コードです。
'handle ^Effectを含む関数(*<>) {
'string ~> @"printed" *<> => <~! @"printed" *<> ~> *<>,
@"ゼロ除算" 'integer ~> 'integer => "ok",
}
Rustっぽく書くとこうです。
handle Effectを含む関数(()) {
&str ~> Printed<()> => continue Printed<()> ~> (),
ゼロ除算<i32> ~> i32 => "ok",
}
説明していない構文をたくさん出してしまいましたので、一気に説明します。
^Effectを含む関数(*<>)
はEffectを含む関数
という型の関数を、Rustでいう*<>
を引数に呼び出しています。Desk言語は引数を持たない関数を作れないのでそのような場合はこの例のように明示的に*<>
を受け取る関数にする必要があります。
'handle expr { effect => expr, ... }
がhandle
式です。
この例では effect
と effect
との2つを処理しています。
前者は先ほどでてきた、print
ですね。後者はなんとなくわかると思いますが、ゼロ除算例外です。例外なのにここにも出力がありますね。しかも
<~! expr ~> type
はcontinue
式です。実は!
(perform
式)を使っても同じことを書けますが、便利なのでこちらを使うことが多いです。!
を使う方法も後で説明します。
以上で構文上の説明は終わりです。実際にこのプログラムがどのように動作するかを考えてみます。
まず、handle
式で呼び出した関数は2種類の副作用を持つようです。たぶん関数の型は以下のようになっているでしょう。
おっと、驚かせてしまいました。まずはeffect
を無視した型を見てみます。
とてもわかりやすいですね。Rustだとこうです。
fn(()) -> ()
もとの型に戻ってみると、違いは! { ... }
の部分ということがわかると思います。これは式がperform
する可能性のあるeffect
の集合になっており、これは型システムにより推論されます。
このような型になっていることから、関数の内部に ! "hello world" ~> @"printed" *<>
や ! @"ゼロ除算" expr ~> 'number
といった式があることを想像できます。
ではまず関数内で! "hello world" ~> @printed *<>
が呼ばれた時には何が起こるでしょうか。上のhandle
式を見ながらすこし考えてみてください。
正解は「何も起こらず、関数内の処理がそのまま続く」です。関数側はprint
することを想定してeffect
をperform
しましたが、handler
では何も出力しないままに、<~ @"printed" *<> ~> *<>
でEffect
に対して出力を渡して、処理を継続(continue
)させています。
effect
は handler
は何でもいいというのが、Algebraic Effects and Handlersの特徴といって良いでしょう。
次に関数内で! @"ゼロ除算" 1 ~> 'number
が起こったら何が起こるでしょうか。こちらも上を参照しながら考えてみてください。
こちらの正解は「関数の実行が完全に中断されて、handle
式の評価値が"ok"
になる」です。こちらでは<~
(continue
式)を使いませんでした。そのため、関数はeffect
の発生時点で処理が止まり、そのままhandle
式全体の評価に移り、"ok"
と評価されるわけです[3]。
なぜ例外に「出力」が必要なのか
以上の話で、なぜDesk言語の例外に出力が必要なのかがわかった方もいると思います。ざっくり言えば、Desk言語のeffect
はcontinue
(継続)できるからです。Desk言語ではeffect
を「入力」とともにperform
することができ、handler
から「出力」を受け取ることで処理をcontinue
することができます。
つまり例外もcontinue
できるということです。
試しに実際にやってみましょう。
'handle ^ゼロ除算を含む関数(*<>) {
@"ゼロ除算" 'integer ~> 'integer => <~! 0 ~> *<>,
}
こうするとゼロ除算が起きたとしても、例外による失敗はしなくなり、3 / 0
のような計算の結果は(effect
の出力である)0
になります。
PerformとContinueは対称
continue
式の代わりにperform
式が使えると、先ほど説明しました。その例をお見せします。
'handle ^Effectを含む関数(*<>) {
'string ~> @"printed" *<> => ! @"printed" *<> ~> *<>,
}
こちらもコンパイルが通り、期待通り動きます。先ほどとの違いは<~!
を!
に変更しただけです。
はいそうです、perform
とcontinue
は対称です。つまり「effect
に対して、ある値を出力として渡してcontinue
させること」=「ある値を入力としてperform
してhandle
対象の式の評価された値を出力として受け取ること」となります。
<~!
を!
にするだけなので、タイプ数が減りましたね。しかし、基本的には<~!
を使いましょう。continue
していることをコードを読む人に対して明示できるだけでなく、間違ってcontinue
とは関係ない別のeffect
をperform
してしまっていないかをコンパイラが検査してくれます。
逆に!
を使ってcontinue
しないといけない場面はなんでしょうか。実は<~!
はhandle
式の中でしか使えません。従って、外部でhandler
を定義したいときは!
を使う必要があります。
'forall output \ *<> -> ! { @"ゼロ除算" 'number ~> output } *<>
このように多相を用いれば、どんな型を返す式に対しても同じhandler
を使い回すことができます。
Multi-shotな継続
Desk言語ではeffect
に出力を渡して処理をcontinue
させることができると説明しましたが、実は出力は何度でも渡すことができます。以下の例を見てみましょう。
'type add \ *<@1 'number, @2 'number> -> @sum 'number
'handle 'begin
$ ! "a" ~> 'number;
^add(&'number, &'number)
'end {
'string ~> 'number =>
^add(<~! 1 ~> 'number, <~! 2 ~> 'number)
}
ちょっと複雑ですね。Rustっぽく書き直してみましょう。
handle {
let number = ! "a" ~> i32;
number * 2
} {
'string ~> 'number => (<~! 1 ~> i32) + (<~! 2 ~> i32)
}
このコードでは"a"
を入力にしてeffect
を発生させ、その出力を2倍しています。
また、handler
では2回continue
して、その2回の出力を足しています。
さてこの式全体の結果は何になるでしょうか。少し考えてみてください。
答えを説明します。1
と2
でcontinue
したことと同じことをコードの変形で同じ結果になる式に置き換えてみましょう。
(<~! 1 ~> 'number) + (<~! 2 ~> 'number)
このように2回continue
していました。これを
(1 * 2) + (2 * 2)
と変形するのと同じ結果になります。
つまり結果は6
となります。
Effectとはシステムコールである
実はDesk言語単体では副作用のあるプログラムを書けません。いくらDesk言語内でうまくeffect
を使おうとしても、ファイルを読み書きしたり、現在時刻を取得したり、軽量プロセスを起動したりといったことはできないわけです。
しかし1つだけ、Deskのプログラムにそういった副作用を持たせる方法があります。それはeffect
を外界とのインターフェースとして扱い、他の言語でシステムコールを呼ぶような感覚でeffect
をperform
し、言語処理系(DeskVM)が適切にeffect
を処理すれば良いわけです。
つまり、言語内で解決できるeffect
は「言語内のhandler
」を使い、本物の副作用を扱いたい場合は「言語外のhandler
」を使うことになります。
ところで、型システムにより、静的に全てのシステムコールを列挙できるわけですが、セキュリティー的に良いと思いませんか?
Appendix 1. 高階関数とEffect
高階関数、例えばmap
はどのような型になるでしょうか。
! ^t(a) [b]
のところ以外は読めると思います。
^t(a)
はt
関数にa
という引数を渡した時に起こる可能性のあるeffect
の集合を意味します。
map関数にeffectful
な関数を渡したとしてもちゃんと結果の型にeffect
が表れます。
!
の後の部分はeffect expr
と呼ばれるものを置くことができて、前述の{effect, ...}
や^t(a)
以外にも-<effect expr, effect expr>
(effect
集合の減算)や+<effect expr, ...>
(effect
集合の加算)のようなものがあり、様々な高階関数にeffect
が正しく推論できる型付けをすることができるようになっています[4]。
実装の話をすると、この辺りはテストケースや実装が足りてなくて、おそらくうまく動かないので、どなたかテストと実装を手伝っていただけると嬉しいです。
Appendix 2. Effectの推論
perform
式を含む式は
関数のbodyが
handle
式について、対象の式のeffect
の集合を effect
の集合を handler
について、handler1
のeffect
の集合を handler1
のcontinue
のeffect
だけを含む集合を handle
式のエフェクト集合は以下のようになる。
詳しくはtypeinferの実装を読んでください。
Discussion