サンプルから学ぶTypeProviderの作りかた

先日の第5回Fun Fan Fsharpで型プロバイダー(TypeProvider)の話がありました。 最近は全然TypeProviderを触っていなかったのですが、久しぶりに触ってみるかー、と思って色々触ってみました。 発表資料でもあったように、イマドキは dotnet コマンドでテンプレートが提供されているんですねぇ・・・

TypeProviderとは

コンパイル時に型(Type)を提供する(Provide)しくみです。 分かりやすいのはJSONとかCSVとかに対するものでしょうか。

#r "nuget: FSharp.Data"
open FSharp.Data

// JsonProviderを使って、[{"name": "aaa", "age": 20}]という形に対応する型を提供してもらう
// <...>の中が「静的パラメーター」で、JsonProviderはこの情報を使って型を作り、提供する
type Users = JsonProvider<"""[{"name": "aaa", "age": 20}]""">

// データをパース(本来ならAPI叩いて取ってくるようなイメージ)
let users = Users.Parse("""
  [ {"name": "Ken", "age": 25},
    {"name": "Bob", "age": 30} ]
""")
for user in users do
  // NameやAgeといった表記でアクセス可能。当然、コード補完も効く。
  let name = user.Name
  let age = user.Age
  printfn $"%s{name}:%d{age}"

これらはTypeProvider以前では、コード生成や文字列ベースでのアクセスで解決していた問題です。 「データを取ってきていじる」系のコードはTypeProviderによってコード補完が効くようになり、非常に書きやすくなりました。

TypeProviderを作る

そんな便利なTypeProviderですが、作るとなるとハードルが高かったりします。 ちょっとでもハードルを下げられたらな、というのがこの記事の目標です。

準備

発表資料を参考に、

dotnet new -i FSharp.TypeProviders.Templates

して、

dotnet new typeprovider -o TP

して、global.json の version を dotnet --list-sdks して出てきたバージョンに書き換え(自分の環境では6.0.201)、 paket.dependencies の FSharp.Core のバージョンを 6.0.3 に書き換え、 TP.Runtime.fsproj の FSharp.Core のバージョンを 6.0.3 に書き換え、

cd TP
dotnet tool restore
dotnet paket update
dotnet build

したものとして進めます。 他にも、TP.Tests.fsproj の TargetFramework を net6.0 に書き換えました。

ProvidedTypes.fs / ProvidedTypes.fsi

TypeProviderを作るためにほぼ必須なのがこれらのファイルです*1。 発表資料通りにやれば、paket-files/fsprojects/FSharp.TypeProviders.SDK/src の下にダウンロードされてきます。

これらのファイル、非常に重要なのですが、ProvidedTypes.fs はなんと16,000行程度ある超巨大ファイルです。 さすがにこれを全部理解するのは難しいというのが、TypeProvider自作のひとつのハードルになっているのではないか、と思います。 ProvidedTypes.fsi は560行なので、必要になるAPIは限られるもののそれでも大きいです。

そこで今回は、テンプレートによって生成されるサンプルを解説することで、 TypeProviderを作り始められる必要最小限の理解が得られることを目標にします。

テンプレートで生成されるプロジェクト

テンプレートで生成されるプロジェクトは、3つあります。

まずは DesignTime プロジェクトです。 このプロジェクトには、TypeProviderの実装が含まれる、一番重要なプロジェクトです。 アセンブリには TypeProviderAssembly 属性を含むようにします。 このプロジェクトは、コンパイル時に必要となる実装を含むようにします*2。

次に Runtime プロジェクトです。 このプロジェクトには、DesignTime のdllを指定する TypeProviderAssembly 属性を含むようにします。 TypeProvider自体の実装はコンパイル時に動くものなので、実行時には不要です。 そのため、このように DesignTime と Runtime で分割して、TypeProviderを使う側では Runtime のみを参照するような構成を取るのがおすすめです。 fspoj ファイルを見てみると、DesignTime プロジェクトの参照に IsFSharpDesignTimeProvider という設定が付いていたり、FSharpToolsDirectory と PackagePath に typeproviders が設定されていますが、これらの効果はよくわかっていません。

