要点
- 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 である。合成・委譲・転送の用語を整理する
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 を継承することは「その型として扱える」と宣言する行為である。契約が成立しない継承がなぜ危険かを具体例で示す
まとめ
- 基底クラスの継承は is-a / LSP の観点で慎重に判断する。
- interface の継承にも SRP・ISP・継承より合成 を適用すべきである。
- Activity / Fragment に責務を集約せず、振る舞いは合成と委譲で分離する。
言語仕様上 : で書けることと、良い設計であることは同義ではない。