🔷

SRTP入門

2020/12/07に公開
1

はじめに

この記事は F# アドベントカレンダー 2020 の8日目の記事となります。

F# において SRTP (= Statically Resolved Type Parameters) は非常に強力な機能の一つです。また、この機能は C# に存在しないため F# と C# の差別化ポイントでもあります。

しかし、日本語記事や英語記事にはこれの入門となるような記事が存在していません。
そのため今回はSRTPの基本となる部分についてわかりやすく紹介していこうと思います。

基本構文

何はともあれまずは基本構文を紹介していこうと思います。
Microsoft Docs - Statically Resolved Type Parameters を参考テキストとして利用していきます。

SRTP-Syntax
ˆtype-parameter

非常にシンプルな構文ですね!
型パラメータにハット(^)を付けるだけです。

かんたんな利用例

SRTP を利用することで簡単に ジェネリクス を実現することも可能です。

sample.fs
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# での正規のジェネリクス機能を利用すれば良いですし、そもそも型パラメータの指定すら不要です。

sample.fs
// ジェネリクスを利用する場合: ^ ではなく ' を利用している点が異なる
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

ではどのように解決してくれているのか見てみましょう。
image.png

見ての通り SRTP を利用していることがわかります。
実はジェネリクスを利用している場合も結局は SRTP を利用した形に解釈されていたりします。
image.png

ちなみにこの型パラメータには 制約 がかけられており、( + ) の演算方法が定義されていない型でなければ add 関数の引数に渡すことができません。
image.png

この制約は関数内で + 演算子 を利用しているため付与されています。
つまり、わざわざプログラマが制約を指定する必要がない のです!

もちろん他の演算子を利用すれば、それに対する制約をいい感じに設定してくれます。

例えば以下のような値を累乗する関数 pow を作ったとします。

sample.fs
let inline pow (x: ^X) =
  x * x

すると引数で渡される値は ( * ) の演算方法が定義されていることを求められます。
image.png

このように F# では単純な制約についてはある程度自動で行ってくれます。
ここで重要なのはこれらが解決してくれるタイミングが "コンパイル時である" ということです。

つまり今回の例で言えば ( + )( * ) が定義されていない型のインスタンスを渡そうとすると コンパイルエラー になってくれるということです。
本当にそうなのか、実際にサンプルで見てみましょう。

単純な2次元座標を保持するだけの Point2D という型を作ってみます。
これには X座標 と Y座標 を保持するだけの能力しかありません。

Point2D
type Point2D = { X: float; Y: float; }

では、この型のインスタンスを使って addpow を使ってみようと試みてみます。

sample
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 を実行しようとしている箇所でエラーが発生しているのがわかります。
image.png

エラー内容を見てみると、( + )( * ) が定義されていないことが原因であることがわかります。
image.png
image.png

では、Point2D に +演算子*演算子 を定義してみましょう。

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 }

すると先ほどまで addpow で発生していたエラーがなくなるはずです。
// 出力させるために ignore の部分を printfn に書き換えています。
image.png

以下が完全なコードサンプルです。
このコードはエラーなくコンパイル・実行が可能です。

sample
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のページで唐突に現れる メンバー制約 の使い方について紹介していきたいと思います。

メンバー制約の仕方については、英語でもなかなか情報を集めるのが大変で機能を知るのに苦労した方も多いかもしれません。(私はそうでした)
そのためこの記事ではできる限りわかりやすいようにシンプルなサンプルを利用して説明していきたいと思います。

定義

まずはメンバー制約のやり方の前に定義を紹介します。

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

岡本和樹岡本和樹

制約を導入する式が関数適用じゃなくて、関数自体に評価された方が便利そうなのに開発陣はなぜこう設計したのかなと思いました。

下記の式が

(^a: (static member add: ^T * ^T -> ^T) p1, p2)

こう書けた方が

(^a: (static member add: ^T * ^T -> ^T))(p1, p2)

例えば別の公開関数に add を渡すときに簡潔になるのにな、と。

今の文法だとクロージャーで包んでこう書くことになりそう。

foo (fun p1 p2 -> (^a: (static member add: ^T * ^T -> ^T) p1, p2))

(あ、型変数は内側のスコープに持っていけるのかな?)