Arantium Maestum

プログラミング、囲碁、読書の話題

Effect.Deepで書かれたResumable ExceptionをEffect.Shallowで書き直す

OCaml 5.0のeffect handler機能はStandard Libraryに入っているEffectというモジュールを通して扱う。

このEffectというモジュールにはEffect.DeepとEffect.Shallowという二つのサブモジュールがあり、ほとんどの機能はこれらサブモジュールに入っている。

この二つのサブモジュールは一見似たような機能を提供しており、どちらにも('a, 'b) continuation('a, 'b) handlerといった型、get_backtracediscontinue_with_backtraceといった関数(後者は同じ名前なのにシグニチャが違うが・・・)、そしてEffect.Deep.continueEffect.Shallow.continue_withなどの対応する関数などが多く存在している。

この二つのモジュールの違いについてOCaml Effect Tutorialは以下のように説明している:

When a deep handler returns a continuation, the continuation also includes the handler. This means that, when the continuation is resumed, the effect handler is automatically re-installed, and will handle the effect(s) that the computation may perform in the future.

Shallow handlers on the other hand, allow us to change the handlers every time an effect is performed.

Deepの方の限定継続は自動的に元のハンドラに包まれていて新しいハンドラをつけることはできない(しかしその分ハンドラを明示することを省略できる)のに対して、Shallowでは限定継続を呼び出す時に明示的にハンドラをつけなければいけない分呼び出しごとにハンドラを更新することもできる。

両者の違いを理解するために、Effect.Deepで書かれたResumable Exceptionの例をEffect.Shallowで書き直してみる。

Effect.Deepのコード

このResumable Exceptionコードは「文字列を整数に変換するが、変換できない場合はConversion_failureというエフェクトを発生させる」というオレオレint_of_stringと、それを利用した「Conversion_failureが発生したらエラーを出力した上で整数0として扱って処理を続行する」というロジックを実装している。

まず少し自分好みにリファクタしておく:

type _ Effect.t += Conversion_failure : string -> int Effect.t

let int_of_string l =
  try int_of_string l with
  | Failure _ -> Effect.perform (Conversion_failure l)

let rec sum_up acc =
  let l = input_line stdin in
  acc := !acc + int_of_string l;
  sum_up acc

let _ =
  let module D = Effect.Deep in
  Printf.printf "Starting up. Please input:\n%!";
  let r = ref 0 in
  let handler =
  { D.effc = (fun (type c) (eff: c Effect.t) ->
      match eff with
      | Conversion_failure s -> Some (fun (k: (c,_) D.continuation) ->
          Printf.fprintf stderr "Conversion failure \"%s\"\n%!" s;
          D.continue k 0)
      | _ -> None
    );
    D.exnc = (function
      | End_of_file -> Printf.printf "Sum is %d\n" !r
      | e -> raise e
    );
    D.retc = fun _ -> failwith "Impossible, sum_up shouldn't return"
  } in
  D.match_with sum_up r handler

主な変更点は二点:

  • open Effectなどを避けてモジュール名を明示するようにした
  • 複雑なレコードを関数適用している箇所で定義するのではなく、変数handlerとして定義した上でmatch_with関数に渡すようにした

Effect.Shallowのコード

上記のEffect.Deepを使ったコードをEffect.Shallowを使うように変更する:

type _ Effect.t += Conversion_failure : string -> int Effect.t

let int_of_string l =
  try int_of_string l with
  | Failure _ -> Effect.perform (Conversion_failure l)

let rec sum_up acc =
    let l = input_line stdin in
    acc := !acc + int_of_string l;
    sum_up acc

let _ =
  let module S = Effect.Shallow in
  Printf.printf "Starting up. Please input:\n%!";
  let r = ref 0 in
  let rec handler =
  { S.effc = (fun (type c) (eff: c Effect.t) ->
      match eff with
      | Conversion_failure s -> Some (fun (k: (c,_) S.continuation) ->
              Printf.fprintf stderr "Conversion failure \"%s\"\n%!" s;
              S.continue_with k 0 handler)
      | _ -> None
    );
    S.exnc = (function
        | End_of_file -> Printf.printf "Sum is %d\n" !r
        | e -> raise e
    );
    (* Shouldn't reach here, means sum_up returned a value *)
    S.retc = fun _ -> failwith "Impossible, sum_up shouldn't return"
  } in
  S.continue_with (S.fiber sum_up) r handler

変更点はすべてlet _ = ...の中だ。

列挙すると:

  • let module D = Effect.Deep inの代わりにlet module S = Effect.Shallow in
  • {D.effc=...; D.exnc=...; D.retc=...}{S.effc=...; S.exnc=...; S.retc=...}
  • fun (k: (c,_) D.continuation) -> ...fun (k: (c,_) S.continuation) -> ...
  • let handler =let rec handler =
  • D.continue k 0S.continue_with k 0 handler
  • 最後のD.match_with sum_up r handlerS.continue_with (S.fiber sum_up) r handler

最初の3つは自明だろう。Deepの代わりにShallowを使うので、名前を入れ替える必要がある(余談だがここはもしopen Effect.Deepとしていたらそこだけ変えればよかった、がしかしどの型・関数の提供元が変わったのかが明示的にわかる方がメリットがあると思う)

let handler =let rec handler =になるというのは、その次のD.continue k 0S.continue_with k 0 handlerになる変更に対応するためだ。つまりhandlerの中で限定継続に対してcontinueしているわけだが、Deepだとその場合特定のhandlerを指定しなくても現在のものがそのまま使われるのに対してShallowだと明示的に指定する必要が出てくる。なのでhandlerの中でShallow.continue_withにhandlerを再帰的に渡すという処理が出てくる。OCamlでは少し珍しい非関数な再帰だ。

最後のD.match_with sum_up r handlerS.continue_with (S.fiber sum_up) r handlerになるというところには二つの変更点がある。まずD.match_withS.continue_withになっている点、そしてsum_up(S.fiber sum_up)になっている点だ。

D.match_withS.continue_withになることに関しては、Deepがmatch_withtry_withcontinueを提供しているのに対してShallowはこれらに対応するものはcontinue_withしか提供していない。Shallowのeffectfulな処理を実行する場合、必ず処理を限定継続の形にしてハンドラと共にcontinue_withに渡すことになる。

sum_up(S.fiber sum_up)に変わっているというのはまさに「effectfulな処理を関数から限定継続に変換する」というShallow特有のロジックの発露だ。

雑感

個人的にはDeepにも限定継続を作り出せるfiber関数が(関数だけではなくハンドラも渡すような形で)存在してもいいのでは?と思わなくもない。そうすればmatch_withはcontinueに統合できるように思う。またShallowにもtry_withのように「ハンドラにeffcだけ書いてexncとretcを省略する」ような記法があってもいいはず。ここら辺のAPIの整備は今後進んでいくのかもしれない。

そもそもDeepって必要だろうか?DeepでできることはまずShallowでもできるはず(同じハンドラを使うよう指定するだけ)。逆はその限りではなく、Shallowでできるような「限定継続の呼び出しのたびにハンドラを変更する」といった処理は(少なくとも副作用などを使わない限り)Deepではできない。内部実装が違う可能性はあるが、上部だけをみるとDeepはShallowに対するちょっとした糖衣構文のように見える。