コンピュテーション式でキーワード引数

これはF# Advent Calendar 2021の23日分の記事です。

この間の第3回 FUN FAN F#に参加したところ、カスタムオペレーターをキーワード引数のように使っていたのをみて思いついたネタを紹介します。 あくまでネタですので、実際にこのようなAPIを提供するのかどうかの判断は各自に任せます。

カスタムオペレーターの説明は、カスタムオペレーションハンズオンなどを参考にしていただくとして、今回目指すのは以下のようなAPIです。

let str = "F# Advent Calendar 2021"
let res1 = str |> substring { start 3; end_ 9 }
let res2 = str |> substring { start 19; length 4 }
printfn "%s, %s" res1 res2 // => Advent, 2021

これだけだとつまらないので、次のようなものはコンパイルエラーにしたいです。

// end_とlengthは両方指定できないようにしたい
substring { start 3; end_ 9; length: 10 }

// startがないとエラーにしたい
substring { end_ 9 }

// 同じキーワードを複数指定してほしくない
substring { start 3; end_ 9; start: 4 }

また、順不同にしたいですよね。

// 次の2つは同じ
substring { start 3; end_ 9 }
substring { end_ 9; start 3 }

考え方

コンピュテーション式では、Run を提供することでコンピュテーション式で組み立てた結果を Run の引数に渡せるようになります。 ということで、Runオーバーロードして型によって処理を切り分けるということができます。

そして、カスタムオペレーションで許される組み合わせが型で指定できれば、それ以外の組み合わせはエラーにできます。 この方針で作っていきます。

作り方

まずは、設定されていないキーワードを表す型を用意します。

type NotSet = NotSet

次に、カスタムオペレーションを使うので、ビルダークラスに Yield が必要です。

member _.Yield (()) =
  (NotSet, NotSet, NotSet)

Yield はタプルを返すようになっていて、前から順に start, end_, length に対応します*1。 当然、初期段階ではどのキーワードも指定されていないため、すべて NotSet です。

次に、カスタムオペレーションごとに対応するタプルの要素を更新します。 例えばこんな感じです。

[<CustomOperation("end_")>]
member _.End((startParam: 'a, _: NotSet, lengthParam: NotSet), end_: int) =
  (startParam, end_, lengthParam)

タプルの最初の要素(startParam) は end_ を呼び出す前でも後でも設定されてくれればいいので、なんでも受け取れるようにしています。

タプルの2番目の要素は、end_ 自体ですのでこれは End の第2引数を使うため、使いません。 なので、_ で受けています。 また、型を NotSet に限定していますが、これを限定せずに 'b とすることで、何度も設定できてかつ後勝ちになるようなAPIを提供できます。

タプルの最後の要素(lengthParam)は NotSet に限定しています。 これは、end_ オペレーターを呼び出す前に length オペレーターを呼び出していた場合、コンパイルエラーにしたいからです。

このように、

  • このオペレーターが呼び出された場合にエラーにしたいキーワードは NotSet を指定
  • このオペレーターが呼び出された場合にエラーにしたくないキーワードは型パラメーターを指定

することで、変な組み合わせを弾けるようにしています。

最後に、適切な組み合わせの場合に適切な処理をする Run を定義して完成です*2

member _.Run((start: int, end_: int, _: NotSet)) =
  fun (str: string) -> str.Substring(start, end_ - start)

member _.Run((start: int, _: NotSet, length: int)) =
  fun (str: string) -> str.Substring(start, length)

全体

全体はこんな感じです。

type NotSet = NotSet

type SubstringBuilder () =
  member _.Yield (()) =
    (NotSet, NotSet, NotSet)

  [<CustomOperation("start")>]
  member _.Start ((_: NotSet, endParam: 'b, lengthParam: 'c), start: int) =
    (start, endParam, lengthParam)

  [<CustomOperation("end_")>]
  member _.End ((startParam: 'a, _: NotSet, lengthParam: NotSet), end_: int) =
    (startParam, end_, lengthParam)

  [<CustomOperation("length")>]
  member _.Length ((startParam: 'a, endParam: NotSet, _: NotSet), length: int) =
    (startParam, endParam, length)

  member _.Run((start: int, end_: int, _: NotSet)) =
    fun (str: string) -> str.Substring(start, end_ - start)

  member _.Run((start: int, _: NotSet, length: int)) =
    fun (str: string) -> str.Substring(start, length)

let substring = SubstringBuilder ()

ネタばらし?

お気づきの方もいるかもしれませんが、これは型安全なBuilderパターンをコンピュテーション式で再解釈したものですね。 型制約とか使わなくていいので、オリジナルよりも大分シンプルに実装できているような気はします。

余談

今日で35歳になってしまいました。 プログラマの定年なので、年金ください。

追記

効率化とエラーメッセージを改良したバージョンについての記事を書きました。

*1:パラメーターが多くなる場合はタプルじゃなくてレコードにするのがいいでしょう

*2:startを指定しなかったときに0とみなしたい場合はどうすればいいでしょうか。簡単なので、読者への課題とします。