SRTP入門
はじめに
この記事は F# アドベントカレンダー 2020 の8日目の記事となります。
F# において SRTP (= Statically Resolved Type Parameters) は非常に強力な機能の一つです。また、この機能は C# に存在しないため F# と C# の差別化ポイントでもあります。
しかし、日本語記事や英語記事にはこれの入門となるような記事が存在していません。
そのため今回はSRTPの基本となる部分についてわかりやすく紹介していこうと思います。
基本構文
何はともあれまずは基本構文を紹介していこうと思います。
Microsoft Docs - Statically Resolved Type Parameters を参考テキストとして利用していきます。
ˆtype-parameter
非常にシンプルな構文ですね!
型パラメータにハット(^)を付けるだけです。
かんたんな利用例
SRTP を利用することで簡単に ジェネリクス を実現することも可能です。
let inline add (lhs: ^a) (rhs: ^b) =
lhs + rhs
add 10 20 |> printfn "%d" // 30
add 10.5 20.5 |> printfn "%f" // 31.000000
ただ、これに関して言えば F# での正規のジェネリクス機能を利用すれば良いですし、そもそも型パラメータの指定すら不要です。
// ジェネリクスを利用する場合: ^ ではなく ' を利用している点が異なる
let inline add (lhs: 'a) (rhs: 'b) =
lhs + rhs
add 10 20 |> printfn "%d" // 30
add 10.5 20.5 |> printfn "%f" // 31.000000
// そもそも勝手に解決してくれる
let inline add' lhs rhs =
lhs + rhs
add' 10 20 |> printfn "%d" // 30
add' 10.5 20.5 |> printfn "%f" // 31.000000
ではどのように解決してくれているのか見てみましょう。
見ての通り SRTP を利用していることがわかります。
実はジェネリクスを利用している場合も結局は SRTP を利用した形に解釈されていたりします。
ちなみにこの型パラメータには 制約 がかけられており、( + ) の演算方法が定義されていない型でなければ add 関数の引数に渡すことができません。
この制約は関数内で + 演算子 を利用しているため付与されています。
つまり、わざわざプログラマが制約を指定する必要がない のです!
もちろん他の演算子を利用すれば、それに対する制約をいい感じに設定してくれます。
例えば以下のような値を累乗する関数 pow を作ったとします。
let inline pow (x: ^X) =
x * x
すると引数で渡される値は ( * ) の演算方法が定義されていることを求められます。
このように F# では単純な制約についてはある程度自動で行ってくれます。
ここで重要なのはこれらが解決してくれるタイミングが "コンパイル時である" ということです。
つまり今回の例で言えば ( + ) や ( * ) が定義されていない型のインスタンスを渡そうとすると コンパイルエラー になってくれるということです。
本当にそうなのか、実際にサンプルで見てみましょう。
単純な2次元座標を保持するだけの Point2D という型を作ってみます。
これには X座標 と Y座標 を保持するだけの能力しかありません。
type Point2D = { X: float; Y: float; }
では、この型のインスタンスを使って add と pow を使ってみようと試みてみます。
type Point2D = { X: float; Y: float; }
let inline add (lhs: ^X) (rhs: ^X) =
lhs + rhs
let inline pow (x: ^X) =
x * x
let p1 = { X=10.5; Y=20.1 }
let p2 = { X=18.3; Y=0.5 }
add p1 p2 |> ignore
pow p1 |> ignore
すると、add と pow を実行しようとしている箇所でエラーが発生しているのがわかります。
エラー内容を見てみると、( + ) や ( * ) が定義されていないことが原因であることがわかります。
では、Point2D に +演算子 と *演算子 を定義してみましょう。
type Point2D = { X: float; Y: float; } with
static member (+) (lhs: Point2D, rhs: Point2D) =
{ X = lhs.X + rhs.X; Y = lhs.Y + rhs.Y }
static member (*) (lhs: Point2D, rhs: Point2D) =
{ X = lhs.X * rhs.X; Y = lhs.Y * rhs.Y }
すると先ほどまで add
と pow
で発生していたエラーがなくなるはずです。
// 出力させるために ignore の部分を printfn に書き換えています。
以下が完全なコードサンプルです。
このコードはエラーなくコンパイル・実行が可能です。
type Point2D = { X: float; Y: float; } with
static member (+) (lhs: Point2D, rhs: Point2D) =
{ X = lhs.X + rhs.X; Y = lhs.Y + rhs.Y }
static member (*) (lhs: Point2D, rhs: Point2D) =
{ X = lhs.X * rhs.X; Y = lhs.Y * rhs.Y }
let inline add (lhs: ^X) (rhs: ^X) =
lhs + rhs
let inline pow (x: ^X) =
x * x
let p1 = { X = 10.5; Y = 20.1 }
let p2 = { X = 18.3; Y = 0.5 }
add p1 p2 |> printfn "%A"
pow p1 |> printfn "%A"
{ X = 28.8
Y = 20.6 }
{ X = 110.25
Y = 404.01 }
このようにSRTPを単純に利用する場合にはジェネリクスを使う場合となんら変わらない使い心地で使えます。
また、ジェネリクスの場合では関数内で演算子の類は使えないですが、SRTPの場合には使うことができます。
些細な違いに感じるかもしれませんが、実際に利用するシーンになった場合には大きな違いに感じると思います。
応用的な利用例
導入
「かんたんな利用例」で紹介した内容は「F# SRTP」などで検索すればすぐに見つけられる情報なので、すでに知っている・利用している方も多かったと思います。
応用的な利用例では 不親切極まりないことで有名な Microsoft DocsのSRTPのページで唐突に現れる メンバー制約 の使い方について紹介していきたいと思います。
メンバー制約の仕方については、英語でもなかなか情報を集めるのが大変で機能を知るのに苦労した方も多いかもしれません。(私はそうでした)
そのためこの記事ではできる限りわかりやすいようにシンプルなサンプルを利用して説明していきたいと思います。
定義
まずはメンバー制約のやり方の前に定義を紹介します。
// プロパティ値の取得方法
(^型パラメータ名: (member プロパティ名: 型名) ^型パラメータ名型のインスタンス)
// メソッドの実行方法
(^型パラメータ名: ([static] member メソッド名: シグニチャ) ^型パラメータ名型のインスタンス, メソッドへのパラメータリスト)
利用例
static member method
それでは簡単な static なメンバメソッドを呼び出してみます。
type Point2D = { X: float; Y: float; } with
static member add (lhs: Point2D, rhs: Point2D) =
{ X = lhs.X + rhs.X; Y = lhs.Y + rhs.Y; }
let inline fn (p1: ^a) (p2: ^a) =
// ^型パラメータ名 : ^a
// メソッド名 : add
// シグニチャ : Point2D * Point2D -> Point2D
// ^型パラメータ名型のインスタンス : (なし)
// メソッドへのパラメータリスト : p1, p2
let v = (^a: (static member add: Point2D * Point2D -> Point2D) p1, p2)
printfn "%A" v
let p1 = { X = 10.; Y = 20.; }
let p2 = { X = 30.; Y = 40.; }
fn p1 p2
{ X = 40.0
Y = 60.0 }
上記のコード内の fn
は以下のコードと同等となります。
let inline fn (p1: Point2D) (p2: Point2D) =
let v = Point2D.add(p1, p2)
printfn "%A" v
ただ、このままだと add のシグニチャが Point2D * Point2D -> Point2D
で固定になってしまっているためあまり価値のないものになってしまっています。
例えば、以下のようなレコードは現状受け付けてくれません。
type Point3D = { X: float; Y: float; Z: float; } with
static member add (lhs: Point3D, rhs: Point3D) =
{ X = lhs.X + rhs.X; Y = lhs.Y + rhs.Y; Z = lhs.Z + rhs.Z; }
let inline fn (p1: ^a) (p2: ^a) =
let v = (^a: (static member add: Point2D * Point2D -> Point2D) p1, p2)
printfn "%A" v
let p3 = { X = 10.; Y = 20.; Z = 30.; }
let p4 = { X = 40.; Y = 50.; Z = 60.; }
// これは当然エラー
fn p3 p4
そこでSRTPやジェネリクスを利用して、柔軟なシグニチャを実現するのが一般的です。
// SRTPを利用するパターン
let inline fn (p1: ^a) (p2: ^a) =
let v = (^a: (static member add: ^T * ^T -> ^T) p1, p2)
printfn "%A" v
// ジェネリクスを利用するパターン
let inline fn (p1: ^a) (p2: ^a) =
let v = (^a: (static member add: 'T * 'T -> 'T) p1, p2)
printfn "%A" v
上記のようにすることで add で表現できる幅が広がります。
また、今回の場合であれば実際の引数と戻り値の型は ^a型 で同じであるため、以下のように記述することも可能です。
let inline fn (p1: ^a) (p2: ^a) =
let v = (^a: (static member add: ^a * ^a -> ^a) p1, p2)
printfn "%A" v
これらを踏まえ、以下のようにコードを作成すると無事実行することが可能となります。
type Point2D = { X: float; Y: float } with
static member add (lhs: Point2D, rhs: Point2D) =
{ X = lhs.X + rhs.X; Y = lhs.Y + rhs.Y }
type Point3D = { X: float; Y: float; Z: float; } with
static member add (lhs: Point3D, rhs: Point3D ) =
{ X = lhs.X + rhs.X; Y = lhs.Y + rhs.Y; Z = lhs.Z + rhs.Z; }
let inline fn (p1: ^a) (p2: ^a) =
let v = (^a: (static member add: ^a * ^a -> ^a) p1, p2)
printfn "%A" v
// Point2Dは問題なし
let p1 = { X = 10.; Y = 20.; }
let p2 = { X = 30.; Y = 40.; }
fn p1 p2
// もちろんPoint3Dも問題なし!
let p3 = { X = 10.; Y = 20.; Z = 30.; }
let p4 = { X = 40.; Y = 50.; Z = 60.; }
fn p3 p4
{ X = 40.0
Y = 60.0 }
{ X = 50.0
Y = 70.0
Z = 90.0 }
member method
通常のメンバメソッドについてもstaticの場合とほぼ同じです。
static
キーワードがあるかないかくらいしか違いがありません。
今回も Point2D
を使って動作を見ていきましょう
type Point2D = { X: float; Y: float } with
member this.move (p: Point2D) =
{ X = this.X + p.X; Y = this.Y + p.Y }
let inline fn (p1: ^a) (p2: ^a) =
// ^型パラメータ名 : ^a
// メソッド名 : move
// シグニチャ : ^a -> ^a
// ^型パラメータ名型のインスタンス : p1
// メソッドへのパラメータリスト : p2
let v = (^a: (member move: ^a -> ^a) p1, p2)
printfn "%A" v
let p1 = { X = 10.; Y = 20.; }
let p2 = { X = 30.; Y = 40.; }
fn p1 p2
上記のコードは以下のようなコードと同等となります。
let inline fn (p1: Point2D) (p2: Point2D) =
let v = p1.move(p2)
printfn "%A" v
ここで引数がunit型のパターンと引数が2つ以上ある場合についても見ていきましょう。
type Foo = { A: int; B: int; C: int; } with
member this.copy() =
{ A = this.A; B = this.B; C = this.C; }
member this.update (a, b, c) =
{ A = this.A + a; B = this.B + b; C = this.C + c; }
let inline copy (f: ^a) =
// ^型パラメータ名 : ^a
// メソッド名 : copy
// シグニチャ : unit -> ^T
// ^型パラメータ名型のインスタンス : f
// メソッドへのパラメータリスト : (なし)
(^a: (member copy: unit -> ^T) f)
let inline update (f: ^a) (a, b, c) =
// ^型パラメータ名 : ^a
// メソッド名 : update
// シグニチャ : ^T * ^T * ^T -> ^a
// ^型パラメータ名型のインスタンス : f
// メソッドへのパラメータリスト : a, b, c
(^a: (member update: ^T * ^T * ^T -> ^a) f, a, b, c)
let foo = { A = 10; B = 20; C = 30; }
printfn $"%A{copy foo}"
printfn $"%A{update foo (30, 20, 10)}"
{ A = 10
B = 20
C = 30 }
{ A = 40
B = 40
C = 40 }
見てもらうとわかるとおり、引数がunit型のメソッド(= copy)を呼ぶ場合にはインスタンスの後ろに何も記述していません。
また、引数が複数あるメソッド(= update)を呼ぶ場合にはインスタンスの後ろにカンマ区切り(= tuple)で必要な分だけパラメータを記述しています。
このように引数がいくつの場合であっても同様の構文で対象のメソッドを呼び出すことができます。
member property
プロパティについてもメソッドの場合と同様の方法で値にアクセスすることができます。
type Point2D = { X: float; Y: float; }
let inline fn (p: ^a) =
// ^型パラメータ名 : ^a
// プロパティ名 : X
// シグニチャ : ^T
// ^型パラメータ名型のインスタンス : p
(^a: (member X: ^T) p)
let p = { X = 10.; Y = 20.; }
printfn $"%A{fn p}"
10.0
おわりに
SRTPはF#の機能の中でも非常に強力なものなので、覚えておいて損はないでしょう。
これを利用することで型安全なダックタイピングのようなことができるようになると考えるとわかりやすいかもしれません。
また、SRTPを利用して型クラスを実現することも可能なようなので、興味がある方は調べてみると面白いかもしれません。
// もし抜け・漏れ・間違い等ございましたら編集リクエストをお願いいたします🙇♂️
Discussion
制約を導入する式が関数適用じゃなくて、関数自体に評価された方が便利そうなのに開発陣はなぜこう設計したのかなと思いました。
下記の式が
こう書けた方が
例えば別の公開関数に
add
を渡すときに簡潔になるのにな、と。今の文法だとクロージャーで包んでこう書くことになりそう。
(あ、型変数は内側のスコープに持っていけるのかな?)