最後に Test プロジェクトです。 Runtime プロジェクトを参照していて、実際にTypeProviderを使うようなコードになっています。

テンプレートで生成されるサンプルのTypeProvider

テンプレートで生成されるコードには、2つのTypeProviderの実装がサンプルとして含まれています。 BasicErasingProvider と BasicGeneraGenerativeProvider です。

共通部分

両者で共通する部分を見ると、TypeProviderってこんなものなのか、という雰囲気が掴めるかもしれません。

  • TypeProvider 属性が付いている
  • コンストラクターで TypeProviderConfig を受け取っている
  • TypeProviderForNamespaces を継承している
  • createType(s) の中で ProvidedTypeDefinition を作っている
    • 作ったオブジェクトに対して AddMember を呼び出している
    • 最終的には、作ったオブジェクトを返している(リストに包むかどうか、という違いはある)
  • コンストラクターの処理(do の部分)で this.AddNamespace を呼び出している
    • 第二引数には ProvidedTypeDefinition オブジェクトのリストを渡している

大体はこんなところです。 TypeProviderの重要な部分は、上で大体まとまっています。

BasicErasingProvider

では、BasicErasingProvider がどのように実装されているのかについて見てみましょう。

[<TypeProvider>]
type BasicErasingProvider (config : TypeProviderConfig) as this =
  inherit TypeProviderForNamespaces (
    config,
    assemblyReplacementMap=[("TP.DesignTime", "TP.Runtime")],
    addDefaultProbingLocation=true)

    ...

まず、基底クラス TypeProviderForNamespace のコンストラクターに色々と渡しています。 config はそのまま渡しているのでいいとして、assemblyReplecementMap に DesignTime のアセンブリ名と Runtime のアセンブリ名を渡しています。 これは、DesignTime と Runtime に分割していているので必要になります。 Runtime を通じて DesignTime の機能を使うための記述という理解です。 ですので、アセンブリを分割しない場合は不要です。

また、他にも addDefaultProbingLocation=true を指定しています。 これを true にすると、アセンブリの探索ディレクトリに実行中のアセンブリ(Assembly.GetExecutingAssembly で取れるアセンブリ)の場所が追加されます。 指定しない場合は false 扱いですが、今回のように単独で完結するようなTypeProviderの場合 false でも問題ないはずです*3。

...

let myType = ProvidedTypeDefinition(asm, ns, "MyType", Some typeof<obj>)

...

ProvidedTypeDefinition が、このTypeProviderによって提供される型です。 ここではアセンブリと名前空間と型名とベースの型を渡して作っています*4。 消去型のアセンブリには Assembly.GetExecutingAssembly() したものを渡してあげる必要があるようです。

そして、ベースの型に obj を指定しています。 この「ベースの型」というのは非常に重要なものなのですが、サンプルや説明ではよく obj が使われていて存在意義がよく分からないものになってしまっています。 消去型においては、ベースの型はコンパイル後に現れる型になります*5。 例えば、冒頭で紹介した JsonProvider は消去型で、サンプルでのベースの型は IJsonDocument[] です。 そのため、users は配列として扱えます(なので、forで回せていたのです)。 サンプルを改変して、JsonProvider に与えるJSONを変えてみましょう。

#r "nuget: FSharp.Data"
open FSharp.Data

type User = JsonProvider<"""{"name": "aaa", "age": 20}""">

let user = User.Parse("""
  {"name": "Ken", "age": 25}
""")
// 当然、今までのようにプロパティーでのアクセスもできるが・・・
let name = user.Name
let age = user.Age
printfn $"%s{name}:%d{age}"
// JsonDocumentとしても使える
match user.JsonValue with
| JsonValue.Record xs -> printfn $"%A{xs}"
| _ -> printfn ""

この場合、ベースの型は JsonDocument となります。 消去型では、上の例で user.Name のようなアクセスはベース型での操作に裏で変換されるような実装になっていることが多いです。 また、JsonProvider の例のように、ベースの型はTypeProviderに渡される情報によって変わる場合も多く見られます。

