『オブジェクト指向設計実践ガイド』を読んでて学びがあったので、自分なりに整理してメモ。
要約
継承において、あるメソッドを呼んだら各サブクラスに共通の処理をしつつ、各サブクラス独自の処理もしたいとき、以下の方法がある。
- superをつかう
-
Parent#method
を定義し、共通処理を記述する -
Child#method
を定義し、superで共通処理(Parent#method
)を呼んだうえで、独自の処理を記述する
-
- フックメソッドをつかう
-
Child#method
は定義しない -
Child#feature
に各サブクラス独自の処理を定義する -
Parent#method
を定義し、共通処理を記述したうえで、独自処理(Child#feature
)を呼び出す
-
両者を比較すると、superをつかうよりも、フックメソッドをつかう方が良いことが多い。((もちろん場合によるとは思う))
サンプル
まず、superを使う例。
class LunchSet
def serve
['salad', 'coffee']
end
end
class MeatLunchSet < LunchSet
def serve
super << 'meat'
end
end
class FishLunchSet < LunchSet
def serve
super << 'fish'
end
end
MeatLunchSet.new.serve # => ["salad", "coffee", "meat"]
続いて、フックメソッドをつかう例。
class LunchSet
def serve
['salad', 'coffee', main_dish]
end
private
def main_dish
raise NotImplementedError
end
end
class MeatLunchSet < LunchSet
private
def main_dish
'meat'
end
end
class FishLunchSet < LunchSet
private
def main_dish
'fish'
end
end
MeatLunchSet.new.serve # => ["salad", "coffee", "meat"]
serveメソッドには親クラスのLunchSetが応答し、その中でフックメソッドのmain_dishを呼び出すようにしている。
上記をそれぞれ「super版」「フックメソッド版」とし、以下2つの観点から違いを述べる。
観点1: 子クラスの親クラスに対する依存度
子クラスは親クラスについて、以下のことを知っている。
- super版の場合
- 親クラスはserveというメソッドに応答する。
- その引数は0個である。
- その戻り値は
<<
メソッドに応答する。
- フックメソッド版の場合
- 親クラスはserveというメソッドに応答する。
- その引数は0個である。
フックメソッド版の方が親クラスについて知っていることが少ない。つまり、親クラスに対する依存度が低い。
オブジェクト間の依存度はなるべく低く保っておいた方が、変更が波及しないので拡張する際のコストが小さい。
よってフックメソッド版の方が望ましいコードと言える。
では、実際に変更が生じた場合の具体例を以下でみてみよう。
LunchSet#serve
の戻り値が配列から文字列に変更された状況を想定する。
super版の場合
class LunchSet
def serve
['salad', 'coffee'].join(', ') # changed
end
end
class MeatLunchSet < LunchSet
def serve
super + ', meat' # changed
end
end
class FishLunchSet < LunchSet
def serve
super + ', fish' # changed
end
end
MeatLunchSet.new.serve # => "salad, coffee, meat"
親クラスだけではなく、子クラスでも変更が生じている。
これは、MeatLunchSet#serve
内の処理が「LunchSet#serve
の戻り値<<
メソッドに応答する」という事実に依存していたためである。
フックメソッド版の場合
class LunchSet
def serve
['salad', 'coffee', main_dish].join(', ') # changed
end
private
def main_dish
raise NotImplementedError
end
end
class MeatLunchSet < LunchSet
private
def main_dish
'meat'
end
end
class FishLunchSet < LunchSet
private
def main_dish
'fish'
end
end
MeatLunchSet.new.serve # => "salad, coffee, meat"
変更されたのは親クラスのみであり、子クラスには変更が生じていない。
よって、複数のクラスに波及することなく、低コストで変更を実現できるという点で、フックメソッド版の方が望ましい設計と言える。
観点2: 共通処理の呼び出しを忘れるリスク
super版では、1つのメソッドを呼び出す継承階層の旅の中で、親クラスが共通処理を行い、子クラスが独自処理を行う。複数のクラスがこっそりと連携しているため、そこでバトンが取り落とされても、気づかれない場合がある。
フックメソッド版では、最初のメソッドによって親クラスの共通処理が呼ばれ、親クラスは改めてselfに対して独自処理を呼び出し、子クラスがこれを引き受ける。この連携の過程は2回のメソッド呼び出しから構成され明示的であるため、バトンの受け渡しは衆目に晒されており、いつの間にかひっそりと過誤が生じるリスクは小さい。
以下で、サブクラスとしてPastaLunchSetを新たに作成することを想定する。
super版の場合
適切にPastaLunchSetクラスを実装するために必要なステップは2つある。
-
PastaLunchSet#serve
を定義する。 -
PastaLunchSet#serve
の中で共通処理を行うためにsuperを呼び出す。
1.については、既存の親クラスにも子クラスにもserveメソッドがあることから、その必要性は明らかである。
しかし1. に比べると、2.の必要性はそれほど明白ではない。既存の子クラスのserveメソッドの中身を慎重に観察して、嗅ぎださなければならない。
以下に2.が漏れてしまったケースを示す。
class LunchSet
def serve
['salad', 'coffee']
end
end
class MeatLunchSet < LunchSet
def serve
super << 'meat'
end
end
class FishLunchSet < LunchSet
def serve
super << 'fish'
end
end
class PastaLunchSet < LunchSet
def serve
'pasta'
end
end
PastaLunchSet.new.serve # => "pasta"
このパスタランチでは残念ながら食後のコーヒーを楽しむことはできない。
もちろん、これほど簡単な例ではsuperの呼び出しを忘れることは現実的ではない。しかしより複雑なアプリケーションにおいては、十分に有り得ることだろう。
しかもこの例では、メソッドを呼び出した時点ではエラーを生じずに、おそらくは実際にサラダやコーヒーに手を付けようとした時点で、つまり真に問題がある箇所とは別の箇所と形態においてエラーを誘発する。
原因を特定しにくいという点で、たちの悪い不具合だと言える。
フックメソッド版の場合
こちらの場合、適切にPastaLunchSetクラスを実装するために必要なステップは1つだけで済む。
-
PastaLunchSet#main_dish
を定義する。
既存の子クラスにmain_dishメソッドがあるため、この必要性は明白である。
class LunchSet
def serve
['salad', 'coffee', main_dish]
end
private
def main_dish
raise NotImplementedError
end
end
class MeatLunchSet < LunchSet
private
def main_dish
'meat'
end
end
class FishLunchSet < LunchSet
private
def main_dish
'fish'
end
end
class PastaLunchSet < LunchSet
private
def main_dish
'pasta'
end
end
PastaLunchSet.new.serve # => ["salad", "coffee", "pasta"]
これならば、必要な記述が漏れてしまうリスクは、super版の場合よりも格段に低いだろう。
要約(再掲+α)
継承において、あるメソッドを呼んだら各サブクラスに共通の処理をしつつ、各サブクラス独自の処理もしたいとき、以下の方法がある。
- superをつかう
-
Parent#method
を定義し、共通処理を記述する -
Child#method
を定義し、superで共通処理(Parent#method
)を呼んだうえで、独自の処理を記述する
-
- フックメソッドをつかう
-
Child#method
は定義しない -
Child#feature
に各サブクラス独自の処理を定義する -
Parent#method
を定義し、共通処理を記述したうえで、独自処理(Child#feature
)を呼び出す
-
両者を比較した場合、フックメソッドをつかう方が以下の点で望ましい。((もちろん、場合による。))
- 子クラスの親クラスに対する依存度を低く保つことで、より低コストでの拡張が可能となる。
- 子クラスを増設/改修した際に、共通処理の呼び出しを実装し損ねるリスクが低い。