Featured image of post interface 継承は型の契約である(is-a が崩れると何が起きるか)

interface 継承は型の契約である(is-a が崩れると何が起きるか)

interface を継承することは「その型として扱える」と宣言する行為である。契約が成立しない継承がなぜ危険かを具体例で示す

要点

  • 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 を継承するということは、その型として扱える と宣言することである。
この意味を、具体的なコードで考えてみる。

問題の出発点

1
2
3
4
5
interface Engine { fun start() }

class Car : Engine {
    override fun start() { /* ... */ }
}

「start() を実装できるなら、これくらいは良いのでは?」— そう思えるかもしれない。

しかし、class Car : Engine と書いた瞬間に、次の約束が外部に公開される。

「Car は Engine として扱える」

これは「start() を実装できる」という意味ではない。「Engine を期待する文脈に Car を渡しても、意味が成立する」— すなわち 呼び出し側の前提や期待を壊さない ことを約束するものである。

多態的利用の文脈で破綻する

型契約が崩れたときに問題が顕在化するのは、多態的利用(置換)の文脈 である。

1
2
3
4
5
6
fun startEngine(engine: Engine) {
    // 呼び出し側は「Engine ならエンジンが始動する」という前提で書く
    engine.start()
}

startEngine(Car()) // ← これは本当に安全か?

startEngine の作者が期待する意味は「エンジンを始動する」ことである。しかし Car.start() が実際に表す意味は「車を発進させる」等にズレやすい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// NG:Car は Engine ではない
class Car : Engine {
    override fun start() {
        // Car.start() は「エンジン始動」ではなく
        // 「車を発進させる」等の意味になりやすい
        //
        // しかし型は Engine なので、
        // 呼び出し側は「エンジンを始動する」つもりで Car を渡せてしまう
    }
}

これは呼び出し側のバグではなく、型設計が嘘をついている状態 である。

LSP の要件は「コンパイルが通る」ではなく「置換しても意味が保たれる」ことである。startEngine(Car()) はコンパイルが通るが、Car は Engine の契約を満たさない可能性がある。

契約を守る継承は成立する

同じ Engine を継承していても、契約を守る設計であれば is-a は成立する。

1
2
3
4
5
6
7
8
// OK:Engine の契約を保ったまま機能を足す(Decorator パターン)
// Java の BufferedInputStream(InputStream) と同じ構造。
class LoggingEngine(private val base: Engine) : Engine {
    override fun start() {
        Log.d("Engine", "starting")
        base.start()
    }
}

startEngine(LoggingEngine(DefaultEngine())) は安全に成立する。LoggingEngine は Engine の契約(エンジンを始動する文脈で意味が成立する)を守っているからである。

ただし、「契約が成立しているかどうか」の判断は設計者の主観に委ねられる部分が大きい。
判断に迷う場合は、合成(has-a)を選ぶ方が安全である。

合成(has-a)にする

Car と Engine の関係は、is-a(である)ではなく has-a(持っている)で表現すべきである。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface Engine { fun start() }

class DefaultEngine : Engine {
    override fun start() { /* ... */ }
}

// OK:Car has-a Engine
class Car(private val engine: Engine = DefaultEngine()) {
    fun start() = engine.start()
}

この設計では startEngine(Car()) はコンパイルエラーになる。型設計が正直に「Car は Engine ではない」と表現できている。

Car : Engine(is-a) Car has Engine(has-a)
startEngine(Car()) コンパイル通る(意味が壊れる可能性) コンパイルエラー(型が正直)
ドメインの境界 曖昧 明確
Engine 変更の影響 Car に波及する Car の内部に閉じる

本記事は、is-a 継承の判断基準と合成(has-a)の概念を扱った以下の記事の続きである。

is-a 関係が成立していても継承が正しいとは限らない。interface の多重継承にも設計原則を適用すべき理由を具体例で示す
cover.webp

また、合成・委譲・転送の用語については以下で整理している。

原義は Favor Composition over Inheritance である。合成・委譲・転送の用語を整理する
cover.webp

まとめ

  • interface 継承は「実装できる」ではなく「その型として扱える」契約を公開する行為である。
  • is-a が崩れると、多態的利用(置換)の文脈で意味が破綻する。
  • 振る舞いを付与できるかどうかではなく、その interface の契約が成立するかどうかで判断する。
  • 迷ったら、まず合成(has-a)を検討する。
Hugo で構築されています。
テーマ StackJimmy によって設計されています。