そのほか、以下のような追加のオプション引数も指定できます。

引数名 型 デフォルト値 意味
hideObjectMethods bool false objのメソッドを隠すかどうか
nonNullable bool false nullを弾くかどうか*6
isErased bool true 消去型かどうか
isSealed bool true sealedにするか
isInterface bool false interfaceにするか
isAbstract bool false abstractにするか

ちなみに、hideObjectMethods は TypeProviderEditorHideMethods なる属性によって実現されているようです。

...

let ctor = ProvidedConstructor(
  [],
  invokeCode = fun args -> <@@ "My internal state" :> obj @@>
)
myType.AddMember(ctor)

let ctor2 = ProvidedConstructor(
  [ProvidedParameter("InnerState", typeof<string>)],
  invokeCode = fun args -> <@@ (%%(args.[0]):string) :> obj @@>
)
myType.AddMember(ctor2)

...

ProvidedConstructor オブジェクトを作って、myType に AddMember しています。 myType は、先ほど作った ProvidedTypeDefinition オブジェクトです。

ProvidedConstructor のコンストラクターは ProvidedParameter list と Expr list -> Expr を受け取ります。 ctor2 をF#のコードで書くとするなら、こんな感じです。

new (InnerState: string) =
  InnerState :> obj

このように、TypeProviderを作るにはコード引用符によるプログラミングにある程度慣れている必要があります。 また、消去型ではコンストラクターで返す値はベースの型を一致させておきましょう*7。

...

let innerState = ProvidedProperty(
  "InnerState",
  typeof<string>,
  getterCode = fun args -> <@@ (%%(args.[0]) :> obj) :?> string @@>
)
myType.AddMember(innerState)

...

コンストラクターの時と同じように、ProvidedProperty オブジェクトを作って、myType に AddMember しています。 プロパティー名、プロパティーの型、ゲッターの処理を渡しています。 他にも、セッターの処理や static かどうか、インデクサパラメーターも渡せます。

このプロパティーは非 static なので、getterCode の最初の引数に this が渡されます。 消去型における this は、コンストラクターで返した値になります。

プロパティーに限らず、非 static なメンバーは最初の引数に this が入ってきます。 逆に、static なメンバーは this に相当する値は入ってきませんので、引数のインデックスの管理には注意が必要です。

他にも ProvidedMethod や ProvidedParameter を使ってメンバーを追加していますが、あとは似たような感じで読み下せるはずです。

...

do
  this.AddNamespace(ns, createTypes())

...

最後に、作った ProvidedTypeDefinition を AddNamespace しています。 ここで渡す名前空間名は作ったオブジェクトが持っているんだし、渡さなくてもいいじゃん・・・と思うんですが、渡すようになっています。 ちょっと面倒なので、誰か名前空間名を指定しなくてもいいオーバーロードを追加したpull request投げるといいんじゃないでしょうか。

BasicGenerativeProvider

BasicGenerativeProvider は違う部分を中心に見てみましょう。 先頭から見るとわかりにくいので、まずは決定的に違う部分である、静的パラメーターまわりから見てみます。 使う側を確認しておきます。

// GenerativeProviderに静的パラメーター2を渡している
type Generative2 = TP.GenerativeProvider<2>

サンプルでは生成型のみ静的パラメーターを使っていますが、静的パラメーターは消去型でも使える点には注意が必要です。 静的パラメーターに関する記述はこのあたりです。

...

let myParamType = 
  let t = ProvidedTypeDefinition(asm, ns, "GenerativeProvider", Some typeof<obj>, isErased=false)
  t.DefineStaticParameters(
    [ProvidedStaticParameter("Count", typeof<int>)],
    fun typeName args -> createType typeName (unbox<int> args.[0]))
  t
do
  this.AddNamespace(ns, [myParamType])

...

まず、GenerativeProvider という名前の ProvidedTypeDefinition を asm を指定して作っています。 静的パラメーターを持つTypeProviderはTypeProvider自体を表す ProvidedTypeDefinition と、そのTypeProviderが返す型を表す ProvidedTypeDefinition が必要です。 上の ProvidedTypeDefinition は前者の「TypeProvider自体を表す ProvidedTypeDefinition」です。

