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

コンピュテーション式でキーワード引数を改良しました。 前回の記事では、以下のような欠点がありました。

  • 実行効率が悪い
  • エラーメッセージが分かりにくい

今回はこの2つの欠点を解決してみましょう。

実行効率をよくする

ビルダーのメソッドに inline 指定をすればいい感じになります。 このようなコードを考えてみます。

let f () =
  "hoge hoge" |> substring { start 5; end_ 7 }

このコードを inline を付けずにコンパイルしたILはこのようになります。

IL_0000: call      class Program/SubstringBuilder Program::get_substring()
IL_0005: ldc.i4.5
IL_0006: ldc.i4.7
IL_0007: call      class Program/NotSet Program/NotSet::get_NotSet()
IL_000c: newobj    instance void class System.Tuple`3<int32,int32,class Program/NotSet>::.ctor(!0,
                                                                                               !1,
                                                                                               !2)
IL_0011: callvirt  instance class Microsoft.FSharp.Core.FSharpFunc`2<string,string> Program/SubstringBuilder::Run(class System.Tuple`3<int32,int32,class Program/NotSet>)
IL_0016: ldstr     "hoge hoge"
IL_001b: tail.
IL_001d: callvirt  instance !1 class Microsoft.FSharp.Core.FSharpFunc`2<string,string>::Invoke(!0)
IL_0022: ret

F#コードにするとこんな感じでしょうか。

let f () =
  "hoge hoge" |> substring.Run((5, 7, NotSet))

タプルを構築したり、Run で返ってきた関数を呼び出していることがわかります。 それに対して、inline を付けてコンパイルするとこのようになります。

IL_0000: ldstr    "hoge hoge"
IL_0005: ldc.i4.5
IL_0006: ldc.i4.2
IL_0007: callvirt instance string System.String::Substring(int32,
                                                           int32)
IL_000c: ret

これをF#コードにするとこんな感じです。

let f () =
  "hoge hoge".Substring(5, 2)

すべて展開され、単なる Substring 呼び出しにできています*1

エラーメッセージをわかりやすくする

このようなコードを考えてみます。

let f () =
  "hoge hoge" |> substring { start 5; end_ 7; length 2 }

ここで、end_ 7 の部分がエラーになり、エラーメッセージはこのようになります。

error FS0193: 型の制約が一致しません。次の型
    'int * int * NotSet'
は次の型と互換性がありません
    'int * NotSet * NotSet'

これは、元のコードが意味的には次のコードと同じになるためです。

let b = substring
"hogehoge" |> b.Run(b.Length(b.End(b.Start(b.Yield(()), 5), 7), 2))

End の戻り値の型は 'a * int * NotSet であり、Length の引数の型は 'a * NotSet * NotSet なので、 このようなエラーメッセージになるのです*2。 丁寧に読み解けばわからなくはないメッセージですが、わかりやすいとは言えません。

これを解決する一つの方法としては、NotSet の部分にメッセージを埋め込む方法が考えられますが、ちょっと無理矢理感が高いです。

F#6.0からカスタムオペレーターのメソッドがオーバーロードできるようになったので、 これと CompilerMessage 属性を組み合わせるといい感じにコンパイルエラーメッセージが作れます。

[<CustomOperation("length")>]
[<CompilerMessage("lengthはend_と一緒には使えません", 10001, IsError=true)>]
member _.Length ((_: 'a, _: int, _: NotSet), _: int) : 'a * int * NotSet =
  failwith "oops!"

Length メソッド側にオーバーロードを追加し、end_ が設定されている、 つまりは第一引数のタプルの第2要素が int になっているオーバーロードが選択された場合に、 コンパイルエラーにしてしまうのです。

そのため、length 2 の部分がエラーになり、エラーメッセージはこのようになります。

error FS10001: lengthはend_と一緒には使えません

以前は合致するメソッドが見つからなかったため、End 側がエラーになっていましたが、 今回は合致するオーバーロードが見つかったうえでコンパイルエラーになるため、 エラーの場所もより自然な場所に出るようになっています。

また、戻り値の型を指定しています。 戻り値の方を指定しない場合は戻り値の型がジェネリックになります。 Runオーバーロードされており、戻り値の型がジェネリックだと適切な Run が選べません。 そのため、substring の部分にもコンパイルエラーが出てしまいます。 戻り値の型指定は、これを避けるために付けています*3

全体

今回のコードの全体はこのようになります。

type NotSet = NotSet

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

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

  [<CustomOperation("start")>]
  [<CompilerMessage("startは2回指定できません", 10001, IsError=true)>]
  member _.Start ((_: int, _: 'b, _: 'c), _: int) : int * 'b * 'c = failwith "oops!"

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

  [<CustomOperation("end_")>]
  [<CompilerMessage("end_は2回指定できません", 10001, IsError=true)>]
  member _.End ((_: 'a, _: int, _: 'b), _: int) : 'a * int * 'b = failwith "oops!"

  [<CustomOperation("end_")>]
  [<CompilerMessage("end_はlengthと一緒には使えません", 10001, IsError=true)>]
  member _.End ((_: 'a, _: NotSet, _: int), _: int) : 'a * NotSet * int = failwith "oops!"

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

  [<CustomOperation("length")>]
  [<CompilerMessage("lengthは2回指定できません", 10001, IsError=true)>]
  member _.Length ((_: 'a, _: 'b, _: int), _: int) : 'a * 'b * int = failwith "oops!"

  [<CustomOperation("length")>]
  [<CompilerMessage("lengthはend_と一緒には使えません", 10001, IsError=true)>]
  member _.Length ((_: 'a, _: int, _: NotSet), _: int) : 'a * int * NotSet = failwith "oops!"

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

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

let substring = SubstringBuilder ()

*1:startにもend_にも定数を指定しているため、Substringの第2引数が計算されてやはり定数になっています

*2:'aがintになっているのは、b.Startによって'aがintに固定化されたため

*3:例外を投げずに、引数をそのまま返すようにしても大丈夫です