superをフックメソッドで代替すると良い、という話

下記書籍を読んでて学びがあったので、自分なりに整理してメモ。

オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方

要約

継承において、あるメソッドを呼んだら各サブクラスに共通の処理をしつつ、各サブクラス独自の処理もしたいとき、以下の方法がある。

  • superをつかう
    • Parent#methodを定義し、共通処理を記述する
    • Child#methodを定義し、superで共通処理(Parent#method)を呼んだうえで、独自の処理を記述する
  • フックメソッドをつかう
    • Child#methodは定義しない
    • Child#featureに各サブクラス独自の処理を定義する
    • Parent#methodを定義し、共通処理を記述したうえで、独自処理(Child#feature)を呼び出す

両者を比較すると、superをつかうよりも、フックメソッドをつかう方が良いことが多い*1

サンプル

まず、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版の場合
    1. 親クラスはserveというメソッドに応答する。
    2. その引数は0個である。
    3. その戻り値は<<メソッドに応答する。
  • フックメソッド版の場合
    1. 親クラスはserveというメソッドに応答する。
    2. その引数は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つある。

  1. PastaLunchSet#serveを定義する。
  2. 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つだけで済む。

  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)を呼び出す

両者を比較した場合、フックメソッドをつかう方が以下の点で望ましい。*2

  • 子クラスの親クラスに対する依存度を低く保つことで、より低コストでの拡張が可能となる。
  • 子クラスを増設/改修した際に、共通処理の呼び出しを実装し損ねるリスクが低い。

*1:もちろん場合によるとは思う

*2:もちろん、場合による。