契約による設計と名前による型づけ
最近, 社内で契約による設計の話が雑談として何度か出ていて, id:hakobe932さんが社内勉強会で紹介していたり,
id:shiba_yu36さんがWEB+DB PRESSで
Smart::Args
で制約をチェックする記事を書いていたり, 活発な議論になっている. インスタンスのファクトリメソッドとオプショナルな型を組み合わせると事前・事後条件を満たすことが保証できて, id:hakobe932さんの資料で言うところの「要求型」と「保護型」の区別も明確になってよいという話を書こうかとおもっていた. (これはそのうち別で書く.)
とはいえ, こんな話はもう言っている人がいるだろうと思ってちょっと調べていて, どういう語句で調べたらいいか考えていた. インスタンスの型からそれを生成したファクトリメソッドが特定できて, それによって事前・事後条件が保証されるというのは名前による(nominal)型システムだからできることで, 構造による(structural)型システムではファクトリメソッド(コンストラクタ)と型が結びつかないからダメだよなぁ, という具合にその辺りのキーワードで検索していたら面白いものを見つけた:
要約すると, OOPにおいて研究者は歴史的に構造的部分型(structural subtyping)こそ至高という感じでやってきたけれども, 産業界ではもっぱら名前による部分型(nominal subtyping)の言語が主流で, それはべつにプログラマが構造による型システムの利点を理解できないほど馬鹿だからではなく, 名前による型システムに明確な実用上の利点があるから, 言い換えれば構造的部分型の欠点が実用上は致命的だからだ, という内容. 具体的には, 構造的部分型の欠点として,
- 本来は部分型になるべきでないものが部分型になってしまい, これは契約による設計を崩してしまう
- 本来は継承したら部分型になってほしいのに, ならない(できない)場合がある
というものが挙げられている. 後者は再帰型とか関係して説明がめんどくさいのと本論からそれるので省略して, 前者を少し説明しよう. これはまさに「事前・事後条件が保証されるというのは名前による型システムだからできること」というもので, 構造による場合に崩れる場合というのも非常に簡単で, 以下のようなものを考えるとわかりやすい.
class Set { boolean equals(Object s) { ... } void insert(Object o) { ... } void remove(Object o) { ... } boolean isMember(Object o) { ... } } class MultiSet { boolean equals(Object s) { ... } void insert(Object o) { ... } void remove(Object o) { ... } boolean isMember(Object o) { ... } }
Set
とMultiSet
は, 前者は重複を許さないというところだけが異なる. シグネチャは全く同じなので, 構造による型システムではMultiSet
をSet
として使うことを許してしまう.
MultiSet m = new MultiSet(); m.insert(2); m.insert(2); Set s = m;
もし仮にSet
を引数としてとるメソッドがあったとして, 当然そのメソッドは「引数のコレクションの要素には重複がない」という事前条件を期待してSet
を要求しているのに, こんなことができてしまっては事前条件を守ってもらえる保証がなくなってしまう.
この例は, Smart::Args
的なものを用いてオブジェクトの構造をつぶさに調べていくような事前条件を定義することがいかに不毛か, ということも示している. そのような条件チェックのやり方で「コレクションの要素には重複がない」ことを確かめるには, 実際に全要素を見てまわるしかない.
まぁ構造的部分型の欠点は知っている人には当たり前の話で, 研究者ももちろん認識しているはずだけど, それにしても名前による部分型を軽視してるでしょ, という怒りが感じられた. ちなみに僕はどういう立場かと言うと, 名前による部分型をきちんと学術的(数学的)に扱うことに広く貢献したものとしてこのエッセイにも取り上げられているFeatherweight Javaの考案者の一派なので, どちらかと言うと実学無視しちゃダメでしょという方です. (でも構造的部分型もたいへんべんりなので好きです. 両方できると嬉しい. )
不変なオブジェクト
けっきょく, OOPにおける(名前による型づけの)型というのは, その型のオブジェクトがいろいろ振る舞った結果ずっと維持され続ける不変条件に名前をつけたもの, ととらえることができるのだなぁ. それゆえ契約による設計の事前・事後条件がその不変条件と一致するように綺麗にモデリングできていれば, 型名だけ調べたらよいことになる.
振る舞った結果途中で別の条件(より緩い条件やより厳しい条件)に推移する場合もあって, その場合はオブジェクトが不変(immutable)だと都合がよい. たとえば不変なバージョンのImmutableSet
とImmutableMultiSet
を考えてみよう.
class ImmutableSet { boolean equals(Object s) { ... } ImmutableSet insert(Object o) { ... } ImmutableSet remove(Object o) { ... } boolean isMember(Object o) { ... } ImmutableMultiSet toMultiSet() { ... } } class ImmutableMultiSet { boolean equals(Object s) { ... } ImmutableMultiSet insert(Object o) { ... } ImmutableMultiSet remove(Object o) { ... } boolean isMember(Object o) { ... } ImmutableSet distinct() { ... } }
ImmutableSet
はすべての要素が1つずつの, 特殊な状態のImmutableMultiSet
とみなせるので, toMultiSet()
というメソッドを定義できそう. またImmutableMultiSet
の重複を取り除けばImmutableSet
としての条件を満たすはずなので, distinct()
というメソッドを定義できそう.
一方, (可変(mutable)な方の)MultiSet
から重複を取り除くdistinct()
メソッドを用意したとしても, その結果をSet
として扱うことはできない.
class MultiSet { ... void distinct() { ... /* 自分自身から重複を取り除く */ } } MultiSet m = new MultiSet(...); m.distinct(); Set s = m; // コンパイルエラー
もちろん, distinct()
メソッドだけ新しいインスタンスを返すという手もあるけれど, わざわざオブジェクトを可変にしてメモリ効率をよくしたのに, このメソッドだけ新しいインスタンスが生成されるというのはあまり一貫していない. つまり, 一貫したスタイルとしては, 「不変条件に名前がついたもの」という観点で圧倒的に不変なスタイルが適していると言えそう. 「不変」なんだからそりゃそうか?