7
2

More than 1 year has passed since last update.

オブジェクト指向エクササイズをRubyで書いてみる

Last updated at Posted at 2023-07-20

導きの始まり

リーダー
「この新しいチームではオブジェクト指向的にコードを書いていきたい」


「...はい! (オブジェクト指向とか意識したことねぇぇ...(´・_・`) )」

オブジェクト指向エクササイズ

こちらにすごくわかりやすい説明が載っていたのでまんま掲載します。

オブジェクト指向エクササイズとは、書籍『ThoughtWorksアンソロジー』で紹介されているオブジェクト指向設計を理解し実際に使えるようになるためのエクササイズです。

オブジェクト指向エクササイズの具体的な方法は、以下に定められた9つのルールを適用してコードを書く、というものになります。

  • 1つのメソッドにつきインデントは1段階までにすること
  • else句を使用しないこと
  • すべてのプリミティブ型と文字列型をラップすること
  • 1行につきドットは1つまでにすること
  • 名前を省略しないこと
  • すべてのエンティティを小さくすること
  • 1つのクラスにつきインスタンス変数は2つまでにすること
  • ファーストクラスコレクションを使用すること
  • Getter、Setter、プロパティを使用しないこと

どっから勉強すれば良いかなと入門書を読んだり、
ハンズオンでできる教材を探していると、ちょうど良いものを発見した。

オブジェクト指向エクササイズをやってみる

こちらの記事では、java で書かれていますが、これをruby で書き直すことで
OO的なコードの雰囲気をフワッと掴めたらと思います。

使用する初期コード

簡易的な自動販売機をRubyで表現したコードです。
実行すると、「コーラを購入しました。お釣りは400です」
のような購入メッセージが表示されます。

drink.rb
class Drink
  COKE = 0
  DIET_COKE = 1
  TEA = 2

  def initialize(kind)
    @kind = kind
  end

  def kind()
    @kind
  end
end
vending_machine.rb
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
client.rb
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型みたいなやつです。

ここでいうと以下のような面々

vending_machine.rb
    @quantity_of_coke = 5 # コーラの在庫数
    @quantity_of_diet_coke = 5 # ダイエットコーラの在庫数
    @quantity_of_tea = 5 # お茶の在庫数
    @number_of_100yen = 10 # 100円玉の在庫
    @change = 0 # お釣り

現状のコードは硬貨をInteger型で表現していますが、
実際の自動販売機では硬貨を投入します。
そのため、硬貨クラスを作成して、硬貨クラスしか渡せない設計に変更します。

また、以下の要素もプリミティブ型なので、ラップするクラスを用意します。

  • 在庫
  • 飲み物の種別
  • 硬貨

在庫クラス Stock

ドリンクの在庫に関する責務を担います。
外部からはこのクラスに問い合わせることで在庫の内容を調整するようにします。

stock.rb
class Stock

  attr_reader :quantity

  def initialize(quantity)
    @quantity = quantity
  end

  def decrement
    @quantity -= 1
  end
end

コインクラス Coin

coin.rb
class Coin
  attr_reader :amount

  def initialize(amount)
    @amount = amount
  end

  ONE_HUNDRED = Coin.new(100)
  FIVE_HUNDRED = Coin.new(500)
end

ドリンク種別クラス DrinkType

1COKEを示す表現が難解なので、せめて文字列での表現に変更します。

drink_type.rb
class DrinkType
  COKE = 'COKE'
  DIET_COKE = 'DIET_COKE'
  TEA = 'TEA'
end

上記の結果、自動販売機クラスは以下のようになります。

自動販売機クラス
vending_machine.rb
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

呼び出し方も以下のように変更する

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

stock_of_100yen.rb
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

change.rb
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

自動販売機クラスの責務は、配列クラスに必要な操作を問い合わせる実装になりました。

自動販売機クラス
vending_machine.rb
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 クラスに「在庫があるかどうか」の判定ロジックを移動

stock.rb
class Stock

  def initialize(quantity)
    @quantity = quantity
  end

  def decrement
    @quantity -= 1
  end

  # 以下を追加
  # 在庫があるかどうか判定
  def empty?
    @quantity == 0
  end
end

Coin クラスの金額を取得するGetterを排除

  • 返却値がIntegerになっているのも何気に良くない
  • Getterを削除
    → お金クラス(Money)を作成して、それを返却する実装へ
coin.rb
# 以下を追加
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を作成

money.rb
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クラス作成により、お釣りクラスを修正

change.rb
class Change
  # 以下を修正
  def amount
    # @coins.sum(&:amount)
    @coins.sum(&:to_i).to_s
  end
end

100円硬貨の数量判定、取得ロジックが飛び出している

問題のコード

vending_machine.rb
    # 釣り銭不足
    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枚取り出し、お釣りを生成するメソッドを作る

以下ファイルを修正

stock_of_100yen.rb
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

ドリンク種別の判定ロジックが飛び出している

問題のコード

client.rb
drink = vm.buy(Coin::FIVE_HUNDRED, DrinkType::COKE)
change = vm.refund

# 判定用のビジネスロジックが飛び出している
if drink != nil && drink.kind == DrinkType::COKE then

解決方法
ドリンク(drink)クラスに、ドリンクの種類を判定するメソッドを追加

drink.rb
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

上記を踏まえて自動販売機クラスは以下のようになる

自動販売機クラス
vending_machine.rb
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

また、呼び出す側も以下の修正が必要になる

client.rb
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つのインスタンス変数が存在しており、
クラス内の処理が複雑になっている。

vending_machine.rb
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メソッドを用意します。

storage.rb
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
coin_mech.rb
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クラスの登場で、
自動販売機クラスは以下のように書き直すことができます。

自動販売機クラス
vending_machine.rb
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 句を使用しないこと

ここまでの段階で自動販売機クラスはかなりスマートになりましたが、
まだ条件分岐を含んでいます。

vending_machine.rb
    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の責務の中に入れることができそう

vending_machine.rb
    # 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

payment.rb
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 クラス

coin_mech.rb
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

上記を踏まえ、自動販売機クラスは下記のように変更できます。

自動販売機クラス
vending_machine.rb
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 クラスに移し替えます。
修正後

vending_machine.rb
    # 飲み物の在庫切れ
    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)
storage.rb
  # def decrement(type)
  #   @stocks[type].decrement
  # end

  #  以下を追加
  def take_out(type)
    @stocks[type].decrement
    Drink.new(type)
  end

上記を合わせると自動販売機クラスは以下のようになります。

自動販売機クラス
vending_machine.rb
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による読み込みのパスを修正すると完成です。

クラス図

矢印は、オブジェクトがどのクラスからどのクラスへ受け渡されているか示します。
image.png

感想

  • 自動販売機 クラスの見通しが良くなっていくのが楽しい(^^)
    ビジネスロジックが隠蔽され、値の受け渡しのみになったことで可動性が向上した!

  • 「else句を使わない」あたりで脳みそがつりそうになった。
    プリミティブ型しか知らない人がいきなりオブジェクト指向エクササイズは負荷が強い

  • getter 使わない」縛りは、たまには許容しても良いかと思った。
    金額クラスを参照して計算する時、どうしても数値型が欲しかったけど使えないのでto_iを用意した。これなら、getterを許容しても良いんじゃないかと思った。「原則getterを乱用しない」くらいの気持ちで書くくらいでいいと思う。

  • Paymentクラスを作る発想はなかなかできないなと思った。 
    初見では、CoinMechクラスに処理を全部書いていた。
    インスタンス変数の制限がなければ、分割という発想に辿り着かない と思う。
    気がつくとクラス内の責務が増えすぎるので、その対策としてクラスあたりのインスタンス変数の上限数を決めておくのもアリだと思った。。

参考資料

オブジェクト指向を学ぶためのオブジェクト指向エクササイズ
オブジェクト指向エクササイズをやってみる
ファーストクラスコレクション
【入門】結局getter/setterは悪なのか
デメテルの法則とは?深堀してみた
良いコード/悪いコードで学ぶ設計入門
ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2