イミュータビリティ(不変性)が重要である理由を理解するために、小さな例から始めましょう。
以下は、数値のリストを処理する簡単なC#のコードです。
public List<int> MakeList()
{
return new List<int> {1,2,3,4,5,6,7,8,9,10};
}
public List<int> OddNumbers(List<int> list)
{
// 何らかのコード
}
public List<int> EvenNumbers(List<int> list)
{
// 何らかのコード
}
では、これをテストしてみましょう:
public void Test()
{
var odds = OddNumbers(MakeList());
var evens = EvenNumbers(MakeList());
// odds が 1,3,5,7,9 であることを確認 -- OK!
// evens が 2,4,6,8,10 であることを確認 -- OK!
}
すべてうまく動作し、テストにも合格します。ただ、リストを2回作成しているのに気づきました。これはリファクタリングすべきでしょうか?ここでは、リファクタリングをしてみます。以下が新しく改良されたバージョンです:
public void RefactoredTest()
{
var list = MakeList();
var odds = OddNumbers(list);
var evens = EvenNumbers(list);
// odds が 1,3,5,7,9 であることを確認 -- OK!
// evens が 2,4,6,8,10 であることを確認 -- 失敗!
}
しかし、今度はテストが突然失敗してしまいました!なぜリファクタリングがテストを壊してしまったのでしょうか?コードを見ただけでその理由がわかりますか?
答えは、もちろん、リストがミュータブル(可変)であり、OddNumbers
関数がフィルタリングのロジックの一部としてリストに破壊的な変更を加えている可能性が高いということです。確実なことを言うには、OddNumbers
関数の中身を調べる必要があります。
つまり、OddNumbers
関数を呼び出すとき、意図せずに望ましくない副作用を生み出してしまっているのです。
これが起こらないようにする方法はあるでしょうか?はい、関数が代わりにIEnumerable
を使えば可能です:
public IEnumerable<int> MakeList() {}
public List<int> OddNumbers(IEnumerable<int> list) {}
public List<int> EvenNumbers(IEnumerable <int> list) {}
この場合、OddNumbers
関数を呼び出してもリストに影響を与える可能性はなく、EvenNumbers
も正しく動作すると確信できます。さらに重要なのは、関数の内部を調べることなく、シグネチャを見るだけでこのことがわかるという点です。そして、リストに代入することで関数の一つを誤動作させようとしても、コンパイル時にすぐにエラーが発生します。
このケースではIEnumerable
が役立ちますが、IEnumerable<int>
の代わりにIEnumerable<Person>
のような型を使った場合はどうでしょうか?それでも関数が意図しない副作用を持たないと確信できるでしょうか?
イミュータビリティが重要な理由
上記の例は、イミュータビリティが役立つ理由を示しています。実は、これは氷山の一角に過ぎません。イミュータビリティが重要な理由はいくつかあります:
- イミュータブルなデータはコードを予測可能にする
- イミュータブルなデータは扱いやすい
- イミュータブルなデータは「変換」アプローチの使用を強制する
まず、イミュータビリティはコードを予測可能にします。データがイミュータブルであれば、副作用はありえません。副作用がなければ、コードの正確性について推論するのがずっと、ずっと簡単になります。
そして、イミュータブルなデータを扱う2つの関数がある場合、どちらを先に呼び出すべきか、あるいは一方の関数が他方の関数の入力をめちゃくちゃにしないかなどを心配する必要がありません。また、データを受け渡す際にも安心です(たとえば、オブジェクトをハッシュテーブルのキーとして使い、そのハッシュコードが変更される心配がありません)。
実際、イミュータビリティが良いアイデアである理由は、グローバル変数が悪いアイデアである理由と同じです:データはできるだけローカルに保ち、副作用は避けるべきです。
第二に、イミュータビリティは扱いやすいです。データがイミュータブルであれば、多くの一般的なタスクがずっと簡単になります。コードは書きやすく、メンテナンスもしやすくなります。必要なユニットテストの数が少なくなり(関数が単独で機能することを確認するだけで済みます)、モックの作成も非常に簡単になります。並行処理も非常に簡単になります。更新の競合を避けるためにロックを使う心配がないからです(更新がないため)。
最後に、デフォルトでイミュータビリティを使うことは、プログラミングに対する考え方を変えることを意味します。データをその場で変更するのではなく、変換することを考えるようになります。
SQLクエリとLINQクエリは、この「変換」アプローチの良い例です。どちらの場合も、元のデータを変更するのではなく、常に元のデータをさまざまな関数(選択、フィルタリング、ソートなど)を通じて変換します。
プログラムが変換アプローチを使って設計されると、結果はより優雅で、よりモジュール化され、よりスケーラブルになる傾向があります。そして、偶然にも、変換アプローチは関数指向のパラダイムにも完璧にフィットします。
F#がイミュータビリティを実現する方法
先ほど見たように、F#ではイミュータブルな値と型がデフォルトです:
// イミュータブルなリスト
let list = [1;2;3;4]
type PersonalName = {FirstName:string; LastName:string}
// イミュータブルな人物
let john = {FirstName="John"; LastName="Doe"}
このため、F#には生活を楽にし、基礎となるコードを最適化するためのいくつかのトリックがあります。
まず、データ構造を変更できないため、変更したい場合はコピーする必要があります。F#では、必要な変更だけを加えて別のデータ構造をコピーするのが簡単です:
let alice = {john with FirstName="Alice"}
そして、複雑なデータ構造は連結リストや類似のものとして実装されているため、構造の共通部分が共有されます。
// イミュータブルなリストを作成
let list1 = [1;2;3;4]
// 先頭に追加して新しいリストを作成
let list2 = 0::list1
// 2番目のリストの最後の4つを取得
let list3 = list2.Tail
// 2つのリストはメモリ上で同一のオブジェクト!
System.Object.ReferenceEquals(list1,list3)
この技術により、コード上では何百ものリストのコピーがあるように見えても、舞台裏では同じメモリを共有していることが保証されます。
ミュータブルなデータ
F#はイミュータビリティに関して融通が利きます。mutable
キーワードを使ってミュータブルなデータもサポートしています。ただし、何かをミュータブルにするには、明示的にそう指定する必要があります。これはF#のデフォルトの動作(イミュータブル)から意識的に外れることを意味します。通常、最適化やキャッシュなどの特殊なケース、あるいは.NETライブラリを扱う場合にのみ必要とされます。
実際には、現実世界のアプリケーションは、ユーザーインターフェース、データベース、ネットワークなどの複雑な要素を扱う場合、何らかのミュータブルな状態を持つことになります。しかし、F#はそのようなミュータブルな状態を最小限に抑えることを奨励しています。一般的に、コアとなるビジネスロジックはイミュータブルなデータを使って設計することができ、それに伴うすべての利点を得ることができます。