サンプルから学ぶ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.jsonversiondotnet --list-sdks して出てきたバージョンに書き換え(自分の環境では6.0.201)、 paket.dependenciesFSharp.Core のバージョンを 6.0.3 に書き換え、 TP.Runtime.fsprojFSharp.Core のバージョンを 6.0.3 に書き換え、

cd TP
dotnet tool restore
dotnet paket update
dotnet build

したものとして進めます。 他にも、TP.Tests.fsprojTargetFrameworknet6.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自体の実装はコンパイル時に動くものなので、実行時には不要です。 そのため、このように DesignTimeRuntime で分割して、TypeProviderを使う側では Runtime のみを参照するような構成を取るのがおすすめです。 fspoj ファイルを見てみると、DesignTime プロジェクトの参照に IsFSharpDesignTimeProvider という設定が付いていたり、FSharpToolsDirectoryPackagePathtypeproviders が設定されていますが、これらの効果はよくわかっていません。

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

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

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

共通部分

両者で共通する部分を見ると、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 はそのまま渡しているのでいいとして、assemblyReplecementMapDesignTimeアセンブリ名と Runtimeアセンブリ名を渡しています。 これは、DesignTimeRuntime に分割していているので必要になります。 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にするか

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

...

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 オブジェクトを作って、myTypeAddMember しています。 myType は、先ほど作った ProvidedTypeDefinition オブジェクトです。

ProvidedConstructorコンストラクターProvidedParameter listExpr 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 オブジェクトを作って、myTypeAddMember しています。 プロパティー名、プロパティーの型、ゲッターの処理を渡しています。 他にも、セッターの処理や static かどうか、インデクサパラメーターも渡せます。

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

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

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

...

do
  this.AddNamespace(ns, createTypes())

...

最後に、作った ProvidedTypeDefinitionAddNamespace しています。 ここで渡す名前空間名は作ったオブジェクトが持っているんだし、渡さなくてもいいじゃん・・・と思うんですが、渡すようになっています。 ちょっと面倒なので、誰か名前空間名を指定しなくてもいいオーバーロードを追加した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 という名前の ProvidedTypeDefinitionasm を指定して作っています。 静的パラメーターを持つ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 を生成して渡しています。 生成型(isErasedfalse)のTypeProviderが提供する型は、このように ProvidedAssembly を渡す必要があります。

...

asm.AddTypes [ myType ]

...

createType の最後らへんに上のコードがあります。 このように、生成型では ProvidedAssembly に作った ProvidedTypeDefinitionAddTypes してあげる必要があります。 この AddTypesProvidedAssembly に実装されているため、生成型の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 でアクセスできるようになるので地味に便利です。 IsImplicitConstructortrue を設定しない場合は、以下のように自前で諸々実装する必要があります。

// フィールドを用意
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 = ... のような形で型を受けておく必要があり、 メソッドが返す型を静的パラメーターによって決めたい場合には面倒でした。 ProvidedMethodDefineStaticParameters を使うことで、いちいち型を受ける必要がなくなります。

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って用語だったの・・・?