歪んだ親子関係はプログラマを殺す 〜誤った継承関係による共通化の功罪〜
オブジェクト指向学びたての頃は「共通化」 = 「親クラスに実装」くらいに考えていた時がありました。
当時はそれで良いと思っていましたし、実際コード量も減ったように見えるのでガンガン継承を使っていたと記憶しています。
継承による共通化の罠
共通化の手段として継承を利用するのは間違いではありません。
しかし、継承は親クラスと子クラスとが密結合になるという点に注意しなければなりません。
例えば以下のような2つのクラスがあったとします。
class Human {
public function run() {
// do something
}
public function eat() {
// do something
}
}
class Car {
public function run() {
// do something
}
public function refuel() {
// do something
}
}
この二つのクラスにはrun()という同じ処理が記述されています。「同じ処理は2度書かない」という原則を盲信するのであれば、この処理は何かしらの形で共通化したいと思うはずです。
共通化の手段として継承を選んだとしましょう。すると以下のようなコードになります。
abstract class Something() {
public function run() {
// do something
}
}
class Human extends Something {
public function eat() {
// do something
}
}
class Car extends Something {
public function refuel() {
// do something
}
}
まあそれなりに良さげに見えます。run()メソッドを抽象クラスに移すことでHumanクラスとCarクラスの記述量を共通化することができました。今後runメソッドを修正したくなった場合、抽象クラスを修正するだけで良いのでメンテナンスコストも下がるかもしれません。ここまでは問題ないように見えます。
ここでBusというクラスが増えたとします。
class Bus {
public function run() {
// do something
}
public function refuel() {
// do something
}
public function payFee() {
// do something
}
}
run()とrefuel()はCarクラスと同じ処理なのでこれもSomethingクラスに共通化できそうです。
abstract class Something() {
public function run() {
// do something
}
public function refuel() {
// do something
}
}
class Human extends Something {
public function eat() {
// do something
}
}
class Car extends Something {
}
class Bus extends Something {
public function payFee() {
// do something
}
}
コードはさらに減り、なんだかオブジェクティブなコーディングになっているように見えます。
エンジニアになりたての私は上記のような最悪な継承の使い方をしたコードをたくさん書いていたように思います。。
ここまで極端な例であれば多くの方が気づいたと思いますが、上記のコードには2つの重大な欠点があります。
- Humanクラスがrefuel()を持ってしまっている
- run()の拡張、修正が困難である
まず第一に本来Humanクラスが持つべきでないrefuelメソッドを持ってしまっています。refuelとは「ガソリンを入れる」という意味ですが、人間はガソリンで動くわけではないためrefuelメソッドをHumanクラスが持つべきではありません。にもかかわらず継承で共通化してしまったがために意図しないメソッドがHumanクラスがアクセスできるという状態になっています。これは抽象クラスレベルで処理を実行する際に意図せぬ動作を引き起こす可能性があります。
第二に、run()メソッドの拡張、修正が非常に困難な実装になっています。例えば、Carクラス、Busクラスがrun()メソッドを呼び出した時に「ガソリンを消費する」という処理を追加するとします。本来であればrun()メソッドの処理の中にガソリンを減らす処理を記述すれば良いはずです。ところがこのメソッドはHumanクラスからも呼び出されるメソッドであるため、単純に「ガソリンを減らす」という処理を追加してしまうと「人間がガソリンを消費して走る」という妙なメソッドが出来上がってしまいます。本質的に異なるはずのrun()メソッドを単に現在の実装が同じという理由だけで共通化してしまったがために、run()メソッドの拡張、修正が非常に困難になってしまっています。
本質的に異なるものは継承による共通化を避ける
上記の実装では、HumanクラスとCarクラスという全く本質の異なるクラスを継承により共通化してしまったことに根本的な誤りがあります。
もう少しオブジェクティブな表現の仕方をすると、is-aの関係でないクラスを抽象化すべきではないのです。
継承による共通化はオブジェクティブ思考における非常に協力な実装の手段です。
ただし、行きすぎた継承による共通化は親子関係による密結合を助長し、保守性を著しく損なうコードを生み出してしまうことを理解すべきです。
例え、現時点でHumanクラスのrun()メソッドとCarクラスのrun()メソッドに同じ処理が記述されていたとしても、この場合は無理に共通化すべきではないでしょう。その点、BusクラスがCarクラスを継承し、run()メソッド、refuel()メソッドを共通化するのは良い方法です。これは「Bus is a Car」の関係が成り立っているためです。
「同じコードを2度書かない」を盲信しない
「同じコードを2度書かない」というのは誰もが納得する主張です。ただし、無理な共通化によって密結合が進むことは避けるべきです。
そうでなければ「リファクタリングの結果、コードの記述量は減ったがバグが大量に増えた」という本末転倒な結果を招くことになります。
歪んだ親子関係はプログラマを殺す
実際のプロジェクトでは上記のような極端な例は少ないにしても、本質的には同じようなコードに出くわすことが少なくありません。
誤った継承関係のソースに手を加える場合、最悪全てのソースを書き換える必要があります。
まさに歪んだ親子関係はプログラマを殺すのです。