そして、DefineStaticParameters メソッドにより静的パラメーターを定義しています。 引数は静的パラメーターの引数の情報(名前と型((第三引数としてデフォルト値も設定可能です。)))のリストと、そのTypeProviderが返す型を作るための関数です。 関数の引数に typeName が渡されてきますので、それを使ってそのTypeProviderが返す型を表す ProvidedTypeDefinition を作ります*8。

次に、TypeProviderが返す型を表す ProvidedTypeDefinition を作って返す createType 関数の中身を見てみます。

...

let asm = ProvidedAssembly()
let myType = ProvidedTypeDefinition(asm, ns, typeName, Some typeof<obj>, isErased=false)

...

アセンブリに ProvidedAssembly を生成して渡しています。 生成型(isErased が false)のTypeProviderが提供する型は、このように ProvidedAssembly を渡す必要があります。

...

asm.AddTypes [ myType ]

...

createType の最後らへんに上のコードがあります。 このように、生成型では ProvidedAssembly に作った ProvidedTypeDefinition を AddTypes してあげる必要があります。 この AddTypes が ProvidedAssembly に実装されているため、生成型のTypeProviderが提供する型は ProvidedAssembly を使う必要があるのです。

この例では静的パラメーターで指定した数値を使って、その個数分のプロパティーを生やしています。 消去型でも同じようにできますので、生成型の例を参考にして書いてみると理解が進むかもしれません。

サンプルで使っていない機能

サンプルで使っていない機能も ProvidedTypes.fs には多く含まれています。 ここからは、そのなかから2つを選んで紹介します。

implicit constructor

まずは、ProvidedConstructor にある IsImplicitConstructor プロパティーです。 これを true にすることで、プライマリコンストラクター*9と同じようなコードが生成されます。 実際にフィールドを生成する必要があるため、生成型のみで使えます。 例えば、

let ctor = ProvidedConstructor(
  [ProvidedParameter("x", typeof<int>)],
  invokeCode = fun args -> <@@ null @@>)
ctor.IsImplicitConstructor <- true

とすることで、

let p = ProvidedProperty(
  "X", typeof<int>, getterCode = fun args -> Expr.GlobalVar("x"))

のように、Expr.GlobalVar でアクセスできるようになるので地味に便利です。 IsImplicitConstructor に true を設定しない場合は、以下のように自前で諸々実装する必要があります。

// フィールドを用意
let field = ProvidedConstructor("state", typeof<int>)
myType.AddMember(field)
// コンストラクターを容易
let ctor = ProvidedConstructor(
  [ProvidedParameter("x", typeof<int>)],
  // 用意したフィールドに設定するコードをExprとして書く
  invokeCode = fun args -> Expr.FieldSet(args[0], field, args[1]))
myType.AddMember(ctor)

// アクセスはGlobalVarではなく、FieldGetを使う
let p = ProvidedProperty(
  "X", typeof<int>, getterCode = fun args -> Expr.FieldGet(args[0], field))
myType.AddMember(p)

ProvidedMethodのDefineStaticParameters

F#4.0で入った拡張に、メソッドにも静的パラメーターが持てるようになった、というものがあります。 静的パラメーターを持つTypeProviderは type T = ... のような形で型を受けておく必要があり、 メソッドが返す型を静的パラメーターによって決めたい場合には面倒でした。 ProvidedMethod の DefineStaticParameters を使うことで、いちいち型を受ける必要がなくなります。

let res = TP.Regex.TypedMatch<"(?<First>.)(?<Rest>.*)">("hoge")
Assert.AreEqual("h", res.First)
Assert.AreEqual("oge", res.Rest)

こんな感じに、TypedMatch メソッドの静的パラメーターにパターンを指定して、 返ってきたオブジェクトに対してグループ名を使って結果にアクセスしています。 マッチに失敗した場合の考慮などしておらず不完全な実装ですが、こんなこともできるよ、という例として考えていただければ。

