サンプルから学ぶ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 ()
コンピュテーション式でキーワード引数
これは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歳になってしまいました。 プログラマの定年なので、年金ください。
追記
プログラミング言語の構文って大事だよね、という話
Twitterでこんなつぶやきをしました。
プログラミング言語としてのSQL(select)とか、プログラミング言語としてのXSLTとかやると、構文大事って実感持ててオススメ。オススメはしない。
— ぐるぐる系SQL (@bleis) 2021年4月24日
が、ちょっと違う感じに受け取られたかな、と思ったので補足します。
「プログラミング言語としての」とは
つぶやきの中でいちいち「プログラミング言語としての」と書いたのは、 「普通の」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 で数式を評価 (完全版 + α) - ぐるぐる~ あたりがオススメです。数式評価だけど、演算子の優先順位を設定可能な完全に頭おかしいやつです。
ということで、他の人にはあまりオススメできない、オススメの構文のありがたみが体感できる方法でした。 制約された環境であれこれ考えるのが好きな人であればあるいは・・・
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の範囲には入っていないようにも見えます。
こういう地味に面倒な問題も解消されていくと嬉しいですね。
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入れようぜ!」というものだったので大分おとなしいところに着地した感はあります。