マイペースなRailsおじさん

Ruby、Ruby on Rails、オブジェクト指向設計を主なテーマとして扱います。だんだん大きくなっていくRuby on Rails製プロダクトのメンテナンス性を損なわない方法を考えたり考えなかったりしている人のブログです。

extendを使ったStrategyパターン風のアルゴリズム切り替え

に、extendの興味深い利用方法が出てきていたのでご紹介。この本の、8.13 タイプコードからモジュールのextendへ(Replace Type Code with Module Extention) の内容をStrategyパターン と比較します。

やりたいこと

レンタルビデオの料金計算を行うプログラムを考えます。料金の計算は、下記のルールで行います。

  • レンタル料は、ビデオの基本レンタル料に利用者種別ごとの割引を適用して計算する。
  • ビデオごとに値段が設定してあり、これを基本レンタル料と呼ぶ。
  • 利用者種別には、子供と大人の二種類がある。
    • 大人: 基本レンタル料から100円を引いた金額がレンタル料となる
    • 子供: 基本レンタル料の半額がレンタル料となる

素直にcase文

case文を使って、計算方法を切り替えます。

Video = Struct.new(:price)

class Rental
  ADULT_DISCOUNT_AMOUNT = 100
  CHILD_DISCOUNT_RATE = 0.5

  attr_writer :person_kind

  def initialize(video, person_kind)
    @video = video
    @person_kind = person_kind
  end

  def price
    case @person_kind
    when :adult
      @video.price - ADULT_DISCOUNT_AMOUNT
    when :child
      @video.price * CHILD_DISCOUNT_RATE
    end
  end
end

利用者種別が2種類のみと少なく、また計算方法が単純な間は、priceメソッドは小さく収まります。 priceは呼び出しがあるたびに計算し直します。これは、person_kindが変更されることを許容するためです。

このクラスを使って、基本レンタル料が300円のビデオのレンタル料を計算する例を示します。

video = Video.new(300)
child_rental = Rental.new(video, :child)
adult_rental = Rental.new(video, :adult)
p child_rental.price # => 150.0
p adult_rental.price # => 200
adult_rental.person_kind = :child
p adult_rental.price # => 150.0

Strategyパターンを使う

さきほどの例では、料金計算方法の切り替えにcase文を使いました。十分にシンプルなコードになったので、コードの複雑さに関して問題はありません。

ここで、料金体系が追加される可能性について考えます。利用者種別が、20代、シニア、ポイントカード所持者など追加するかもしれません。case文でこれらの計算方法を切り替える場合、case文が巨大化していくことは明らかです。また、料金の計算方法ももっと複雑化するかもしれません。また、case文が追加されるたびに、Rental.priceのメソッド切り替えが壊れていないかに気をつける必要もあります。

そこで、利用者種別が追加されたとしてもStrategyパターンをつかって、Rentalクラスのに変更を加えなくて済む方法を考えます。

Video = Struct.new(:price)

class Pricer
  def initialize(video)
    @video = video
  end
end

class ChildPricer < Pricer
  DISCOUNT_RATE = 0.5

  def price
    @video.price * DISCOUNT_RATE
  end
end

class AdultPricer < Pricer
  DISCOUNT_AMOUNT = 100

  def price
    @video.price - DISCOUNT_AMOUNT
  end
end

class Rental
  def initialize(video, person_kind)
    @video = video
    self.person_kind = person_kind
  end

  def person_kind=(person_kind)
    # person_kindが変更されたらpricerも付け替える
    @person_kind = person_kind
    @pricer = pricer
  end

  def pricer
    pricer_klass = Object.const_get "#{@person_kind.to_s.capitalize}Pricer"
    pricer_klass.new(@video)
  end

  def price
    @pricer.price
  end
end

Pricer、AdultPricer、ChildPricerが導入されました。Pricerは抽象クラスで、AdultPricer、ChildPricerはその派生クラスです。person_kindとPricerクラスの派生クラスは、Rental.pricerにあるような方法でクラスを取得できるような名前を意図的に命名しています。

この方法により、新たなクラスを導入するという構造上の複雑さが増加した代わりに、変更に対して閉じていて拡張に対して閉じている、という開放/閉鎖原則に合致するの特性を得ることができました。