[<TypeProvider>]
type RegexTypeProvider (config: TypeProviderConfig) as this =
  inherit TypeProviderForNamespaces(config, assemblyReplacementMap=[("TP.DesignTime", "TP.Runtime")])

  let ns = "TP"
  let asm = Assembly.GetExecutingAssembly()
  let providedType = ProvidedTypeDefinition(asm, ns, "Regex", Some typeof<obj>, hideObjectMethods=true)

  // 戻り値の型を作る
  let createRetType typeName props =
    // asm, nsを指定せずにProvidedTypeDefinitionを作る
    let t = ProvidedTypeDefinition(typeName, Some typeof<Match>, hideObjectMethods=true)
    let ctor = ProvidedConstructor([ProvidedParameter("res", typeof<Match>)], fun args -> args[0])
    t.AddMember(ctor)
    // 静的パラメーターの情報を使ってプロパティを生やす
    for prop in props do
      let p = ProvidedProperty(prop, typeof<string>, getterCode = fun args -> <@@
        let r = (%%(args[0]) : Match)
        r.Groups[prop].Value
      @@>)
      t.AddMember(p)
    // thisにAddNamespaceするのではなく、providedTypeにAddMemberする(ネストしたクラスとして定義)
    providedType.AddMember(t)
    t

  // メソッドの実体を作る
  let createMethod methodName pattern =
    let props = Regex(pattern).GetGroupNames() |> Array.toList
    let retType = createRetType ("RetType" + methodName) props
    let meth = ProvidedMethod(
      methodName,
      [ProvidedParameter("str", typeof<string>)],
      // 戻り値の型を作った型にする
      retType,
      isStatic = true,
      invokeCode = fun args -> <@@ Regex.Match(%%(args[0]), pattern) @@>)
    providedType.AddMember(meth)

    meth

  do
    // 静的パラメーターを取るメソッドの定義
    let meth = ProvidedMethod("TypedMatch", [], typeof<unit>, isStatic=true)
    meth.DefineStaticParameters(
      [ProvidedStaticParameter("Pattern", typeof<string>)],
      // ProvidedMethodを返すようにする
      fun methodName args -> createMethod methodName (args[0] :?> string)
    )
    providedType.AddMember(meth)
    this.AddNamespace(ns, [providedType])

試していませんが、ProvidedTypeDefinition にも静的パラメーターを定義して、両方の静的パラメーターを使う、 ということもできると思います。 ProvidedTypeDefinition 側に接続文字列を、ProvidedMethod 側にSQLを置いていい感じに型を生成するようなものもできるかもしれません。

まとめ

TypeProviderをとりあえず作り始められるようになるための情報は一通り説明できたと思います。 これで、あとは今まで自動生成で解決していたものとか、文字列ベースのアクセスをしていたものとかをTypeProviderに置き換えるだけですね!

*1:いにしえの時代にはITypeProviderを直接実装してTypeProviderを作ったのですが、今さすがにそういう人はほぼいないと思います。そもそもTypeProviderを作る人が少ないですが・・・

*2:サンプルのfsprojでは、Runtime側のファイルを含むような構成になっています。このように、実行時に必要なものはRuntime側に置き、コンパイル時にも必要になるものはDesignTime側にも含めるような構成にします。

*3:なんでこのサンプルがtrueにしているのかは謎

*4:アセンブリと名前空間を指定しないコンストラクターもありますが、こちらはネストした型を作る際のものなので、トップレベルの型の提供には使いません。

*5:生成型では基底クラスになります。

*6:これのデフォルトがfalseなのは、F#3.0時代はこれがサポートされていなかったためだと思われます。この機能はF#4.0から入りました。

*7:場合によっては一致させなくても問題ないこともあるんですが、一致させておくのが無難です。

*8:ここで違う名前を使うとエラーになります。

*9:え、これImplicit Constructorって用語だったの・・・?

コンピュテーション式でキーワード引数 その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:例外を投げずに、引数をそのまま返すようにしても大丈夫です

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

これは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とみなしたい場合はどうすればいいでしょうか。簡単なので、読者への課題とします。

