CodeKataやってみた記事、第九弾です。
今回は、Kata09: Back to the Checkout
をやっていきます。
今回は、実際にコードを書く問題です。
以前書いた、CodeKata第一回の問題を元にしているため、Back to the Checkoutという題名のようです。
概要
Checkoutとは、ここではスーパーのレジを指すようです。
ざっくり言うと、レジを作りましょう、という問題です。
スーパーで買い物する際、最も単純な価格決定方法は、単価 * 個数です。
この問題では、さらにまとめ買いが存在します。
例えば、50セントの商品をまとめて3つ購入した場合、130セント(1ドル30セント)になる、といった具合です。
こういう、特殊なルールにも対応できるコードを作成します。
価格表
Item Unit Special Price Price -------------------------- A 50 3 for 130 B 30 2 for 45 C 20 D 15
単価50セントのAを3個購入したら、本来150セントのところを130セントで買えます。 Bも似たような価格設定ですが、CとDは単純に単価 * 個数です。
実装指針
co = CheckOut.new(pricing_rules)
co.scan(item)
co.scan(item)
: :
price = co.total
サイトに載っているRubyの例です。
- CheckOut(レジ)に金額ルールリストを渡す
- 個々の商品を一つずつ渡す(順不同。A→B→A→C→Aのような順番でも、Aを3つ購入になるので、割引が適用される)
- 最後に、レジから合計金額を取得
のように実装します。
肝は、scanに渡される商品に応じて、内部計算を切り替えるところです。
目的
チェックアウト(レジ)が、特定の商品や価格戦略(strategies)を知らない方法で、これを実装できますか?
とあります。いわゆる抽象化のことです。
また、価格戦略を簡単に追加できるように考慮して、作成します。
いつものように、続きは一応隠します。(直接見ると隠れません)
自分なりの答え
ドメイン駆動等を考えると、型定義はしっかりすべきですが、大変なので金額はint
、商品識別はstr
で行っています。
価格戦略
価格戦略(価格決定ルール)は、個数を渡すことで、個数に応じた金額を返します。
from abc import ABCMeta, abstractmethod class PriceRule(metaclass=ABCMeta): """価格決定ルール""" @abstractmethod def calculate(self, count: int) -> int: pass
そのまんまの継承元(インターフェイス)を作成します。
シンプルなもの
from rules.pricerule import PriceRule class SimpleRule(PriceRule): """単価 * 個数ルール""" def __init__(self, price: int): self._price = price def calculate(self, count: int) -> int: return count * self._price
コンストラクタで単価指定し、immutableなクラスにします。
複雑なもの
from rules.pricerule import PriceRule class MultipleDiscountRule(PriceRule): """一定個数購入時割引ルール""" def __init__(self, price: int, discount_count: int, discount_price: int): self._price = price self._count = discount_count self._discount_price = discount_price def calculate(self, count: int) -> int: simple = count % self._count * self._price discount = int(count / self._count) * self._discount_price return simple + discount
コンストラクタで、本来の単価、割引を受ける個数、割引時の価格を受け取ります。 それを元に計算します。
ルール群
from rules.pricerule import PriceRule class PriceRules(object): """価格決定ルール群""" def __init__(self): self._rules = {} def register(self, code: str, rule: PriceRule): """商品を登録します。""" self._rules[code] = rule def calculate(self, code: str, count: int) -> int: """個数に応じて、指定コードの商品金額を計算します。""" rule: PriceRule = self._rules.get(code) if rule == None: # 本来、例外で返すべきだが、省略 return 0 return rule.calculate(count)
価格ルールは、商品コード(この問題では、アルファベット一文字)ごとに管理します。
そして、当クラスに計算機能も持たせています。
ルールを返すだけ、としてもよいのですが、その場合はCheckOut
で計算するだけです。
CheckOut実装
from rules.pricerules import PriceRules class CheckOut: def __init__(self, pricing_rules: PriceRules): self._rules = pricing_rules self._counts = {} def scan(self, code: str): self._counts.setdefault(code, 0) self._counts[code] += 1 @property def total(self) -> int: """合計金額を返します。""" prices = 0 for code, count in self._counts.items(): prices += self._rules.calculate(code, count) return prices
クラスが持つ役割は、
- 価格表は外部から受け取る
- 商品購入数量管理は自分で行う
- 金額計算は、受け取った価格表のみを用いる
というところです。
total
が呼ばれると、その時点の購入状態に応じて計算が行われます。
ルール群の作成
from rules.pricerules import PriceRules from rules.rule import SimpleRule, MultipleDiscountRule def get_test_price() -> PriceRules: prices = PriceRules() prices.register('A', MultipleDiscountRule(50, 3, 130)) prices.register('B', MultipleDiscountRule(30, 2, 45)) prices.register('C', SimpleRule(20)) prices.register('D', SimpleRule(15)) return prices
ルール群に、各ルールを追加するだけです。
ここは、DB値を元に登録する等、様々な方法が考えられます。日々変わるものなら、登録手段は凝る必要があります。
方式の利点
こういう実装にする利点を、ケースごとに挙げます。
ルールの追加は容易
ルールが持つべき要件はPriceRule
に準拠すること、すなわち、商品個数を受け取ったら価格を返すことのみです。
実装は簡単です。
もっとも、ルール群に渡す必要はあります。
- レジは、受け取ったものにしか依存しない
レジが自分で管理しているのは、商品の個数のみであり、価格は管理していません。
よって、価格変更等が起こっても、レジは影響を受けません。
おわりに
今回は、例題を解く感じでした。
この手の問題は、わりとよくあると思うので、いい練習になります。
なんとなく実装は浮かぶけど、すぐに実装するのは難しいように思います。
練習問題としてはいいなぁ、と思いました。