ここで得られたメリットは、主に下記の2つです。

  • Rentalの中にあった定数が各Pricerの中に移動した。
    • 本当に使うところに情報が移動したことで、計算方法に関する知識がPricer内で「閉じる」ことになった。
    • 各Pricerには、他の計算方法の知識が入ることはないので、定数名に利用方法を接頭辞として入れる必要がなくなり、名前を短くできた。
  • Rentalは計算方法についての具体的な知識が必要なくなった。
    • priceメソッドを持つインスタンスが@pricerに設定されていれば料金が計算できる。
    • その代わり、Pricerの生成方法、つまり自分にとって適切なPricerが何なのかを知る必要が出てきた。(FactoryメソッドをPricerに生やせば、この知識も外に出せる)

利用側のコードは、先程と全く同じです。

video = Video.new(300)
child_rental = Rental.new(video, :child)
adult_rental = Rental.new(video, :adult)
p child_rental.price # => 150.0
p adult_rental.price # => 200
adult_rental.person_kind = :child
p adult_rental.price # => 150.0

料金体系の追加や計算方法の複雑さに怯える必要はなくなりました。料金体系が追加された場合は、Pricerの派生クラスを作るだけで済み、Rentalクラスには手を加える必要がありません。また、計算方法が複雑化した場合も、変更するのはPricerの派生クラスの中だけになるため、意図しない部分の動作を変更してしまう心配もありません。

ここで気をつけるべきは、現時点でこの複雑さを導入する必要はあるのか?という点です。 結論から言えば、ほとんどの場合メリットはありません。もとのRental#priceメソッドを見ればわかるように、現時点での要件に対しては、case文を愚直に使うだけで十分わかりやすいコードです。これは例なのでお許しを。

Object#extendを使う

リファクタリング: Rubyエディションに示されている、「8.13 タイプコードからモジュールのextendへ」をStrategyパターンらしく利用する方法です。

Video = Struct.new(:price)

module ChildPricer
  DISCOUNT_RATE = 0.5

  def price
    @video.price * DISCOUNT_RATE
  end
end

module AdultPricer
  DISCOUNT_AMOUNT = 100

  def price
    @video.price - DISCOUNT_AMOUNT
  end
end

class Rental
  def initialize(video, person_kind)
    @video = video
    self.person_kind = person_kind
  end

  def person_kind=(person_kind)
    @person_kind = person_kind
    # pricerモジュールに生えているメソッドをインスタンスに生やす
    extend pricer
  end

  def pricer
    Object.const_get "#{@person_kind.to_s.capitalize}Pricer"
  end
end

Object#extendは、インスタンスでから呼び出すと、モジュールのメソッドをそのインスタンスだけのインスタンスメソッドとして生やします。

この方法では、Pricerのためにインスタンスを生成することなくRentalごとに違った料金計算方法を定義できます。extendを使ってメソッドの実装を動的に切り替えるという、非常にRubyっぽい方法に感じます。ここが一番伝えたいところです。Rentalにはpriceメソッドが映えることになりますが、インスタンスごとに違う実装になっているんです!そんなことができる言語はなかなかないので、委譲を利用するStrategyパターンが生まれたわけですね。めっちゃRubyっぽい!

(すごい感動したんですけど、文章で伝えるって難しいですね)

Strategyパターンとの違い、共通点、注意点を下記に示します。

Strategyパターンとの共通点

  • Pricerを追加するだけで料金種別の追加に対応できる
  • 各Pricerの料金計算を別々の塊に分けられるため、他の計算方法の影響を受けない

Strategyパターンとの違い

  • 料金計算をPricerに委譲するのではなく、Rental自身が行う
  • PricerはRentalのもつ構造について知っている必要がある(@videoが存在することなど)
  • Pricerのクラスの生成についての複雑さが取り除かれる

注意点

一度生成したインスタンスのPricerを変更する場合、上書きされなかったメソッドはRentalのメソッドとして残り続ける点に注意してください。

例として、ChildPricerをこのように定義します。

module ChildPricer
  DISCOUNT_RATE = 0.5

  def only_child_method
    'Only child'
  end

  def price
    @video.price * DISCOUNT_RATE
  end
end

料金の変更は反映されるものの、only_child_methodの呼び出しは可能なままです。これは、extendをつかってメソッドを追加しているだけなので、前に追加されたものを削除しているわけではないからです。

video = Video.new(300)
child_rental = Rental.new(video, :child)
puts child_rental.price # => 150
puts child_rental.only_child_method # => Only child
child_rental.person_kind = :adult
puts child_rental.price # => 200
puts child_rental.only_child_method # Only child