プログラミング言語の構文って大事だよね、という話

Twitterでこんなつぶやきをしました。

が、ちょっと違う感じに受け取られたかな、と思ったので補足します。

「プログラミング言語としての」とは

つぶやきの中でいちいち「プログラミング言語としての」と書いたのは、 「普通の」SQLや「普通の」XSLTとは違って、ということを明示したかったからです。

普通はSQLは問合せのための言語だし、XSLTはXMLを変換するための言語*1であって、JavaやC#などのプログラミング言語と同じようなものと見ている人はほぼいないでしょう。 ただ、ちょっとしたテクニックを知っていると、これらを使って「普通の」プログラミング言語でやるような処理が書けてしまうのです。

XSLTでプログラミング

XSLTにはループや分岐が組み込まれているので、手続き型プログラミングであればどうとでもなります。 なりますが、それでは面白くないのでループを封印してみましょう。

ループを使わないXSLTプログラミング

XSLTでは template を関数とみなすことで、ループを使わなくても再帰呼び出しが使えます。 あとは高階関数が使えればざっくり関数プログラミング的なものが出来ますね。 詳しい説明は そのアイディアを形にした人が書いたドキュメント に譲るとして、 そのテクニックを使うことでFizzBuzzが range 関数と map 関数と fizzbuzz 関数を組み合わせることで書けてしまいます。

長いので折り畳みましたが、これと同じようなことを F# で書くとこうなります。

let rec range from ``to`` =
  if from <= ``to`` then from :: range (from + 1) ``to`` 
  else []

let rec map f = function
| [] -> []
| x::xs -> f x :: map f xs

let fizzbuzz n =
  if n % 15 = 0 then "FizzBuzz\n"
  elif n % 5 = 0 then "Buzz\n"
  elif n % 3 = 0 then "Fizz\n"
  else string n + "\n"

let xs = range 1 100
xs |> map fizzbuzz |> List.iter (printf "%s")

大体機械的に対応は取れると思います。

SQLでプログラミング

SQLは再帰クエリーによって再帰が書けます。 しかし、XSLTとは違い関数に対応させられるようなものが(SELECT文の範疇では)ありませんし、高階関数など夢のまた夢です。 そのため、SQLでのプログラミングはXSLTよりも困難です。

ただ、再帰クエリーでも使う WITH を「入力固定の関数」のようなものとみなすことで、ある程度の構造化は可能です。

-- 最近PostgreSQLを使っているのでPostgreSQL想定
WITH RECURSIVE range_ (n_) AS (
  SELECT 1
  UNION ALL
  SELECT n_ + 1 FROM range_ WHERE n_ < 100
)
, fizzbuzz_ (n_, result_) AS (
  SELECT
      n_
    , CASE
        WHEN n_ % 15 = 0 THEN 'FizzBuzz'
        WHEN n_ % 5 = 0 THEN 'Buzz'
        WHEN n_ % 3 = 0 THEN 'Fizz'
        ELSE CAST(n_ AS varchar)
      END
  FROM
    range_
)
SELECT * FROM fizzbuzz_;

さらに複雑なことがしたい場合、空白区切りなどの文字列をリストと見立てて操作することでよりいろいろなことができるようになります*2。

構文って大事

こんな感じで、XSLTでプログラミングしようとすると大量のノイズで本来書きたい処理はタグの中に埋もれてしまいますし、 SQL(select)でプログラミングしようとするとそもそも関数からしてないので考え方からして変えないといけません(そのための例としてはFizzBuzzは小さすぎたかも)。 SQLで複雑なやつだと、 SQL で数式を評価 (完全版 + α) - ぐるぐる~ あたりがオススメです。数式評価だけど、演算子の優先順位を設定可能な完全に頭おかしいやつです。

ということで、他の人にはあまりオススメできない、オススメの構文のありがたみが体感できる方法でした。 制約された環境であれこれ考えるのが好きな人であればあるいは・・・

*1:言語?ツール?

*2:配列型やJSONを使うという選択肢もあるけどそれだとツマラナイ

