要点
- interface を継承することは「その型として扱える」という契約を外部に公開する行為である。
- 契約が成立しない継承は、多態的利用(置換)の文脈で意味が破綻する。
- 迷ったら、まず合成(has-a)を検討する。
interface は型を定義する
GoF は次のように述べている。
An object’s type only refers to its interface—the set of requests to which it can respond.
(オブジェクトの型はそのインターフェース、つまりそのオブジェクトが応答できるリクエストの集合のみを指す)— Design Patterns: Elements of Reusable Object-Oriented Software
すなわち、オブジェクトの型は interface によって定義される。本記事では、この関係を 型の契約 として扱う。
interface を継承するということは、その型として扱える と宣言することである。
この意味を、具体的なコードで考えてみる。
問題の出発点
|
|
「start() を実装できるなら、これくらいは良いのでは?」— そう思えるかもしれない。
しかし、class Car : Engine と書いた瞬間に、次の約束が外部に公開される。
「Car は Engine として扱える」
これは「start() を実装できる」という意味ではない。「Engine を期待する文脈に Car を渡しても、意味が成立する」— すなわち 呼び出し側の前提や期待を壊さない ことを約束するものである。
多態的利用の文脈で破綻する
型契約が崩れたときに問題が顕在化するのは、多態的利用(置換)の文脈 である。
|
|
startEngine の作者が期待する意味は「エンジンを始動する」ことである。しかし Car.start() が実際に表す意味は「車を発進させる」等にズレやすい。
|
|
これは呼び出し側のバグではなく、型設計が嘘をついている状態 である。
LSP の要件は「コンパイルが通る」ではなく「置換しても意味が保たれる」ことである。startEngine(Car()) はコンパイルが通るが、Car は Engine の契約を満たさない可能性がある。
契約を守る継承は成立する
同じ Engine を継承していても、契約を守る設計であれば is-a は成立する。
|
|
startEngine(LoggingEngine(DefaultEngine())) は安全に成立する。LoggingEngine は Engine の契約(エンジンを始動する文脈で意味が成立する)を守っているからである。
ただし、「契約が成立しているかどうか」の判断は設計者の主観に委ねられる部分が大きい。
判断に迷う場合は、合成(has-a)を選ぶ方が安全である。
合成(has-a)にする
Car と Engine の関係は、is-a(である)ではなく has-a(持っている)で表現すべきである。
|
|
この設計では startEngine(Car()) はコンパイルエラーになる。型設計が正直に「Car は Engine ではない」と表現できている。
Car : Engine(is-a) |
Car has Engine(has-a) |
|
|---|---|---|
startEngine(Car()) |
コンパイル通る(意味が壊れる可能性) | コンパイルエラー(型が正直) |
| ドメインの境界 | 曖昧 | 明確 |
| Engine 変更の影響 | Car に波及する | Car の内部に閉じる |
本記事は、is-a 継承の判断基準と合成(has-a)の概念を扱った以下の記事の続きである。
また、合成・委譲・転送の用語については以下で整理している。
まとめ
- interface 継承は「実装できる」ではなく「その型として扱える」契約を公開する行為である。
- is-a が崩れると、多態的利用(置換)の文脈で意味が破綻する。
- 振る舞いを付与できるかどうかではなく、その interface の契約が成立するかどうかで判断する。
- 迷ったら、まず合成(has-a)を検討する。