give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

TDDBC大阪2.0の自動販売機問題はなかなかの良問だった

はじめに

僕とAkiさん(@spring_aki)で毎月主催している西脇.rb & 東灘.rbの合同もくもく会で、前回(第3回)、参加者の寺田さん(@aq2bq)がTDD Boot Camp 大阪 2.0の自動販売機問題を自習のテーマにしていました。


その問題を読んでみると、「簡単そう&面白そう」に見えたので、僕もちょっとチャレンジしてみることにしました。


あ、ちなみに今回のエントリはコードが多めなので、スマホだと見づらいかもしれません。悪しからず。


プログラムの仕様

仕様を引用するとこんな感じです。

ステップ0 お金の投入と払い戻し
  • 10円玉、50円玉、100円玉、500円玉、1000円札を1つずつ投入できる。
  • 投入は複数回できる。
  • 投入金額の総計を取得できる。
  • 払い戻し操作を行うと、投入金額の総計を釣り銭として出力する。
ステップ1 扱えないお金
  • 想定外のもの(硬貨:1円玉、5円玉。お札:千円札以外のお札)が投入された場合は、投入金額に加算せず、それをそのまま釣り銭としてユーザに出力する。
ステップ2 ジュースの管理
  • 値段と名前の属性からなるジュースを1種類格納できる。初期状態で、コーラ(値段:120円、名前”コーラ”)を5本格納している。
  • 格納されているジュースの情報(値段と名前と在庫)を取得できる。
ステップ3 購入
  • 投入金額、在庫の点で、コーラが購入できるかどうかを取得できる。
  • ジュース値段以上の投入金額が投入されている条件下で購入操作を行うと、ジュースの在庫を減らし、売り上げ金額を増やす。
  • 投入金額が足りない場合もしくは在庫がない場合、購入操作を行っても何もしない。
  • 現在の売上金額を取得できる。
  • 払い戻し操作では現在の投入金額からジュース購入金額を引いた釣り銭を出力する。
ステップ4 機能拡張
  • ジュースを3種類管理できるようにする。
    • 在庫にレッドブル(値段:200円、名前”レッドブル”)5本を追加する。
    • 在庫に水(値段:100円、名前”水”)5本を追加する。
  • 投入金額、在庫の点で購入可能なドリンクのリストを取得できる。
ステップ5 釣り銭と売り上げ管理
  • ジュース値段以上の投入金額が投入されている条件下で購入操作を行うと、釣り銭(投入金額とジュース値段の差分)を出力する。
    • ジュースと投入金額が同じ場合、つまり、釣り銭0円の場合も、釣り銭0円と出力する。
    • 釣り銭の硬貨の種類は考慮しなくてよい。
TDD Boot Camp(TDDBC) - TDDBC大阪2.0/課題

 
この課題はさらにステップ6以降もあるのですが、僕自身ステップ5ぐらいでお腹いっぱいになったので、ここには書きません。


また、特に引数や戻り値の指定はないので、自分でAPIを設計する必要があります。
なので、実装は人によって変わってくるところがなかなか興味深いです。


ただし、各用語の英訳例は別ページに載っているので、今回はそれを参考にしてメソッドを命名しました。
英訳を統一しておけば、第三者がコードを読んだときに理解がスムーズに進むと思うので、これぐらいはやっておいた方がいいかなと思います。


TDD Boot Camp(TDDBC) - TDDBC仙台02/課題用語集


僕の実装例

というわけで、僕も実装してみました。
言語はRuby、ユニットテストツールはRSpecです。
 

class Drink
  attr_reader :name, :price

  def self.cola
    self.new 120, :cola
  end

  def self.redbull
    self.new 200, :redbull
  end

  def self.water
    self.new 100, :water
  end

  def initialize price, name
    @name = name
    @price = price
  end

  def ==(another)
    self.name == another.name
  end

  def eql?(another)
    self == another
  end

  def hash
    name.hash
  end

  def to_s
    "<Drink: name=#{name}, price=#{price}>"
  end
end

class VendingMachine
  AVAILABLE_MONEY = [10, 50, 100, 500, 1000].freeze

  attr_reader :total, :sale_amount

  def initialize
    @total = 0
    @sale_amount = 0
    @drink_table = {}
    5.times { store Drink.cola }
  end

  def insert(money)
    AVAILABLE_MONEY.include?(money) ? 
      nil.tap { @total += money } : money
  end

  def refund
    total.tap { @total = 0 }
  end

  def store(drink)
    nil.tap do
      @drink_table[drink.name] =
        { price: drink.price,
          drinks: [] } unless @drink_table.has_key? drink.name
      @drink_table[drink.name][:drinks] << drink
    end
  end

  def purchase(drink_name)
    if purchasable? drink_name
      drink = @drink_table[drink_name][:drinks].pop
      @sale_amount += drink.price
      @total -= drink.price
      [drink, refund]
    end
  end

  def purchasable?(drink_name)
    purchasable_drink_names.include? drink_name
  end

  def purchasable_drink_names
    @drink_table.select{|_, info|
      info[:price] <= total && info[:drinks].any? }.keys
  end

  def stock_info
    Hash[@drink_table.map {|name, info|
      [name, { price: info[:price], stock: info[:drinks].size }]
    }]
  end
end

 

(僕の考えた)API仕様

自動販売機を使う場合はVendingMachineをnewします。

machine = VendingMachine.new

お金を入れる場合はinsertメソッドを使います。

