k-tokitoh

2019-09-14

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

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

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

要約

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

両者を比較すると、 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: 子クラスの親クラスに対する依存度

子クラスは親クラスについて、以下のことを知っている。

フックメソッド版の方が親クラスについて知っていることが少ない。つまり、親クラスに対する依存度が低い。

オブジェクト間の依存度はなるべく低く保っておいた方が、変更が波及しないので拡張する際のコストが小さい。

よってフックメソッド版の方が望ましいコードと言える。

では、実際に変更が生じた場合の具体例を以下でみてみよう。

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 版の場合よりも格段に低いだろう。

要約(再掲+α)

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

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

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

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