
本記事はエムスリー Advent Calendar 2025 15 日目の記事です。
OCaml が好きです。
元々好きでしたが、バージョン 5 からはマルチコア対応が入り、更に好きな要素が増えました。
それは前回も紹介した Algebraic Effects and Handlers という新機能です。
新しい機能はそれだけで心躍るものですが、使い方が十分周知されていないのも事実です。
「なんでもできる」と前回紹介した通り、今回はこのなんでもできる機能が実用的な機能であることを見ていきます。
はじめに
OCaml が大好き、基盤開発チームの田尻です。 特に、Effect Handler は好きな機能で、実用的な機能もあります。
また、エムスリー Advent Calendar 2025 14 日目の記事は継続とそこから関連した Effect System の説明でした。 そちらを読んで Effect System マスターになった読者諸兄姉の中には実用したくてウズウズしている方もいらっしゃるでしょう。*1 この記事では Effect System の中でも、特に Algebraic Effects and Handlers という概念についての詳細を見ていきます。
まだまだ世の中にはチュートリアルも少ない機能なので、入門の一助になればと思い、筆をとりました。
そもそも Algebraic Effects and Handlers とは
前回記事を見てください。
……では、あまりにも不親切なので簡単に説明します。 端的に表現するなら「戻ってこれる例外機構」のことになります。 「戻ってこれる」とはなんでしょうか。
まずはただの例外機構
例えば、標準入力された文字列を繰り返すだけの echo プログラムを OCaml で書いてみます。
open Printf let rec echo () = input_line stdin |> printf "%s\n%!"; echo () let () = try echo () with | End_of_file -> printf "End\n%!"
慣れていない方にはギョッとするプログラムかもしれません。慣れてください。そしてたくさん OCaml を書いてください。私に見せてください。
まず、先頭の open Printf はモジュールのインポートです。一旦スルーして問題ありません。
次に、2 つの let が記述されています。これは関数や変数の定義文です。
1 つ目は let rec で再帰関数を定義することを宣言しています。echo という関数は第 1 引数に () を取り、= 以降の処理を実行します。
input_line stdin は標準入力から 1 行入力を受け、結果を返却します。
返却した結果は |> (パイプライン演算子) によって printf "%s\n" に渡され、表示されます。
最後に再帰呼び出しをすることで無限に echo 関数が呼び出されています。
2 つ目は少し特殊ですが、パターンマッチをしていると考えると良いでしょう。= の結果が () であることだけなので、それを期待していることを明示しています。
= 以降では try ~ with で例外処理をしています。
input_line は EOF になると End_of_file 例外をスローするため、これを捕捉し、"End"を表示します。
というわけで、ここまでは通常の例外処理のお話でした。
当たり前に思えるかもしれませんが、このプログラムでは End_of_file になるとプログラムが終了します。
本当なら無限ループになっている echo 関数から脱出しているわけです。
戻ってくる、とは
では、次の例です。*2
open Printf let r = ref 0 let rec sum_up () = r := !r + ( input_line stdin |> int_of_string (* 注目 *) ); sum_up () let () = try sum_up () with | Failure _ -> printf "Error\n%!" (* 注目 *) | End_of_file -> printf "Sum is %d\n%!" !r
内容を変えて入力された整数を足し合わせるようにしてみました。
注目していただきたいのは「注目」とコメントした 2 行です。
今回利用した int_of_string は失敗する可能性のある計算です。
与えられる文字列が整数に変換できるとは限らないからです。
この時、int_of_string は Failure "int_of_string" をスローしますので、これも捕捉することにしました。
このプログラムをエラーで終了するのではなく、継続するにはどうしたら良いでしょうか? 例えば次の解法が考えられます。
open Printf let r = ref 0 let rec sum_up () = r := !r + ( match input_line stdin |> int_of_string_opt with | None -> printf "Error\n%!"; 0 | Some i -> i ); sum_up () let () = try sum_up () with | End_of_file -> printf "Sum is %d\n%!" !r
int_of_string_opt は int_of_string の Option 版で、例外の代わりに型で表現します。
これはこれで特に問題のないコードですが、別の解法も考えられます。
例外に継続を渡すことです。
open Printf exception Conversion_error_with_cc of (unit -> unit) let int_of_string_cc k s = try int_of_string s with | Failure _ -> raise (Conversion_error_with_cc k) let r = ref 0 let rec sum_up () = r := !r + ( input_line stdin |> int_of_string_cc sum_up ); sum_up () let rec handler f = try sum_up () with | Conversion_error_with_cc k -> printf "Error\n%!"; handler k | End_of_file -> printf "Sum is %d\n%!" !r let () = handler sum_up
Conversion_error_with_cc は例外です。パラメータとして unit -> unit な関数を受け取ります。
これを利用して int_of_string_cc は k : unit -> unit を受け取り、エラーの時には例外に k を付けてスローします。*3
呼び出し元では、k として sum_up 関数自身を渡しています。
例外をキャッチした際には渡された k を呼び出すことで、無事に元の処理に戻ることができました。
ただし、例外機構から抜けてしまう都合上、再帰的に k も例外機構の中に入れるようにしてあげる必要があります。
これが「戻ってこれる例外処理」です。
Effect Handler
ほんとうでしょうか。
今回のコードは unit -> unit にしか対応していないし、戻っているというよりも、続けたい関数を渡しているだけです。
足し算も中途半端な状態で停止していて、今回の例だったから偶然うまく動作している状態です。
わざわざ再帰関数を用意するのも面倒ですね。
真に「戻る」ためには現在の処理の後続処理を自動的に取得して、それを利用する必要がありますね。
というわけでようやく Effect Handler の登場です。 まずはコードを見てみましょう。
open Effect open Effect.Deep open Printf type _ Effect.t += Conversion_error : int Effect.t let int_of_string_eff s = try int_of_string s with | Failure _ -> perform Conversion_error let r = ref 0 let rec sum_up () = r := !r + ( input_line stdin |> int_of_string_eff ); sum_up () let () = match sum_up () with | () -> () | effect Conversion_error, k -> printf "Error\n%!"; continue k 0 | exception End_of_file -> printf "Sum is %d\n%!" !r
今度は例外ではなく Effect を宣言します。*4
Effect は raise の代わりに perform で呼び出し、呼び出し元に戻る際に値を持つことができます。
sum_up 関数が最初の定義と遜色ないことにお気付きでしょうか。*5
例外が発生ならぬ effect が perform された時の処理は呼び出し元に移譲されます。
これにより、与えられた継続 k を呼び出すも、呼び出さぬも自由です。
今回のように呼び出せば、先の例と同様に「戻ってくる」ことが可能です。*6
一方で、先ほどの例と異なるのは perform されて、戻ってくる時に値を持つということです。
今回は 0 を返しておきます。
これでエラーが起きた時は 0 にフォールバックするという処理を書くことができました。
動作させてみましょう。
$ ocaml sum.ml 1 2 abc Error 3 (* ctrl+d *) Sum is 6
さて、ここまでで利用方法が分かりました。 ここからが本編です。実際この機能は何の役に立つの?という話をしていきます。
実用 Algebraic Effects and Handlers
ここまでで完全完璧 Algebraic Effects and Handlers 理解者になった皆さんは「でも、これ、本番では使わないなあ」と思っていることでしょう。 ここから本番で使えそうな例を見せますので完全完璧本番利用可能 Algebraic Effects and Handlers 理解者になってください。
ロギング
まず、簡単な例としてロギングをテーマにしてみましょう。 現代では監視やロギングは非常に重要ですし、構造化することや環境に応じた技術選定が必要です。
まずは簡単なところから始めてみましょう。
#load "unix.cma" open Effect open Effect.Deep open Printf type level = | Debug | Info | Warn | Error let string_of_level = function | Debug -> "DEBUG" | Info -> "INFO" | Warn -> "WARN" | Error -> "ERROR" type log_data = { level: level; duration: float; payload: string; } type _ Effect.t += Log : log_data -> unit Effect.t let stderr_handler f = match f () with | effect Log { level; duration; payload }, k -> eprintf "[%s] [%.3f] %s\n%!" (string_of_level level) duration payload; continue k () | v -> v let () = let start = Unix.gettimeofday () in let duration () = Unix.gettimeofday () -. start in stderr_handler @@ fun () -> perform (Log { level = Info; duration = duration (); payload = "This is a log message." }); let a = ref 0 in for i = 1 to 100_000_000 do a := !a + i done; perform (Log { level = Debug; duration = duration (); payload = Printf.sprintf "Computation result: %d" !a }); printf "Final result: %d\n" !a
$ ocaml -I +unix log.ml [INFO] [0.000] This is a log message. [DEBUG] [0.903] Computation result: 5000000050000000 Final result: 5000000050000000
良い感じですね。 すごいのはここからです。
例えば、ログ出力形式を ltsv にしたくなったとしましょう。 その場合は次のようなハンドラを書いて、ハンドラだけ差し替えると、
(*... 他は全部一緒 ... *) let ltsv_handler f = match f () with | effect Log { level; duration; payload }, k -> eprintf "level:%s\tduration:%.3f\tmessage:%s\n%!" (string_of_level level) duration payload; continue k () | v -> v let () = (* ... *) ltsv_handler @@ fun () -> (* ハンドラだけ差し替え *) (* ... *)
$ ocaml -I +unix log.ml level:INFO duration:0.000 message:This is a log message. level:DEBUG duration:0.908 message:Computation result: 5000000050000000 Final result: 5000000050000000
出力結果を ltsv 形式で得ることが出来ました! 同様に json 形式にも出来そうですね。やってみます。
let json_handler f = match f () with | effect Log { level; duration; payload }, k -> eprintf "{ \"level\": \"%s\", \"duration\": %.3f, \"message\": \"%s\" }\n%!" (string_of_level level) duration payload; continue k () | v -> v
$ ocaml -I +unix log.ml
{ "level": "INFO", "duration": 0.000, "message": "This is a log message." }
{ "level": "DEBUG", "duration": 0.902, "message": "Computation result: 5000000050000000" }
Final result: 5000000050000000
良い感じですね。 このように「エフェクトの発生」と「ハンドラ」が分離されていることで実装を差し替えることが非常に簡単です。
しかも、事前に説明した通りエフェクトは返り値を持っています。 このことを応用してみましょう。
DB I/O
次の実用的な例は DB の I/O です。 とは言っても、実際に実装すると大変なのでハンドラを活かしてモックしてみます。
open Effect open Effect.Deep open Printf type id = int type data = { id: int; name: string; } type _ Effect.t += SelectUser : id -> data Effect.t type _ Effect.t += StoreUser : id * string -> unit Effect.t let mock_handler f = let tbl = Hashtbl.create 10 in match f () with | effect (SelectUser id), k -> let user : data = match Hashtbl.find_opt tbl id with | Some user -> user | None -> raise (Failure (sprintf "User with id %d not found in mock DB" id)) in continue k user | effect (StoreUser (id, name)), k -> let user = { id; name } in Hashtbl.replace tbl id user; continue k () | v -> v let () = mock_handler @@ fun () -> let () = perform (StoreUser (1, "Alice")) in let user = perform (SelectUser 1) in printf "Retrieved user: id=%d, name=%s\n%!" user.id user.name; let () = perform (StoreUser (1, "Bob")) in let user = perform (SelectUser 1) in printf "Retrieved user: id=%d, name=%s\n%!" user.id user.name; let _error_user = perform (SelectUser 2) in ()
$ ocaml dbio.ml Retrieved user: id=1, name=Alice Retrieved user: id=1, name=Bob Exception: Failure "User with id 2 not found in mock DB".
mock_handler では Hashtbl を利用して DB のモックにしています。
本番環境ではハンドラを差し替えて DB へクエリを投げるようにすれば利用できますね。
これは実際にテストする時にも有用です。
つまり、DI が非常に簡単に出来ているわけです。
今回のように Hashtbl を利用しても良いですし、あるいは、単に固定値を返却しても良いでしょう。
柔軟な利用が可能です。
非同期 I/O
更に、実用といえば非同期 I/O ですね、ということで、まずは上記同様の公式 tutorial を解いてみました。 少々長くなってしまうのでリンクだけ共有しておきます。
このように単純な非同期 I/O くらいであれば 1 ファイルで実装できてしまうのもすごいところです。 もし余力のある方は fork 元の tutorial を読んで、ご自分でも実装してみてください。
現在、OCaml には EIO (参考文献 3) という Effect-based な I/O ライブラリが存在しています。 これによって自分で実装せずとも簡単にネットワーク、ファイル、標準入出力といった I/O を並行に処理できます。
終わりに
本稿は参考文献の内容を元に実用を意識して説明してきました。 今回説明出来なかった内容はまだまだありますが、便利な機能だということが伝わっていれば幸いです。
これを読んでスーパー完全完璧本番利用可能 Algebraic Effects and Handlers 理解者と化した皆さんはぜひお手元でも OCaml を使ってみてください!
参考文献
- GitHub - ocaml-multicore/ocaml-effects-tutorial: Concurrent Programming with Effect Handlers
- OCaml - Language extensions
- GitHub - ocaml-multicore/eio: Effects-based direct-style IO for multicore OCaml
We Are Hiring!
弊社では技術課題に挑戦する仲間をいつも募集しております。 ぜひご応募して、私と Effect の話をしましょう!!(もちろん Effect や OCaml に詳しい必要はないです。私も勉強中です)
*1:まだ読んでない?ぜひ読むべきです。
*2:ここからの例は参考文献 1 を編集したものです
*3:cc は current continuation の意味があり、継続という概念を指しています。k は継続に対して慣例的に利用される変数名です。
*4: type _ Effect.t += ... という記法は Extensible variant types - https://ocaml.org/manual/5.4/extensiblevariants.html という機能によるものです
*5:OCaml で多用される shadowing を使えば丸っきり一緒に出来ますが、今回は分かりやすさを重視しました
*6:OCaml には Effect.Deep と Effect.Shallow が存在しており、前者を利用することで例外版で再帰関数を利用して作っていた挙動が再現できます。