"コンピュテーションビルダーに機能を後付けする"の補足?

アドベントカレンダーとその補足記事で色々と賑わっていて素晴らしいですね。 と、前置きはここまでにしておいて。

コンピュテーション式の変形後を覗き見るを改良する - ぐるぐる~

さて、この記事に下記の記述があります。

さて、コンピュテーションビルダーに対する機能の追加ですが、方法としては以下のものがあるでしょう。

  1. コンピュテーションビルダーの書き換え
  2. 既存のコンピュテーションビルダーを継承して機能を追加
  3. 型拡張として機能を追加
  4. 拡張メソッドとして機能を追加

ここでは4つ紹介されていますが、実はもう一つ方法があります。

コンピュテーションビルダーをラップしたコンピュテーションビルダーを作る

asyncquery といった builder-expr はビルダーオブジェクトが変数束縛されているだけの、ごく普通の存在です。*1 なので、ビルダーの持つメソッドを呼び出すことが可能です。

これを利用してビルダーをラップすることで、後付っぽいことを行います。

試しに、 SimpleBuilder をラップしてみましょう。

// 既存の SimpleBuilder
type SimpleBuilder () =
  member __.Return(x) = x
  member __.Bind(x, f) = f x

let simple = SimpleBuilder ()

module Print =

  type SimpleBuilder () =
    member this.Return(x) =
      (sprintf "$b0.Return(x)", simple.Return(x))
    member this.Bind(x, f) =
      let trace, res = simple.Bind(x, f)
      sprintf "$b0.Bind(%A, fun x -> %s)" x trace, res
    member this.Delay(f) = f
    member this.Run(f) =
      let trace,res = f ()
      printf "let $b0: SimpleBuilder = simple\n%s" trace
      res

  let simple = SimpleBuilder()

// モジュールをオープンすればラッパーのほうに切り替わる
open Print

let res = simple {
  let! x = 10
  return x
}

出力は以下の通りです。

let $b0: SimpleBuilder = simple
$b0.Bind(10, fun x -> $b0.Return(x))

使いどころ

使い道は現時点で3つくらい考えられます。

作ろうと思ったメソッドが既に存在する

同じシグネチャを持つメソッドを型拡張や拡張メソッドで後付することはできません。 この場合は、ソースコードを入手できない場合はラップする以外の選択肢がありません。

式木は重過ぎるけど、介入はしたい

コンピュテーション式にデバッグロガーを仕込みたい場合などの、各メソッド適用後の値に介入したい場合に使います。

適用前に介入したい場合は Source という手もありますが、あれは特定の構文にしか現れないので、汎用性に乏しいです。 とはいえ、ラップするより型拡張のほうが楽なのは確かです。

ユーザに既存ビルダーの一部機能を使わせたくない

「このビルダー、この実装以外はいいのに!」という状況や、Source メソッドに介入されてうまくコンパイルできない場合などに使えます。

おすすめ利用順序

機能の後付方法として、個人的なおすすめは

  1. 型拡張
  2. ラップ
  3. コンピュテーションビルダーの書き換え

にの順になります。

型拡張はやっぱり楽なのと、モジュールを分けておけば既存のものを壊さずに済むので、可能であればこれで済ませたいです。

その次はラップです。 多少面倒くさいとはいえ、自由度に機能を追加できます。

ビルダー自体を書き換えるのは、破壊的変更を生み出しかねないので慎重になるべきだと考えています。

継承はあまりお勧めしません。 コンピュテーションビルダーのメソッドがオーバーライドできる可能性は低いからです。

まとめ

たとえ標準の async だろうと、介入方法はいくらでもあるよというのが言いたかっただけです(ただしドキュメントの仕様から外れるリスクもあります)。

*1:seq だけは例外で、あれはシグネチャを見るとわかるとおり関数です