machine.insert 10
machine.insert 50

投入金額を確認する場合はtotalメソッドを使います。

machine.total # => 60

insertメソッドの戻り値は通常nilですが、使えないお金をinsertした場合は、そのお金が戻り値になります。

machine.insert 5 # => 5

購入を中止する場合はrefundメソッドを使います。
投入金額が戻り値になって返ってきます。

machine.refund # => 60

飲み物を追加する場合はstoreメソッドを使います。引数はDrinkです。
Drinkは直接newするのではなく、Drink.cola, Drink.redbull, Drink.waterのようにファクトリメソッドを使って生成します。

machine.store Drink.redbull
machine.store Drink.water

在庫を確認する場合はstock_infoメソッドを使います。在庫と値段の情報がハッシュで返ります。
なお、自動販売機には5本のコーラが最初から入っています。

machine.stock_info # => {:cola=>{:price=>120, :stock=>5},
                   #     :redbull=>{:price=>200, :stock=>1},
                   #     :water=>{:price=>100, :stock=>1}}

現在の投入金額で購入できる飲み物の一覧はpurchasable_drink_namesで取得します。
戻り値はシンボル(飲み物の名前)の配列です。
ただし、売り切れになった場合は投入金額が十分でも名前は返ってきません。

machine.insert 100
machine.insert 50
machine.purchasable_drink_names # => [:cola, :water]

purchasable?メソッドで飲み物別に確認することもできます。

machine.purchasable? :cola # => true
machine.purchasable? :redbull # => false

飲み物を購入する場合はpurchaseメソッドを使います。引数はシンボル(飲み物の名前)です。
戻り値はDrinkと釣り銭が配列になって返ってきます。

machine.purchase :cola # => [Drink.cola, 30]

現時点の売上額を確認するときはsale_amountメソッドを使います。

machine.sale_amount # => 120

 

テストコード

TDD Boot Campの課題、ということで、ちゃんとTDD(テスト駆動開発)で実装しました。
が、テストコードの行数はかなり長いので、ここには書けません。
興味のある方は、GitHubのテストコードを覗いてみてください。

vending_machine_spec.rb - GitHub


やってみた感想

意外と時間がかかった

課題を読んだ時は「ふーん、簡単そうだ。自分ならすぐできるだろう」というのが第一印象だったのですが、ステップ3、4が思った以上にややこしく、結構時間を取られました。


ステップ0〜5を一通り実装するのにかかった時間がだいたい3時間ぐらいです。
その後もいろいろとリファクタリングやAPI設計の見直しを繰り返したので、トータルではその倍ぐらいかかったかもしれません。


APIの設計がちょっと大変だった

この仕様書ではAPIが特に決められていないので、どういうAPI設計が最適なんだろう??と頭を悩ませることもしばしばでした。
上で説明したAPIは紆余曲折した結果の最終形態で、実装が終わった直後のAPIは今のAPIと大きく異なるものでした。


仕様の文章はかなり細かい部分まで説明してくれていますが、ところどころで「これはどういう結果を期待しているのだろう?」と疑問に思う仕様がありました。
よく分からないところは自分の想像でAPIを設計して実装したので、もしかすると出題者の意図とは違うAPIを実装しているかもしれません。
もちろん、出題者がすぐそばにいるなら、逐次仕様を確認した方が良いのは言うまでもありません。


自動販売機問題はなかなかの良問だった

とはいえ、この自動販売機問題はオブジェクト指向設計やTDDを勉強するにはなかなか良い課題だと感じました。
オブジェクト指向やTDDに不慣れな人は熟練者と一緒にペアプロしたりすると、かなりよいエクササイズになると思います。


自分としてもオブジェクト指向設計やTDDは十分マスターしているつもりでしたが、一見簡単そうに見える問題でも実際にやってみると、「良いAPI設計、良いテストコードとは何だ?」という探究心が止まらなくなり、最近あまり使っていなかった脳の領域をフル回転させていたように思います。


というわけで、この自動販売機問題はなかなかの良問でした。
初心者の方も上級者の方もぜひ一度トライしてみてはいかがでしょうか?


【お知らせ】6月8日(土)に第4回 もくもく会を開催します!

さてここでお知らせです。
西脇.rb & 東灘.rbでは6月8日(土)に第4回の合同もくもく会を開催します。


今回はコードレビューの時間をたっぷり取るために、懇親会を兼ねてビアバッシュ形式でコードレビューを実施します。
ビアバッシュ形式なので、お酒を飲んだり、ピザを食べたりしながら、リラックスした雰囲気の中でコードレビューを楽しむこともできると思います。


「もくもく」の時間で作るRubyのコードはあなたの自由です。
今回紹介した自動販売機問題をこの時間で解いてみる、というのも良い学習テーマだと思います。


まだ参加枠は残っていますので、興味のある方はお気軽にご参加ください!


西脇.rb & 東灘.rb 合同もくもく会 4th 告知ページ
f:id:JunichiIto:20130522073006p:plain:w500


あわせて読みたい

今回紹介した自動販売機問題のコードはGitHubページに置いています。
全体像を見てみたい方はこちらをどうぞ。
GitHub - JunichiIto/vending_machine



第3回もくもく会の開催レポートです。
こんな感じで勉強会をやってます。
コードレビューこそ、もくもく会を成功させる極意!? 〜第3回 西脇.rb & 東灘.rb 合同もくもく会 開催レポート〜 #nshgrb - give IT a try