F# と設計について考えてみた #FsAdventJP
F# Advent Calendar 2013 15日目の記事です。
ヘタレなので大したことは書けないです。初めての Advent Calendar でやや緊張気味。
ステートレスとステートフル
現在 F# を実戦投入してます。OpenGL を利用した 3D CAD 系アプリケーションの受託開発です。WPFによるスタンドアロンなデスクトップアプリケーションとなります。下図のような構成となっています。
ViewModel を C# にしたのは深い意味はないです。ViewModel は F# による恩恵が大きい部分ではないだろうという判断と、いきなり全面的に F# にするのに私がビビっただけです。次の機会には ViewModel も F# にするかもしれません。
さて、作っていく過程で気づいたのですが、Model部分は更に次のような2層に別れる感じです。
関数型プログラミングでは状態を持たない(ステートレスな)データ構造や関数が推奨されますが、そうはいってもアプリケーションは操作すれば状態変化を起こしますから、どこかでその状態を管理しなくてはなりません。ですから図のような層に別れるのは当然の帰結だったと言えます。(が、私は実際に使ってみて初めて気づいた(^^;)
多くのバグは状態遷移に起因します。この対策として、状態を持たない不変な値と関数をベースにプログラムを組み立てようというのが関数型、状態変数を隠蔽して状態遷移を局所化しようというのがオブジェクト指向、と分類できるでしょうか。(これは矮小化した捉え方かもしれませんが)
- 関数型「バグるなら 不変にしよう ホトトギス」
- オブジェクト指向「バグるなら 隠蔽しよう ホトトギス」
(ホトトギス関係ない)
F# は関数型的な文法とオブジェクト指向的な文法の両方を備えているので、最初は使い分けに迷いました。上記のようなステートレス/ステートフルな層を意識するようになって以来、迷いが少し解消しました。
出来るだけステートフルな層が薄く設計して、状態管理を局所化すると良い感じです。ステートレスな部分は INotifyPropertyChanged インターフェイスの実装が不要ですから、MVVMもやりやすくなります。ステートフルなコードは、実行時に取り得る状態やその遷移を脳内でイメージしないと読み書きできないので大変です。つまりロジックだけでなく時間軸のイメージも必要になるということです。これに対してステートレスなコードは、動的な時間軸のイメージは必要とされません。ステートレスな構造や関数群からは、時が止まった静謐な印象を受けます(個人の感想です)。
まとめると、
- ステートレスな層とステートフルな層に分けて考える
- ステートレスな層は関数型のスタイルで
- ステートフルな層はオブジェクト指向のスタイルで
- 出来るだけステートフルな層を薄くする(状態管理を局所化する)
- ステートレスな層にはロジックを書く
- ステートフルな層には状態遷移を書く(ロジックは出来るだけステートレスな層に追い出す)
- ステートフルな層もリアクティブプログラミング(よく分かってませんが)な感じで出来るだけ宣言的に書く
という方針がいいんじゃないかなあ、というのが現時点での感想です。
型の種類と粒度
F# には型の種類が色々あって最初は混乱します。整理のために表にまとめてみました。
型 | 代入 | 比較 | 変更 |
---|---|---|---|
struct | deep copy | value equality | stateless |
record/union | shallow copy | value equality | stateless |
class | shallow copy | reference equality | ? |
…とまとめてみたものの、この表のようにスッキリと割り切れるかというと実際はそうでもありません。例えばレコード型は mutable なメンバーも定義できてしまうし(基本的に使っちゃダメだと私は思ってますが)、クラスの比較は Equals 等のオーバーライドによって value equality にすることも出来ます。その辺を端折ってザックリまとめちゃうと、だいたいこの表のような感じになるかなと思っています。
ステートフルな型を定義したい場合はクラスの一択だと私は思います。しかし逆は真ではなく、クラスだからといってステートフルとは限りません。ですので上記の表では ? マークにしてあります。実際、F# を使っていると状態の変更操作を一切持たないステートレスなクラスを定義する機会は非常に多いですし、むしろ積極的にステートレスな設計を目指すべきと思います。
ですから、ここでは class をステートレスなものとステートフルなものに分けてみましょう。大雑把に考えて、粒度の小さな型から大きな型の純に並べると次のようになるのではないでしょうか。
type | stateless | value equality | deep copy |
---|---|---|---|
struct | ○ | ○ | ○ |
record/union | ○ | ○ | - |
stateless class | ○ | - | - |
stateful class | - | - | - |
レコードにするべきかクラスにするべきか、それが問題だ
これが分からなくて、是非 F#er の皆さんのご意見を伺ってみたいと思っているところです。
先ほど、ステートレスとステートフルの二層に分かれると書きました。つまり上の表の "stateless class" と "stateful class" の間に境界があるように思います。
struct < record/union < stateless class <<<(超えられない壁)<<< stateful class
※ 型の位置づけ上
構造体(struct)とレコードの使い分けはあまり迷いません。大抵はレコードにしておけばOKです。構造体を使う時は、例えば外部のネイティブのAPIにデータを渡したい時とか、巨大な配列に格納して unsafe コードで高速にアクセスしたいとか、そういう具体的な理由があるときです。それ以外はレコードで問題無いと思います。ですので以下の議論から構造体は外します。
さて、上で stateless と statefull の間に(超えられない壁)を書きましたが、F# の文法上はレコードとクラスの間に壁があります。
record/union <<<(超えられない壁)<<< stateless class < stateful class
※ F#の文法上
どうも迷うのがレコードと stateless class の使い分けです。レコードで定義していた型に変更が入ると、割と面倒なことになる印象があります。特にレコードをクラスに変更する事案が発生すると割と泣けます。
- レコードにメンバを追加すると、レコードのインスタンスを生成している部分全てに変更が入ります。デフォルトは None でいいんだけどなー、というメンバの追加でもデフォルト値の設定は出来ません。もちろん常に { defaultValue with Hoge = ... } と初期化する習慣がある賢者なら困らないのでしょうけど(そうするべきなのかな?)
- 同様に、何らかの事情でレコードをクラスに変更するとインスタンスを生成している部分は全て修正が必要になります。
- 更に、レコードとクラスでは型推論の効き方が違います。レコードなら hoge.Piyo と書いておくと Piyo プロパティを持つレコード型に推論してくれますが、クラスの場合はこの推論が効きません(理由は知らない…)。ですので、かたっぱしから明示的な型の指定を追加する必要が生じます。
レコードをクラスに変更するなどということ自体が、私がレコードとクラスの使い分けが出来ていない証拠なのかもしれません。レコードが表現するのは「データ」、クラスが表現するのは「(振る舞いによって抽象化された)オブジェクト」とうまく使い分ければ良いのでしょうけれど、なかなか現実はうまくいきません。データとしての性格が強いと判断してレコードで定義した型に、後から「ちょっとコンストラクタで初期化処理を追加したいな」と考えるだけで、クラスに変更する必要が生じるのです。
つまり、「データを表す型」と「振る舞いを表す型」の間を仕切る明確な区切りは(本質的には)存在しないように思うのです。しかし F# ではレコードとクラスの間に文法上の大きな開きがあるために、このような問題が発生するのだと思います。
以上を考えると、レコード型の使用は低いレイヤーに限定しておいて乱用を避けたほうが良いのかもしれません。つまり、十分にプリミティブなデータ構造でこの先変更が入ることはまず考えられないデータ型であるとか、特定のモジュールの内部でのみ使用される private なデータ型などに使用を限定するべきなのかもしれません。
しかし、レコード型のシンプルで明快な文法はあまりに誘惑が大きく、つい使いたくなってしまいます。レコード型との上手な付き合い方、ご教授下さい><
Equals と等号にまつわるエトセトラ【余談】
obj の比較
ここからは余談です。比較演算について C# との違いがあることを知ったので、メモがてら書いておきます。
まずは C# のコードから。
var s1 = new String( new[] { 't', 'e', 's', 't' } ); var s2 = new String( new[] { 't', 'e', 's', 't' } ); Console.WriteLine( object.ReferenceEquals( s1, s2 ) ); // false Console.WriteLine( s1 == s2 ); // true Console.WriteLine( ((object)s1) == ((object)s2) ); // false!!!
string 型には == 演算子がオーバーロードされていますが、object 型にキャストして == 演算子で比較すると参照の比較になってしまいます。(鉄板ネタですね)
同様のコードを F# で試してみます。
let s1 = String( [| 't'; 'e'; 's'; 't' |] ) let s2 = String( [| 't'; 'e'; 's'; 't' |] ) printfn "%A" (obj.ReferenceEquals( s1, s2 )) (* false *) printfn "%A" (s1 = s2) (* true *) printfn "%A" ((s1 :> obj) = (s2 :> obj)) (* true!!! *)
obj にキャストされた状態でもきちんと値で比較されます。F# の比較演算子はきちんと Equals を呼び出してくれるのですね。C# とは違うのだよ、C# とは。
配列の比較
では今度は配列の比較について調べてみましょう。
let array1 = [| 1; 2; 3 |] let array2 = [| 1; 2; 3 |] printfn "%A" (obj.ReferenceEquals( array1, array2 )) (* false *) printfn "%A" (array1.Equals array2) (* false *) printfn "%A" (array1 = array2) (* true *)
配列は Equals は参照比較で、等号は値比較となっています。これはギョッとしますが、Equals は .NET Framework 側で決まっている挙動なのでやむを得ないでしょう。むしろ等号を無理に値比較にしないほうが良かったんじゃないかと思ってみたり思わなかったり、うーむ。ちなみに list は等号も Equals も値比較となっています。
では次のように配列をレコードのメンバに含めるとどうなるでしょうか。
type ArrayRec = { Array : int array }
この場合は ArrayRec は Equals も等号も共に値比較となります。
インターフェイスの比較
等値演算子(等号)は Equals を呼び出すと書きました。ですのでインターフェイスでも等号で値比較ができます。
(* こんなインターフェイスを定義して… *) type IHoge = abstract Piyo : int module Hoge = (* private なレコード型にインターフェイスを実装して… *) type private HogeImpl = { Piyo : int } with interface IHoge with member this.Piyo = this.Piyo (* インスタンス生成関数 *) let create piyo = { Piyo = piyo } :> IHoge (* 値の等しい2つのインスタンスを生成して… *) let hoge1 = Hoge.create 123 let hoge2 = Hoge.create 123 (* IHoge 型でも演算子で値の比較ができる! *) printfn "%A" (hoge1 = hoge2) (* true *)
レコード型は Equals のオーバーロードが暗黙的に行われますので、自分でコードを書かなくても値比較ができます。しかも Equals を明示的に呼び出すことなく等号で値比較が出来るのです。F#er にとっては「当たり前じゃん」という話かもしれません。しかし C# にどっぷり染まっていた私としてはインターフェイスは参照比較という固定観念がありましたので、これには少なからず驚きました。
このスタイルで書いておけば実装の詳細を隠蔽(カプセル化)した値型が定義できる、ような、んーあんまり意味無いような…。
ただこのままでは comparison 制約を満たしません。次のように IComparable を継承させるだけで comparison 制約は実現できます。
type IHoge = inherit IComparable (* これを追加 *) abstract Piyo : int
レコード型では Equals と同様に IComparable も暗黙的に実装されますので、自分で比較演算を実装する必要はありません。これは便利ですね。
しかしこれは注意点があります。IHoge を実装する別のクラスがあると破綻してしまうのです。
(* IHoge の別の実装 *) module YetAnotherHoge = type private HogeImpl = { PiyoPiyo : int } with interface IHoge with member this.Piyo = this.PiyoPiyo (* インスタンス生成関数 *) let create piyopiyo = { PiyoPiyo = piyopiyo } :> IHoge let hoge1 = Hoge.create 123 let hoge2 = YetAnotherHoge.create 123 printfn "%A" (hoge1 < hoge2) (* InvalidCastException!!! *)
コンパイルは通ります。しかし実行してみると、残念ながら InvalidCastException で落ちてしまいます。異なる型で大小の序列が付けられるはずがありませんから、これは当然の結果ですね。安易な IComparable の継承は避けたほうが良さそうです。
最後に
F# はいいですねー。関数型のディープな話題には全くついていけないヘタレですが、使っていて気持ち良いです。みんなもっと使いましょー!
(※ ところで弊社ではエンジニア募集中です。興味ある方は @u_1roh まで。 モノコミュニティ | 採用情報)