オブジェクト指向はプログラミングの基本です。そして、継承はオブジェクト指向の基本的な操作ですから、プログラマーは呼吸をするように継承をできなくてはならないはずです1。
しかしその割に、ダメな継承の使い方をして、スパゲッティコードになるのを実務でしばしば見かけます。
これは、継承の「良い使い方」はデザインパターンとしてリストアップされているのに、「悪い使い方」はまとまっていないせいかもしれません。そこで、自分だったらコードレビューで をつけるような「悪い継承の例」を挙げてみました2。
(この記事は個人的な経験によるもので、理論的な裏付けがあるものではありません。ご意見やオススメ本があれば、コメントをお願いします。また、この記事は随時細かい表現の修正をしています。)
TL;DR
- 継承を使ってはならない
- Mix-inを使ってはならない
-
super
は不吉な兆候 - 例外条項
- インターフェースの実装(implements) は使ってもいい(こともある)
- Template Method パターンは使ってもいい(こともある)
悪い継承
メソッド共有のための継承
共通して使うメソッドだから、基底クラスに定義しよう!と思ってはいけません。
# 悪い例
# 基底クラス
class Base
# 何かのAPIを呼び出せるメソッド
# どのサブクラスでも使うから、基底クラスで定義するのが便利だよね
def call_some_api(params)
...
end
end
# 子クラス
class Foo < Base
def method
...
call_some_api(params) # APIを呼び出そう
...
end
end
# 別のクラス
class Bar < Base
...
def method
...
call_some_api(params) # このクラスでもAPIを呼び出そう
...
end
end
基底クラスに共通メソッドを定義すると、
- 基底クラスと派生クラスの間
- 派生クラス同士の間(基底クラスを介して)
という依存関係が生じるので、共通メソッドを変更・機能追加しづらくなります。
また、上記の例では共通メソッドはシンプルなもの1つだけですが、実際の開発では、
- 様々な派生クラスでの使い方に対応するために、共通メソッドに引数が追加される
- 共通メソッドが別の共通メソッドを呼び出す
- 2個3個10個と共通メソッドが増えていく
といった現象が起き、やがて 神クラス ないし 神・基底クラス が誕生します。
単にクラス間で処理を共有したいだけなら、適当な別クラスを作って static メソッドを定義しましょう。
module SomeAPI
# 何かのAPIを呼び出せるメソッド
def self.call_some_api(params)
...
end
end
# 子クラス
class Foo < Base
def method
...
SomeAPI.call_some_api(params) # APIを呼び出そう
...
end
end
# 別のクラス
class Bar < Base
...
def method
...
SomeAPI.call_some_api(params) # このクラスでもAPIを呼び出そう
...
end
end
なお、TemplateMethodパターンの場合は、この限りではありません。派生クラスの形が限定されるためか、神化の頻度は低いようです(しないわけではない)。
メンバ変数の共有のための継承
たまたま同じ型・名前のメンバ変数だからと言って、共通の基底クラスを作ったりしてはいけません。
# 悪い例
# UserとCarに共通のメンバ変数があるので基底クラスを設けているが、
# UserとCarは全く別種のクラスなので、共通の基底クラスがあるのはおかしい。
# (本当にユーザーと自動車を同列に扱うアプリケーションなら話は別ですが)
# 基底クラス
class Base
# UserとCarで共通のメンバ変数を定義しよう
def initialize(name, age)
@name = name
@age = age
end
end
# ユーザークラス
class User < Base
def initialize(name, age, gender)
super(name, age) # ユーザーには名前と年齢(age)があるから、親のコンストラクタを呼ぼう
@gender = gender
end
end
# 自動車クラス
class Car < Base
def initialize(name, age, vehicle_type)
super(name, age) # 自動車には名前と年数(age)があるから、親のコンストラクタを呼ぼう
@vehicle_type = vehicle_type
end
end
「メソッド共有のための継承」に似ていますが、派生クラスへの影響があるため、メンバ変数を変更しにくくなります。
別なクラスは、メンバ変数が同じであっても、別々に定義しましょう。
機能追加・機能変更のための継承
元のクラスをカスタマイズするために継承するのもダメです。
# 配列
class Array
def push(item)
# 要素を追加するメソッド
end
def sort!
# ソートするメソッド
end
# 他にも色々なメソッドがある
end
# 自動ソートされる配列を作ったぜ!
class AutoSortedArray(Array)
def push(item)
super(item)
sort! # sort
end
end
派生クラスでオーバーライドされる可能性があると、基底クラスを変更しにくくなります。
クラスをカスタマイズしたい時は継承ではなく委譲をします。つまり、内部に元クラスのインスタンスを持つ、ラッパーを作ります。
# 自動ソートされる配列を作ったぜ!
class AutoSortedArray(Array)
def initialize
@array = [] # 内部に元クラスのインスタンスを持つ
end
# カスタマイズしたメソッド
def push(item)
array.push(item)
array.sort!
end
# 他にも必要なメソッドを元クラスのインスタンスに委譲する
def [](index)
array[index]
end
def each(&block)
array.each(&block)
self
end
end
また、カスタマイズが頻繁に必要なのであれば、基底クラスをカスタマイズ前提で実装する(Template Method パターン)べきかもしれません。
良い継承
インターフェースの継承(implements継承)
インターフェースはメソッドの名前・シグニチャのリストなので、継承しても問題は生じません(多分、大抵の場合は)3。
Template Method パターン
Template Method パターン では、子クラスは親クラスで指定されたメソッドを実装するだけなので安全です。
ただし、以下のようなことが起きていれば、それは悪い兆候です。設計を考え直しましょう。
- オーバーライドするメソッドの数が多い
- オーバーライドするメソッドで、super (基底クラスの同名メソッド)を呼んでいる
- オーバーライドするメソッドで、基底クラスの別のメソッドを呼んでいる
- コンストラクタをオーバーライドしている(i.e. 親クラスにメンバ変数がある)
- 派生クラスが、基底クラスのメンバ変数を参照している
また、これらに該当しなかったとしても、コード量・メソッド数が多くなってきたら、それは再設計すべき兆候です。
Template Methodパターンの代替案としては、Strategy パターン や高階関数などがあります。
Ruby の Mixin について
Ruby の Mixin も継承の一種です。そのため、「悪い継承」のパターンは Mixin にも当てはまります。
例えば、
- 共通で使うメソッドを提供するMixinを定義する
といったことは避けましょう。Mixinは禁止くらいに思ってもいいかもしれません。
Rails の場合
Railsでも、
- 基底クラスに共通メソッドを定義する
- app/controller/concerns に Mixin を定義する
といったことは避けましょう。
もちろん、RailsにはApplicationXXXXという名前の「共通メソッドを定義するための基底クラス」が標準で用意されています。本当に共通して使うメソッドなら、そこに定義するのがベストの場合もあります。
しかし、RailsにはフックやValidatorなどの継承・Mixin以外のコード共有の手段も豊富に提供されていますから、安易に継承やMixinに手を出す前に、他の方法も調べてみてください。
余談
オブジェクト指向を使い始めの人には「オブジェクト指向エクササイズ」に挑戦することをお勧めします。
-
オブジェクト指向ではない言語・継承が無いオブジェクト指向言語もありますし、オブジェクト指向は禁止するべきなんて話もありますが、JavaやRubyで仕事をするなら継承は必須スキルです。 ↩
-
「神クラス」「ドメインモデル貧血症」のようなアンチパターンもありますが、この記事では継承に関係するものだけを書きました。 ↩
-
Java系のプロジェクトでDIのためにインターフェースが量産されるのは、それはそれで問題だとは思いますが。 ↩