サンプルから学ぶ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って用語だったの・・・?