F#のパーサーに対する改良が入るかも?

F# Advent Calendar 2020の14日目のエントリーです。

そろそろネタ切れですが、今後入るかもしれない改良の紹介です。

RFC FS-1083

すごい地味なRFCですが、今年のF# Advent CalendarでもあったSRTP(Statically Resolved Type Parameters: 静的に解決される型パラメーター)に関わる改良です。

現状、次のコードはコンパイルできません。

let inline f<^a>: ^a = failwith "error"

現状のF#でコンパイルを通すためには、まず < と ^ の間に空白を入れ、 < ^a> とする必要があります。 おさまりが悪いので、 > の前にも空白を入れ、 < ^a > などとします。

次に、 > と : の間にも空白を入れる必要があります。

つまり、これならコンパイルが通ります。

let inline f< ^a > : ^a = failwith "error"

// 普通の型パラメーターの場合は最初の空白は不要
let g<'a> : 'a = failwith "error"

最初の空白の問題は、 <^ という演算子を定義できるようにするため <^ を一つのトークンとして扱ってしまうのが原因です。 これを、型パラメーターの位置では分割して扱えるようにパーサーを直そう、というのがRFC FS-1083です。

二つ目の問題も >: という一つのトークンとして扱ってしまう点では同じ原因*1ですが、RFC FS-1083の範囲には入っていないようにも見えます。

こういう地味に面倒な問題も解消されていくと嬉しいですね。

*1:なんだけど、実際には「コロンが演算子に含められなくなったよ」という別のエラーに先に引っかかる

nameofの罠

F# Advent Calendar 2020の11日目のエントリーです。

5日目に続いてまた nameof の話です。

nameof にはF#のプログラムとしては当然だけど忘れがちな制約があります。

何の問題もないように見えますが、これはコンパイルエラーです。

let f () = nameof f

これがダメなのは、 f は f の定義本体ではまだ見えないのに参照しようとしているからです。

let f x =
  match x with
  | 0 -> 0
  | x -> f (x - 1) + x

これがダメなのと同じ理由ですね。

なので、もしこういうことがしたければ、

let rec f () = nameof f

のように rec を付ける必要があります。 再帰していないのに rec を付けるのは気持ち悪い気もしますが、これは仕方ないでしょう。

ちなみに、メソッドは定義本体で自分自身が見えるので、次のコードはコンパイルが通ります。

type t () =
  static member F() = nameof t.F

ネストしたレコードの更新が楽になるかも?

F# Advent Calendar 2020の9日目のエントリーです。

今日は(も?)、入ると嬉しいRFCの話です。

RFC FS-1049

RFC FS-1049は、 ネストしたレコードのフィールドを with で書き換えられるように拡張しよう、というものです*1。

F#のレコードは with を使うことで、一部のフィールドのみを更新した新しいレコードが作れます。

type Customer =
  { Name: string
    Address: string
    Age: int }

// NameとAddressはそのままにAgeをインクリメントする関数
let incrAge customer =
  { customer with Age = customer.Age + 1 }

しかし、ネストしたレコードを更新するためには with もネストして使う必要があります。

例えば、

type A = { X: B }
and B = { Y: C }
and C = { Z: string }

let a = { X = { Y = { Z = "str" } } } 

こういうレコードがあった場合に Z を更新するには、

let a' = { a with X = { a.X with Y = { a.X.Y with Z = "updated" } } }

のようにしなければなりません。

RFC FS-1049は、こう書けるようにしようという提案です。

let a' = { a with X.Y.Z = "updated" }

とてもシンプルになりましたね。

ただこれ、フィールドが option とかだとおそらく結局ネストが必要です。

type A = { X: B }
and B = { Y: C option }
and C = { Z: string }

let a = { X = { Y = Some { Z = "str" } } } 
let a' = { a with X.Y = a.X.Y.map(fun y -> { y with Z = "updated" }) }

まぁ、以前より便利になることは確実なので、入ってほしい拡張ではありますね。

*1:もともとの提案は、「ファーストクラスのLens入れようぜ!」というものだったので大分おとなしいところに着地した感はあります。