Featured image of post is-a 継承の判断基準と interface の落とし穴

is-a 継承の判断基準と interface の落とし穴

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

要点

  • is-a 関係が成立していても、基底クラスの不変条件を満たせなければ継承すべきではない(LSP)。
  • interface の多重継承にも設計原則(SRP・ISP)を適用すべき である。基底クラスの継承には慎重なのに、interface になると無批判に継承するのは一貫性がない。

コード例で考える

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface Walkable { fun walk() }
interface Talkable { fun talk() }
interface Flyable { fun fly() }

// A OK: Bird is-a Flyable, Walkable
class Bird : Flyable, Walkable

// B OK: Human is-a Walkable, Talkable
class Human : Walkable, Talkable

// C OK: Superman is-a Flyable, Walkable, Talkable
class SupermanC : Flyable, Walkable, Talkable

// D NG: Superman is-a Bird ではない
class SupermanD : Bird(), Talkable

// E ⚠️: is-a は成立しているが、LSP 違反のリスクがある
class SupermanE : Human(), Flyable

// F NG: Activity の責務外の interface を多重継承している(SRP 違反)
class MainActivity : AppCompatActivity(),
    OnClickListener, OnScrollListener, TextWatcher, NavigationListener

A〜C は問題ない。D・E・F がそれぞれ異なる理由で問題になる。

D — is-a 関係にない継承

Superman は Bird ではない。is-a 関係にない基底クラスを継承すべきではない。これは多くの記事で語られており、賛同を得やすい主張である。

E — LSP 違反のリスク

「Superman は Human である」は一見妥当に見える。
しかし、Human に val maxLifespan: Int = 80 という不変条件が追加されたらどうなるか。
寿命 160 歳の Superman はこの不変条件を満たせない。

リスコフの置換原則(LSP)は「基底クラスのインスタンスを派生クラスで置換しても、プログラムの正しさが保たれること」を要求する。一見 is-a に見える継承でも、基底クラスの不変条件を派生クラスが満たせなければ LSP 違反となる。

個別の実装次第で回避できる場合もあるが、本記事の論点は継承構造の脆さにある。
言語仕様上可能であることと、良い設計であることは同義ではない。

F — SRP 違反と関心の分離

D・E は is-a / LSP の観点で NG であった。F は別の観点 — 単一責任の原則(SRP) と関心の分離 — で NG となる。

MainActivity の主たる責務は UI のライフサイクル管理である。OnClickListener, OnScrollListener, TextWatcher, NavigationListener は、いずれも Activity の責務ではない。

: OnClickListener という宣言は、Activity の責務外の振る舞いを is-a で取り込む行為であり、そこから責務の蓄積が始まる入り口になる。

F の改善:is-a から has-a へ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// NG: Activity is-a OnClickListener(Activity がクリックリスナー「である」)
class MainActivity : AppCompatActivity(), View.OnClickListener {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.button).setOnClickListener(this)
    }

    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.button -> { /* 処理 */ }
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 途中経過: クリック処理の責務を ViewModel に委譲する
@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel() {
    val onClickListener = View.OnClickListener { v ->
        when (v?.id) {
            R.id.button -> onButtonClick()
        }
    }

    private fun onButtonClick() { /* 処理 */ }
}

class MainActivity : AppCompatActivity() {
    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        findViewById<Button>(R.id.button).setOnClickListener(viewModel.onClickListener)
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// OK: Compose 移行で interface 自体が不要になる
@HiltViewModel
class MainViewModel @Inject constructor() : ViewModel() {
    fun onButtonClick() { /* 処理 */ }
}

@Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel()) {
    Button(onClick = viewModel::onButtonClick) {
        Text("Button")
    }
}
パターン 関係 クリック処理の所在
NG: Activity : OnClickListener is-a(である) Activity が担う(SRP 違反)
途中経過: listener を ViewModel に移動 合成(has-a)+ 委譲 ViewModel が担う
OK: ViewModel + Compose 合成(has-a)+ 委譲 ViewModel が担う(interface 不要)

: OnClickListener を外すことで、Activity は「クリックリスナーである」という宣言をやめる。これが is-a と has-a の分かれ目であり、前回の記事で述べた「継承より合成」の最も小さな実践である。

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

interface の継承にも設計原則を適用する

「is-a 関係にない基底クラスの継承は避けるべき」— これは広く語られている。しかし「is-a 関係にない interface の継承も避けるべき」と明言する記事は多くない。

この主張を支える設計原則は十分に確立されている。

  • 単一責任の原則(SRP) — interface の多重継承は、クラスに複数の責務を持たせることになる
  • インターフェイス分離の原則(ISP) — 不要な interface への依存は避けるべきである
  • 継承より合成 — 振る舞いの付与は、継承ではなく合成で行うべきである

Kotlin では class でも interface でも : で継承を表現する。

1
2
class Superman : Bird()              // is-a 違反 → NG(広く語られている)
class MainActivity : OnClickListener // SRP 違反 → 同じ `:` なのに見過ごされがち

基底クラスの継承には is-a / LSP の観点で慎重になれるのに、interface の継承になると設計原則を適用しなくなるのは一貫性がない。

interface のデフォルト実装のリスク

Kotlin の interface はデフォルト実装を持てる。これがさらにリスクを増す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 当初:シンプルな契約。この時点では安全。
interface Flyable {
    fun fly()
}

// ある日:誰かがデフォルト実装を追加した。
interface Flyable {
    val maxAltitude: Int get() = 10000
    fun fly() { /* デフォルトの飛行処理 */ }
}
1
2
3
// Human からは基底クラスの実装を、Flyable からはデフォルト実装を受け取る。
// 実質的に複数の実装を継承する構造に近い。
class Superman : Human(), Flyable

追加した本人は「便利なデフォルトを足しただけ」のつもりでも、継承先で意図しない影響が出る。interface にデフォルト実装を不用意に追加すると、設計上の複雑性が増す。

interface の継承が型の契約としてどう機能するかは、以下の記事で掘り下げている。

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

まとめ

  • 基底クラスの継承は is-a / LSP の観点で慎重に判断する。
  • interface の継承にも SRP・ISP・継承より合成 を適用すべきである。
  • Activity / Fragment に責務を集約せず、振る舞いは合成と委譲で分離する。

言語仕様上 : で書けることと、良い設計であることは同義ではない。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。