導きの始まり
リーダー
「この新しいチームではオブジェクト指向的にコードを書いていきたい」
僕
「...はい! (オブジェクト指向とか意識したことねぇぇ...(´・_・`) )」
オブジェクト指向エクササイズ
こちらにすごくわかりやすい説明が載っていたのでまんま掲載します。
オブジェクト指向エクササイズとは、書籍『ThoughtWorksアンソロジー』で紹介されているオブジェクト指向設計を理解し実際に使えるようになるためのエクササイズです。
オブジェクト指向エクササイズの具体的な方法は、以下に定められた9つのルールを適用してコードを書く、というものになります。
- 1つのメソッドにつきインデントは1段階までにすること
- else句を使用しないこと
- すべてのプリミティブ型と文字列型をラップすること
- 1行につきドットは1つまでにすること
- 名前を省略しないこと
- すべてのエンティティを小さくすること
- 1つのクラスにつきインスタンス変数は2つまでにすること
- ファーストクラスコレクションを使用すること
- Getter、Setter、プロパティを使用しないこと
どっから勉強すれば良いかなと入門書を読んだり、
ハンズオンでできる教材を探していると、ちょうど良いものを発見した。
こちらの記事では、java
で書かれていますが、これをruby
で書き直すことで
OO的なコードの雰囲気をフワッと掴めたらと思います。
使用する初期コード
簡易的な自動販売機をRubyで表現したコードです。
実行すると、「コーラを購入しました。お釣りは400です」
のような購入メッセージが表示されます。
class Drink
COKE = 0
DIET_COKE = 1
TEA = 2
def initialize(kind)
@kind = kind
end
def kind()
@kind
end
end
require './drink'
class VendingMachine
def initialize
@quantity_of_coke = 5 # コーラの在庫数
@quantity_of_diet_coke = 5 # ダイエットコーラの在庫数
@quantity_of_tea = 5 # お茶の在庫数
@number_of_100yen = 10 # 100円玉の在庫
@change = 0 # お釣り
end
# ジュースを購入する.投入金額. 100円と500円のみ受け付ける
# @param kindOfDrink ジュースの種類
# コーラ({@code Juice.COKE}),ダイエットコーラ({@code Juice.DIET_COKE},お茶({@code Juice.TEA})が指定できる
# @return 指定したジュース. 在庫不足や釣り銭不足で買えなかった場合は {@code null} が返される
def buy(i, kind_of_drink)
# 100円と500円だけ受け付ける
if i != 100 && i != 500
@change += i
return nil
end
if kind_of_drink == Drink::COKE && @quantity_of_coke == 0
@change += i
return nil
elsif kind_of_drink == Drink::DIET_COKE && @quantity_of_diet_coke == 0 then
@change += i
return nil
elsif kind_of_drink == Drink::TEA && @quantity_of_tea == 0 then
@change += i
return nil
end
# 釣り銭不足
if i == 500 && @number_of_100yen < 4
@change += i
return nil
end
if i == 100
@number_of_100yen += 1
elsif i == 500 then
# 400円のお釣り
@change += (i - 100)
# 100円玉を釣り銭に使える
@number_of_100yen -= (i - 100) / 100
end
if kind_of_drink == Drink::COKE
@quantity_of_coke -= 1
elsif kind_of_drink == Drink::DIET_COKE then
@quantity_of_diet_coke -= 1
else
@quantity_of_tea -= 1
end
Drink.new(kind_of_drink)
end
# お釣りを取り出す
# @return お釣りの金額
def refund
result = @change
@change = 0
result
end
end
require './drink'
require './vending_machine'
vm = VendingMachine.new
drink = vm.buy(500, Drink::COKE)
change = vm.refund
if drink != nil && drink.kind == Drink::COKE then
print "コーラを購入しました。"
print "お釣りは#{change}です"
else
raise StandardError.new("コーラ買えんかった(´゚д゚`)")
end
実行結果
$ ruby client.rb
=> コーラを購入しました。お釣りは400です%
コードを見て色々と突っ込みたいことは山々だと思いますが、
エクササイズのルールに倣ってOOを取り入れたコード にしたいと思います。
1つのメソッドにつきインデントは1段階までにすること
2階層以上になっているロジックはないのでスルーします。
名前を省略しないこと
VendingMachine
クラスのbuy
メソッドに使用されているi
が難解なので
分かりやすい変数名にします。
投入金額(支払った金額)なのでpayment
に変更します。
すべてのプリミティブ型と文字列型をラップすること
まずプリミティブ型とは言語のコアな機能の一部として組み込まれている型のことで
String型
とかInteger型
みたいなやつです。
ここでいうと以下のような面々
@quantity_of_coke = 5 # コーラの在庫数
@quantity_of_diet_coke = 5 # ダイエットコーラの在庫数
@quantity_of_tea = 5 # お茶の在庫数
@number_of_100yen = 10 # 100円玉の在庫
@change = 0 # お釣り
現状のコードは硬貨をInteger型
で表現していますが、
実際の自動販売機では硬貨を投入します。
そのため、硬貨クラスを作成して、硬貨クラスしか渡せない設計に変更します。
また、以下の要素もプリミティブ型なので、ラップするクラスを用意します。
- 在庫
- 飲み物の種別
- 硬貨
在庫クラス Stock
ドリンクの在庫に関する責務を担います。
外部からはこのクラスに問い合わせることで在庫の内容を調整するようにします。
class Stock
attr_reader :quantity
def initialize(quantity)
@quantity = quantity
end
def decrement
@quantity -= 1
end
end
コインクラス Coin
class Coin
attr_reader :amount
def initialize(amount)
@amount = amount
end
ONE_HUNDRED = Coin.new(100)
FIVE_HUNDRED = Coin.new(500)
end
ドリンク種別クラス DrinkType
1
がCOKE
を示す表現が難解なので、せめて文字列での表現に変更します。
class DrinkType
COKE = 'COKE'
DIET_COKE = 'DIET_COKE'
TEA = 'TEA'
end
上記の結果、自動販売機クラスは以下のようになります。
自動販売機クラス
require './drink'
require './stock'
require './coin'
require './drink_type'
class VendingMachine
def initialize
@stock_of_coke = Stock.new(5) # コーラの在庫数
@stock_of_diet_coke = Stock.new(5) # ダイエットコーラの在庫数
@stock_of_tea = Stock.new(5) # お茶の在庫数
@number_of_100yen = [Coin::ONE_HUNDRED] * 10 # 100円玉の在庫
@change = [] # お釣り
end
# ジュースを購入する.投入金額. 100円と500円のみ受け付ける
# @param kindOfDrink ジュースの種類
# コーラ({@code Juice.COKE}),ダイエットコーラ({@code Juice.DIET_COKE},お茶({@code Juice.TEA})が指定できる
# @return 指定したジュース. 在庫不足や釣り銭不足で買えなかった場合は {@code null} が返される
def buy(payment, kind_of_drink)
# 100円と500円だけ受け付ける
if payment != Coin::ONE_HUNDRED && payment != Coin::FIVE_HUNDRED
@change.push payment
return nil
end
if kind_of_drink == DrinkType::COKE && @stock_of_coke.quantity == 0
@change.push payment
return nil
elsif kind_of_drink == DrinkType::DIET_COKE && @stock_of_diet_coke.quantity == 0 then
@change.push payment
return nil
elsif kind_of_drink == DrinkType::TEA && @stock_of_tea.quantity == 0 then
@change.push payment
return nil
end
# 釣り銭不足
if payment == Coin::FIVE_HUNDRED && @number_of_100yen.length < 4
@change += payment
return nil
end
if payment == Coin::ONE_HUNDRED
@number_of_100yen.push([Coin::ONE_HUNDRED])
elsif payment == Coin::FIVE_HUNDRED then
# 400円のお釣り
@change.concat([Coin::ONE_HUNDRED] * 4)
# 在庫から4枚取る
@number_of_100yen.slice!(0, 4)
end
if kind_of_drink == DrinkType::COKE
@stock_of_coke.decrement
elsif kind_of_drink == DrinkType::DIET_COKE then
@stock_of_diet_coke.decrement
else
@stock_of_tea.decrement
end
Drink.new(kind_of_drink)
end
# お釣りを取り出す
# @return お釣りの金額
def refund
total = @change.sum(&:amount)
@change = []
total
end
end
先ほどまで目立っていたプリミティブ型が無くなった。
Client.rb
呼び出し方も以下のように変更する
require './drink'
require './coin'
require './vending_machine'
vm = VendingMachine.new
drink = vm.buy(Coin::FIVE_HUNDRED, Drink::COKE)
change = vm.refund
if drink != nil && drink.kind == Drink::COKE then
print "コーラを購入しました。"
print "お釣りは#{change}です"
else
raise StandardError.new("コーラ買えんかった(´゚д゚`)")
end
ファーストクラスコレクションを使用すること
VendingMachine
は100円玉の在庫とお釣りを配列で管理していますが、
この配列を管理するクラスを作成します。
配列を扱うだけのクラスを作成するのは、一見冗長に感じますが以下のメリットがあります。
- 既存の配列のメソッド名に左右されず、コードの文脈に合ったメソッド名をそのクラスに定義することができるので、コードの可読性を高める ことができる。
- 配列を扱うロジックを一箇所にまとめる ことができる
100円玉配列クラス StockOf100yen
class StockOf100yen
def initialize(quantity)
@number_of_100yen = [Coin::ONE_HUNDRED] * quantity
end
def add(coin)
@number_of_100yen.push(coin)
end
def size
@number_of_100yen.size
end
def pop
@number_of_100yen.pop
end
end
お釣り配列クラス Change
class Change
def initialize(coins = [])
@coins = coins
end
def add(coin)
@coins.push(coin)
end
def add_all(coins)
@coins.concat(coins)
end
def amount
@coins.sum(&:amount)
end
def clear
@coins = []
end
end
自動販売機クラスの責務は、配列クラスに必要な操作を問い合わせる実装になりました。
自動販売機クラス
require './drink'
require './stock'
require './coin'
require './drink_type'
require './stock_of_100yen'
require './change'
class VendingMachine
def initialize
@stock_of_coke = Stock.new(5) # コーラの在庫数
@stock_of_diet_coke = Stock.new(5) # ダイエットコーラの在庫数
@stock_of_tea = Stock.new(5) # お茶の在庫数
@number_of_100yen = StockOf100yen.new(10) # 100円玉の在庫
@change = Change.new # お釣り
end
# ジュースを購入する.投入金額. 100円と500円のみ受け付ける
# @param kindOfDrink ジュースの種類
# コーラ({@code Juice.COKE}),ダイエットコーラ({@code Juice.DIET_COKE},お茶({@code Juice.TEA})が指定できる
# @return 指定したジュース. 在庫不足や釣り銭不足で買えなかった場合は {@code null} が返される
def buy(payment, kind_of_drink)
# 100円と500円だけ受け付ける
if payment != Coin::ONE_HUNDRED && payment != Coin::FIVE_HUNDRED
@change.add(payment)
return nil
end
if kind_of_drink == DrinkType::COKE && @stock_of_coke.quantity == 0
@change.add(payment)
return nil
elsif kind_of_drink == DrinkType::DIET_COKE && @stock_of_diet_coke.quantity == 0
@change.add(payment)
return nil
elsif kind_of_drink == DrinkType::TEA && @stock_of_tea.quantity == 0
@change.add(payment)
return nil
end
# 釣り銭不足
if payment == Coin::FIVE_HUNDRED && @number_of_100yen.size < 4
@change.add(payment)
return nil
end
if payment == Coin::ONE_HUNDRED
@number_of_100yen.add(payment)
elsif payment == Coin::FIVE_HUNDRED
# 400円のお釣り
# 100円玉在庫から4枚取って、お釣り配列に移す
4.times { @change.add(@number_of_100yen.pop) }
end
if kind_of_drink == DrinkType::COKE
@stock_of_coke.decrement
elsif kind_of_drink == DrinkType::DIET_COKE
@stock_of_diet_coke.decrement
else
@stock_of_tea.decrement
end
Drink.new(kind_of_drink)
end
# お釣りを取り出す
# @return お釣りの金額
def refund
total = @change.amount
@change.clear
total
end
end
配列データとその振るまいがセットで実装されたため、配列内のロジックが変更されても、
複数箇所の実装を変更する必要がなくなりました。
Getter、Setter プロパティを使用しないこと
Getter, Setterを使用すると
「よそのクラスを気にしたり、いじったりするメソッド構造」 に陥りやすくなります。
自動販売機クラスでも「クラス内のデータをクラス外で利用している」ケースがあります。
def buy(payment, kind_of_drink)
# 略
if kind_of_drink == DrinkType::COKE && @stock_of_coke.quantity == 0
@stock_of_coke.quantity == 0
のように
在庫クラスの内部にある在庫数(quantity)を公開しています。
「在庫があるかを確認する」というビジネスロジックが在庫クラスの外に流出することで、
ビジネスロジックの可読性・再利用性などの低下 の原因になります。
在庫数があるかどうかを判定したいだけなので、在庫クラスに empty?
メソッドを用意することで、ビジネスロジックを覆い隠し、在庫数を公開しなくてもいいように変更します。
上記の観点で他も修正していきます。
Stock クラスに「在庫があるかどうか」の判定ロジックを移動
class Stock
def initialize(quantity)
@quantity = quantity
end
def decrement
@quantity -= 1
end
# 以下を追加
# 在庫があるかどうか判定
def empty?
@quantity == 0
end
end
Coin クラスの金額を取得するGetterを排除
- 返却値が
Integer
になっているのも何気に良くない - Getterを削除
→ お金クラス(Money
)を作成して、それを返却する実装へ
# 以下を追加
require './money'
class Coin
# 以下を削除
# attr_reader :amount
# def initialize(amount)
# @amount = amount
# end
# ONE_HUNDRED = Coin.new(100)
# FIVE_HUNDRED = Coin.new(500)
# 以下を追加
ONE_HUNDRED = Money.new(100)
FIVE_HUNDRED = Money.new(500)
end
お金クラス Moneyを作成
class Money
def initialize(amount)
@amount = amount
end
def add(amount)
Money.new(@amount + amount)
end
def to_i
@amount
end
def to_s
@amount.to_s
end
end
Moneyクラス作成により、お釣りクラスを修正
class Change
# 以下を修正
def amount
# @coins.sum(&:amount)
@coins.sum(&:to_i).to_s
end
end
100円硬貨の数量判定、取得ロジックが飛び出している
問題のコード
# 釣り銭不足
if payment == Coin::FIVE_HUNDRED && @number_of_100yen.size < 4
@change.add(payment)
return nil
end
# 略
# 400円のお釣り
# 100円玉在庫から4枚取って、お釣り配列に移す
4.times { @change.add(@number_of_100yen.pop) }
解決方法
100円玉硬貨配列クラスに、
- 100玉硬貨が足りているかどうかの判定メソッドを作る
- 100玉硬貨から4枚取り出し、お釣りを生成するメソッドを作る
以下ファイルを修正
class StockOf100yen
def initialize(quantity)
@number_of_100yen = [Coin::ONE_HUNDRED] * quantity
end
def add(coin)
@number_of_100yen.push(coin)
end
def size
@number_of_100yen.size
end
def pop
@number_of_100yen.pop
end
# 以下を追加
# 100玉硬貨が足りているか判定
def not_have_change?
size < 4
end
# 100玉硬貨の在庫から4枚引き出す
def take_of_change
coins = []
4.times { coins.push(pop) }
coins
end
end
ドリンク種別の判定ロジックが飛び出している
問題のコード
drink = vm.buy(Coin::FIVE_HUNDRED, DrinkType::COKE)
change = vm.refund
# 判定用のビジネスロジックが飛び出している
if drink != nil && drink.kind == DrinkType::COKE then
解決方法
ドリンク(drink)クラスに、ドリンクの種類を判定するメソッドを追加
class Drink
def initialize(kind)
@kind = kind
end
# def kind()
# @kind
# end
# 以下を追加
def coke?
@kind == DrinkType::COKE
end
def diet_coke?
@kind == DrinkType::DIET_COKE
end
def tea?
@kind == DrinkType::TEA
end
end
上記を踏まえて自動販売機クラスは以下のようになる
自動販売機クラス
require './drink'
require './stock'
require './coin'
require './drink_type'
require './stock_of_100yen'
require './change'
class VendingMachine
def initialize
@stock_of_coke = Stock.new(5) # コーラの在庫数
@stock_of_diet_coke = Stock.new(5) # ダイエットコーラの在庫数
@stock_of_tea = Stock.new(5) # お茶の在庫数
@number_of_100yen = StockOf100yen.new(10) # 100円玉の在庫
@change = Change.new # お釣り
end
# ジュースを購入する.投入金額. 100円と500円のみ受け付ける
# @param kindOfDrink ジュースの種類
# コーラ({@code Juice.COKE}),ダイエットコーラ({@code Juice.DIET_COKE},お茶({@code Juice.TEA})が指定できる
# @return 指定したジュース. 在庫不足や釣り銭不足で買えなかった場合は {@code null} が返される
def buy(payment, kind_of_drink)
# 100円と500円だけ受け付ける
if payment != Coin::ONE_HUNDRED && payment != Coin::FIVE_HUNDRED
@change.add(payment)
return nil
end
if kind_of_drink == DrinkType::COKE && @stock_of_coke.empty?
@change.add(payment)
return nil
elsif kind_of_drink == DrinkType::DIET_COKE && @stock_of_diet_coke.empty?
@change.add(payment)
return nil
elsif kind_of_drink == DrinkType::TEA && @stock_of_tea.empty?
@change.add(payment)
return nil
end
# 釣り銭不足
if payment == Coin::FIVE_HUNDRED && @number_of_100yen.not_have_change?
@change.add(payment)
return nil
end
if payment == Coin::ONE_HUNDRED
@number_of_100yen.add(payment)
elsif payment == Coin::FIVE_HUNDRED
# お釣り
@change.add_all(@number_of_100yen.take_of_change)
end
if kind_of_drink == DrinkType::COKE
@stock_of_coke.decrement
elsif kind_of_drink == DrinkType::DIET_COKE
@stock_of_diet_coke.decrement
else
@stock_of_tea.decrement
end
Drink.new(kind_of_drink)
end
# お釣りを取り出す
# @return お釣りの金額
def refund
total = @change.amount
@change.clear
total
end
end
また、呼び出す側も以下の修正が必要になる
require './drink'
require './vending_machine'
require './coin'
require './drink_type'
vm = VendingMachine.new
drink = vm.buy(Coin::FIVE_HUNDRED, DrinkType::COKE)
change = vm.refund
if drink != nil && drink.coke?
print "コーラを購入しました。"
print "お釣りは#{change}です"
else
raise StandardError.new("コーラ買えんかった(´゚д゚`)")
end
1つのクラスにつきインスタンス変数は2つまでにすること
現在、自動販売機には5つのインスタンス変数が存在しており、
クラス内の処理が複雑になっている。
class VendingMachine
def initialize
@stock_of_coke = Stock.new(5) # コーラの在庫数
@stock_of_diet_coke = Stock.new(5) # ダイエットコーラの在庫数
@stock_of_tea = Stock.new(5) # お茶の在庫数
@number_of_100yen = StockOf100yen.new(10) # 100円玉の在庫
@change = Change.new # お釣り
end
インスタンス変数が多くなると、責務の範囲が広がり、凝集度低下の原因につながる。
これを2個になるように整理する。
よく見ると上3つはドリンクの在庫についての責務を持ち、
下2つはお釣りについての責務を持つ。
よって、それぞれをまとめるクラスを作成する。
- 飲み物の在庫管理→Storageクラスを作成
- お釣りの管理→CoinMechクラスを作成
CoinMechって何?という感じもするがそう呼ばれているので仕方がない。
参考
飲み物を収めているところを「商品収納庫」、お金を投入するところを「コインメック」と呼んでいるらしい。
在庫管理クラス Strage
Storage
クラスは DrinkType
をキーにして Stock
をバリューに持たせることで、
ドリンク種別に対する在庫データをまとめて管理します。
要するに、どのドリンクがどれくらいの在庫があるかを管理する。
責務はstockクラスの取りまとめ、中間窓口
在庫が空か確認するempty?
メソッドと
在庫を一つ減らすdecrement
メソッドを用意します。
require './stock'
class Storage
def initialize
@stocks = {}
@stocks[DrinkType::COKE] = Stock.new(5) # コーラの在庫数
@stocks[DrinkType::DIET_COKE] = Stock.new(5) # コーラの在庫数
@stocks[DrinkType::TEA] = Stock.new(5) # コーラの在庫数
end
def decrement(type)
@stocks[type].decrement
end
def empty?(type)
@stocks[type].empty?
end
end
硬貨管理クラス CoinMech
100玉硬貨の枚数の在庫と、お釣りの状態を管理するクラスで
硬貨を投入した時やお釣りを出した時に、それぞれの状態を更新します。
責務は硬貨に関するクラスの取りまとめ、中間窓口
また、StockOf100yen
クラスもCoinMech
クラスに合わせて、
より機能的な名前CashBox
に変更します。
StockOf100yen => CashBox
require './cash_box'
require './change'
class CoinMech
def initialize
@cash_box = CashBox.new(10) # 100円玉の在庫
@change = Change.new # お釣り
end
#
# お釣りに関する処理
#
def add_coin_into_change(coin)
@change.add(coin)
end
def add_change(coins)
@change.add_all(coins)
end
def refund
total = @change.amount
@change.clear
total
end
#
# 100円玉硬貨の在庫に関する処理
#
def add_coin_into_cash_box(coin)
@cash_box.add(coin)
end
def not_have_change?
@cash_box.not_have_change?
end
def take_of_change
@cash_box.take_of_change
end
end
Storage
クラスとCoinMech
クラスの登場で、
自動販売機クラスは以下のように書き直すことができます。
自動販売機クラス
require './drink'
require './coin_mech'
require './storage'
class VendingMachine
def initialize
@coin_mech = CoinMech.new
@storage = Storage.new
end
# ジュースを購入する
def buy(payment, kind_of_drink)
# 100円と500円だけ受け付ける
if payment != Coin::ONE_HUNDRED && payment != Coin::FIVE_HUNDRED
@coin_mech.add_coin_into_change(payment)
return nil
end
# 飲み物の在庫切れ
if @storage.empty?(kind_of_drink)
@coin_mech.add_coin_into_change(payment)
return nil
end
# 釣り銭不足
if payment == Coin::FIVE_HUNDRED && @coin_mech.not_have_change?
@coin_mech.add_coin_into_change(payment)
return nil
end
if payment == Coin::ONE_HUNDRED
@coin_mech.add_coin_into_cash_box(payment)
elsif payment == Coin::FIVE_HUNDRED
# お釣り
@coin_mech.add_change(@coin_mech.take_of_change)
end
@storage.decrement(kind_of_drink)
Drink.new(kind_of_drink)
end
# お釣りを取り出す
def refund
@coin_mech.refund
end
end
インスタンス変数が少なくなったことで、処理がスマートで可読性が向上しました。
else 句を使用しないこと
ここまでの段階で自動販売機クラスはかなりスマートになりましたが、
まだ条件分岐を含んでいます。
if payment == Coin::ONE_HUNDRED
@coin_mech.add_coin_into_cash_box(payment)
elsif payment == Coin::FIVE_HUNDRED
# お釣り
@coin_mech.add_change(@coin_mech.take_of_change)
end
お金の操作に関して、CoinMech
というクラスを切り出したにも拘らず、
自動販売機クラスの中で400円のお釣りを管理するために
CoinMech
からお釣りを取り出して(take_out_change
)、
CoinMech
にお釣りを追加(add_change
)するというややこしいことをしています。
そこで、
自動販売機クラスはCoinMech
クラスに対し、お金(payment
)を渡すだけで
お釣りやコイン在庫の操作はCoinMech
の内部で処理したい。
つまり自動販売機クラスは
-
CoinMech
に投入金額を渡す -
CoinMech
からお釣りを受けとる
というCoinMech
に問い合わせるだけの責務にしたい
そう考えると、以下の「コインの種類を判定ロジック」や「コイン在庫の確認ロジック」も
CoinMech
の責務の中に入れることができそう
# 100円と500円だけ受け付ける
if payment != Coin::ONE_HUNDRED && payment != Coin::FIVE_HUNDRED
@coin_mech.add_coin_into_change(payment)
return nil
end
# 釣り銭不足
if payment == Coin::FIVE_HUNDRED && @coin_mech.not_have_change?
@coin_mech.add_coin_into_change(payment)
return nil
end
しかし、CoinMech
クラスに投入金額を渡すということは
CoinMech
クラスに第3のインスタンス変数、投入金額(payment
)を持たせることになる。
これは 「1つのクラスにつきインスタンス変数は2つまでにすること」 のルールに反する。
そこで、新しく追加される投入金額(payment
)と
既存のお釣り(change
)をまとめる支払クラス Payment を作成する。
CoinMech
クラスの責務はお金の管理
Payment
クラスの責務は 支払い操作
というように責務を分割してみる。
支払いクラス Payment
require './cash_box'
require './change'
class Payment
def initialize(coin)
@coin = coin # 投入金額
@change = nil # お釣り
end
# お釣りが出るか判定
def is_need_change
@coin == Coin::FIVE_HUNDRED
end
# お釣り計算
# 硬貨在庫の変更
def commit(cash_box)
# 100円の場合
if @coin == Coin::ONE_HUNDRED
cash_box.add(@coin)
@change = Change.new
end
# 500円の場合
if @coin == Coin::FIVE_HUNDRED
@change = Change.new(cash_box.take_of_change)
end
@coin = nil
end
# お釣り返却
def refund
# 支払いが失敗するなら、投入金額をそのまま返す
refund = not_commit? ? Change.new(@coin) : @change
refund.amount
end
# 支払いが成功したか判定
def not_commit?
@coin != nil
end
end
すると、CoinMech
クラスは下記のように変更できる
CoinMech クラス
require './cash_box'
require './payment'
class CoinMech
def initialize
@cash_box = CashBox.new(10) # 100円玉の在庫
@payment = nil
end
# 硬貨を投入する
def put(coin)
@payment = Payment.new(coin)
end
# お釣りの計算と在庫の変更を行う
def commit
@payment.commit(@cash_box)
end
# お釣りを返却する
def refund
@payment.refund
end
# お釣りが必要かどうか
def not_have_change?
# 両替が必要かつ100円玉が不足している
@payment.is_need_change && @cash_box.not_have_change?
end
end
上記を踏まえ、自動販売機クラスは下記のように変更できます。
自動販売機クラス
require './drink'
require './coin_mech'
require './storage'
class VendingMachine
def initialize
@coin_mech = CoinMech.new
@storage = Storage.new
end
# ジュースを購入する
def buy(payment, kind_of_drink)
@coin_mech.put(payment)
# 飲み物の在庫切れ
if @storage.empty?(kind_of_drink)
@coin_mech.add_coin_into_change(payment)
return nil
end
@coin_mech.commit
@storage.decrement(kind_of_drink)
Drink.new(kind_of_drink)
end
# お釣りを取り出す
def refund
@coin_mech.refund
end
end
支払いに関する処理がCoinMech
に包み隠されたことで、
スッキリしたコードになった。
また、以下のコードも修正が必要です。
飲み物を取得するロジックもStrage
クラスに移し替えます。
修正後
# 飲み物の在庫切れ
if @storage.empty?(kind_of_drink)
# @coin_mech.add_coin_into_change(payment)
return nil
end
# @storage.decrement(kind_of_drink)
#
# Drink.new(kind_of_drink)
# 以下を新しく追加
@storage.take_out(kind_of_drink)
# def decrement(type)
# @stocks[type].decrement
# end
# 以下を追加
def take_out(type)
@stocks[type].decrement
Drink.new(type)
end
上記を合わせると自動販売機クラスは以下のようになります。
自動販売機クラス
require './drink'
require './coin_mech'
require './storage'
class VendingMachine
def initialize
@coin_mech = CoinMech.new
@storage = Storage.new
end
# ジュースを購入する
def buy(payment, kind_of_drink)
@coin_mech.put(payment)
# 釣り銭不足
if @coin_mech.not_have_change?
return nil
end
# 飲み物の在庫切れ
if @storage.empty?(kind_of_drink)
return nil
end
@coin_mech.commit
# 以下を新しく追加
@storage.take_out(kind_of_drink)
end
# お釣りを取り出す
def refund
@coin_mech.refund
end
end
非常に見通しが良くなりました。
最初の状態と比べると段違いです。
1行につきドットは1つまでにすること
要は、知らない人に話しかけるな ということ
デメテルの法則としても知られています。
オブジェクトを返却するメソッドを呼び出し、
その返却されたオブジェクトのメソッドを呼び出している状態。
例
user.job.type;
# => "teacher"
ドット繋ぎによって他のオブジェクトの内部までアクセスできる状態は
影響範囲が大きく、グローバル変数に似た状態を帯びてくるので悪質です。
現状のコードでは、前述した部類のドット繋ぎになっている箇所がないのでスルーします。
すべてのエンティティを小さくすること
50行を超えるクラス、10ファイルを超えるパッケージは作らない というルールです。
合計12ファイルまで膨れ上がり、見通しが悪くなりました。
そこで、
関連が強いファイルを同じフォルダにまとめます。
- 飲み物に関連するフォルダ → drink
- お金に関するフォルダ → money
- 在庫に関するフォルダ → stock
drink
drink.rb
drink_type.rb
money
cash_box.rb
change.rb
coin.rb
coin_mech.rb
money.rb
payment.rb
stock
stock.rb
storage.rb
vending_machine.rb
client.rb
あとは各ファイルで、require
による読み込みのパスを修正すると完成です。
クラス図
矢印は、オブジェクトがどのクラスからどのクラスへ受け渡されているか示します。
感想
-
自動販売機 クラスの見通しが良くなっていくのが楽しい(^^)
ビジネスロジックが隠蔽され、値の受け渡しのみになったことで可動性が向上した! -
「else句を使わない」あたりで脳みそがつりそうになった。
プリミティブ型しか知らない人がいきなりオブジェクト指向エクササイズは負荷が強い -
「
getter
使わない」縛りは、たまには許容しても良いかと思った。
金額クラスを参照して計算する時、どうしても数値型が欲しかったけど使えないのでto_i
を用意した。これなら、getter
を許容しても良いんじゃないかと思った。「原則getterを乱用しない」くらいの気持ちで書くくらいでいいと思う。 -
Payment
クラスを作る発想はなかなかできないなと思った。
初見では、CoinMech
クラスに処理を全部書いていた。
インスタンス変数の制限がなければ、分割という発想に辿り着かない と思う。
気がつくとクラス内の責務が増えすぎるので、その対策としてクラスあたりのインスタンス変数の上限数を決めておくのもアリだと思った。。
参考資料
オブジェクト指向を学ぶためのオブジェクト指向エクササイズ
オブジェクト指向エクササイズをやってみる
ファーストクラスコレクション
【入門】結局getter/setterは悪なのか
デメテルの法則とは?深堀してみた
良いコード/悪いコードで学ぶ設計入門
ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本