継承より良い方法を選ぼう
書籍『良いコードの道しるべ 変化に強いソフトウェアを作る原則と実践』に書ききれなかった内容を書き記すシリーズ第2弾として、オブジェクト指向プログラミングにおけるクラスの継承の置き換えについて紹介します。
すでに多くの記事でも言及されている通り、クラスの継承はしばしば保守性に関する重大な問題をもたらします。
私自身、継承によって複雑になったコードに幾度となく悩まされてきました。
このブログ記事では、継承ではなく具体的にどのようなコードを書くべきかについて紹介します。
用語の整理
ここでやめるべきと述べている継承とは、インターフェースの実装は含みません。
何らかの実装を持つ抽象クラスに対する継承を避けることを提案しています。
Kotlinで言えばabstractクラス及びopenクラス、Javaで言えばabstractクラス及びfinalでない通常クラスに対する継承を指します。
継承の問題点
継承の大きな問題は、複雑化しやすいことです。
最初はシンプルな継承であっても、コードを変更していくうちにいつの間にか制御できないくらい複雑化していることがよくあります。
具体的には、以下のような状況に陥りがちです。
- 基底クラスが多くの責務を持つようになる。(単一責任原則の違反)
- 特定のサブクラスに特化したコードが基底クラスに含まれる。
- 多重に継承されていてどこに実装があるのかわからない。
- サブクラスと基底クラスで処理がいったりきたりする。
現時点ではシンプルな実装であったとしても、将来の複雑化を防ぐため、他に採用できる代案があればそれを採用すべきです。
共通化のための継承はやめよう
継承の一番のアンチパターンは、一部の処理を共通化するために使うことです。
例えば、以下のようにプロパティに対する制御が共通だったとします。
class ClassA {
private var commonValue: String = ""
fun updateCommonValue() {
// (省略) `commonValue` を参照して更新する処理
}
}
class ClassB {
private var commonValue: String = ""
fun updateCommonValue() {
// (省略) `commonValue` を参照して更新する処理
}
}
このような場合、抽象クラスを使うと一見シンプルに共通化できそうに見えます。
abstract class Base {
protected var commonValue: String = ""
fun updateCommonValue() {
// (省略) `commonValue` を参照して更新する処理
}
}
こういった共通化を目的として基底クラスを組み立てていくと、基底クラスが様々な責務を持つようになります。
共通化が目的であれば、処理自体の目的に合わせてクラスや関数を分離すべきです。
例えば以下のように関数を使った共通化で十分かもしれません。
引数や返り値を使うことでデータの流れを追いやすくなります。
class ClassA {
private var commonValue: String = ""
fun updateCommonValue() {
commonValue = getUpdatedCommonValue(commonValue)
}
}
fun getUpdatedCommonValue(commonValue: String): String {
// (省略) 新しい `commonValue` を作成して返す
}
状態を管理している場合など、プロパティに対する制御が重要であれば、以下のようにクラスとして抜き出すことも選択肢の一つです。
この方法をコンポジションと呼びます。
class ClassA {
private val helper = Helper()
fun updateCommonValue() {
helper.updateCommonValue()
}
}
class Helper {
var commonValue: String = ""
private set
fun updateCommonValue() {
// (省略) `commonValue` を参照して更新する処理
}
}
一部の挙動変更のための継承はやめよう
あるクラスがあって、その一部の処理を書き換えたようなコードを用意したかったとします。
特に、呼び出し側からは拡張後のクラスを拡張前のクラスと同じように扱えるようにしたい場合、継承を使う十分な理由があるように見えます。
例として、以下のコードはデバッグ時にAPIのレスポンス結果を常にエラーに変更できるようにしています。
fetch
関数の挙動を変えるために、 DebugApiClient
は ApiClient
を継承しています。
open class ApiClient {
open fun fetch(): String {
// (省略)サーバからデータを取得して返す
}
fun other() {
// (省略)他のリクエスト
}
}
class DebugApiClient: ApiClient() {
var debugMode: DebugMode = DebugMode.ACTUAL
override fun fetch(): String {
return when (debugMode) {
DebugMode.ACTUAL -> super.fetch()
DebugMode.ALWAYS_ERROR -> throw ApiException()
}
}
}
上記のコードは、継承を使わず以下のように書くことが可能です。
interface ApiClient {
fun fetch(): String
fun other()
}
class RealApiClient: ApiClient {
override fun fetch(): String {
// (省略)サーバからデータを取得して返す
}
override fun other() {
// (省略)他のリクエスト
}
}
class DebugApiClient(private val real: RealApiClient): ApiClient() {
var debugMode: DebugMode = DebugMode.ACTUAL
override fun fetch(): String {
return when (debugMode) {
DebugMode.ACTUAL -> real.fetch()
DebugMode.ALWAYS_ERROR -> throw ApiException()
}
}
override fun other() {
real.other()
}
}
このように、あるクラスが別のクラスに一部の処理を任せる方法を委譲と呼びます。
Kotlinでは、by
を使うことで、委譲をより簡単に実現できます。
class DebugApiClient(private val real: RealApiClient): ApiClient by real {
var debugMode: DebugMode = DebugMode.ACTUAL
override fun fetch(): String {
return when (debugMode) {
DebugMode.ACTUAL -> real.fetch()
DebugMode.ALWAYS_ERROR -> throw ApiException()
}
}
// Otherの定義は不要
}
委譲を使うことの最大のメリットは、呼び出す側と呼び出される側がはっきりしていることです。
継承を使うと基底クラスからサブクラスを呼び出すことが可能になりますが、これが複雑さをもたらします。
委譲でやりたいことが十分実現できる場合、こちらを使ったほうが良いでしょう。
ストラテジーパターンを使う
継承が使われる状況の1つに、実行順序を保証したい場合があります。
例えば、以下のようにメインの処理を行う前に前処理もしくは後処理、もしくはその両方を共通して行いたい場合、以下のような基底クラスで実現できます。
abstract class Base {
fun run() {
// (省略) 前処理
execute()
// (省略) 後処理
}
protected abstract fun execute()
}
これはテンプレートメソッドパターンと呼ばれるデザインパターンです。
一方上記のコードは、以下のようにインターフェースを使って書き換えることが可能です。
interface Strategy {
fun execute()
}
class Runner(private val strategy: Strategy) {
fun run() {
// (省略) 前処理
strategy.execute()
// (省略) 後処理
}
}
これはストラテジーパターンと呼ばれるデザインパターンを使っています。
ただしこの方法には2点のデメリットが存在します。
- 各Strategyを直接呼び出すことができ、前処理や後処理をスキップできる
- クラスの数が増える
個人的にはそれでもストラテジーパターンを使うので十分だと考えていますが、メリットとデメリットを比較したうえで抽象クラスを採用することが可能です。
ライブラリ・フレームワークが継承を強制してくる場合
最後の問題はライブラリやフレームワークがクラスの継承を強制してくる場合です。
こればっかりは仕方がないので、自分で管理するコードが、その設計思想にできる限り引きずられないようにします。
処理を共通化したければ、継承を繰り返すのではなく関数やクラスに抜き出します。
ライブラリに依存した実装クラスに多くの実装を書かないことは、ライブラリ・フレームワークに過度に依存したコードを避けることにも繋がります。
まとめ
昔はよく使われたプラクティスも、時代の変化や言語機能の進化に伴い好まれなくなっているケースも少なくありません。
継承はその最たる例だと考えています。
関数への分割、コンポジション、委譲、そしてストラテジーパターンの適用といった代替手段を優先することで、同じ目的をよりシンプルに達成できます。
継承を選ぶ前に、将来の変更に強いか、読みやすさに寄与しているかを一度立ち止まって検討することをおすすめします。