extendを使ったStrategyパターン風のアルゴリズム切り替え
- 作者:ジェイ・フィールズ,シェーン・ハービー,マーティン・ファウラー
- 発売日: 2020/03/21
- メディア: 単行本
やりたいこと
レンタルビデオの料金計算を行うプログラムを考えます。料金の計算は、下記のルールで行います。
- レンタル料は、ビデオの基本レンタル料に利用者種別ごとの割引を適用して計算する。
- ビデオごとに値段が設定してあり、これを基本レンタル料と呼ぶ。
- 利用者種別には、子供と大人の二種類がある。
- 大人: 基本レンタル料から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