今回もCodeKataをKotlinでやっていきたいと思います。
「そもそもCodeKataって何?」と言う方は"CodeKataをKotlinでやってみた 〜Karate Chop編〜"をご参照ください。
トライしてみる
今回の課題は21あるKataのうち9つ目に当たる"Back to the Checkout"です。
This week, let’s implement the code for a supermarket checkout that calculates the total price of a number of items. In a normal supermarket, things are identified using Stock Keeping Units, or SKUs. In our store, we’ll use individual letters of the alphabet (A, B, C, and so on). Our goods are priced individually. In addition, some items are multipriced: buy n of them, and they’ll cost you y cents. For example, item ‘A’ might cost 50 cents individually, but this week we have a special offer: buy three ‘A’s and they’ll cost you $1.30. In fact this week’s prices are:
Item Unit Special
Price Price
--------------------------
A 50 3 for 130
B 30 2 for 45
C 20
D 15
つまり、今回の課題はA=50セント,B=30セント,C=20セント,D=15セントのそれぞれの商品について適切に価格を計算するプログラムを実装することになります。但し、Aについては3つ買うと130セント、Bについては2つ買うと45セントの特別価格で計上する必要があります。
今回はテストケースを先に示してくださっていて、下記のテストが通過するように実装を行う必要があります(Rubyで書かれているため、自分が使う際にはKotlinに書き換えて実行します)。但し、CheckOut
を作成する際に渡す"RULE"については具体的な形式が言及されていないため、どのように上記で設定されているルールを引き渡すのかもキモだそうです。
class TestPrice < Test::Unit::TestCase
def price(goods)
co = CheckOut.new(RULES)
goods.split("//").each { |item| co.scan(item) }
co.total
end
def test_totals
assert_equal( 0, price(""))
assert_equal( 50, price("A"))
assert_equal( 80, price("AB"))
assert_equal(115, price("CDBA"))
assert_equal(100, price("AA"))
assert_equal(130, price("AAA"))
assert_equal(180, price("AAAA"))
assert_equal(230, price("AAAAA"))
assert_equal(260, price("AAAAAA"))
assert_equal(160, price("AAAB"))
assert_equal(175, price("AAABB"))
assert_equal(190, price("AAABBD"))
assert_equal(190, price("DABABA"))
end
def test_incremental
co = CheckOut.new(RULES)
assert_equal( 0, co.total)
co.scan("A"); assert_equal( 50, co.total)
co.scan("B"); assert_equal( 80, co.total)
co.scan("A"); assert_equal(130, co.total)
co.scan("A"); assert_equal(160, co.total)
co.scan("B"); assert_equal(175, co.total)
end
end
実装
class CheckOut(private val unitRule: MutableMap<String, Int>, private val specialRule: List<Triple<String, Int, Int>> = listOf()) {
// map which stores items scanned with "scan" method
private val totalItemMap = mutableMapOf<String, Int>()
// total price calculated by items stored at "totalItemMap"
var total = 0
/**
* return price according to items passed
*/
fun price(items: String): Int {
// make blank map to record items count
val itemMap = recordItemCount(items, mutableMapOf())
// return calculated total price
return calculatePrice(itemMap)
}
/**
* scan items and store its data to "totalItemMap" and price to "total"
*/
fun scan(items: String) {
// make copy not to affect totalItemMap by process in "calculatePrice"
val totalItemMapCopy = HashMap(recordItemCount(items, totalItemMap))
// assign calculated total price to "total"
total = calculatePrice(totalItemMapCopy)
}
/**
* count items and store its counting data to passed "itemMap"
*/
private fun recordItemCount(items: String, itemMap: MutableMap<String, Int> = totalItemMap): MutableMap<String, Int> {
items.chunked(1).forEach {
try {
val count = itemMap.getByKey(it)
itemMap[it] = count.plus(1)
} catch (e: IllegalArgumentException) {
itemMap[it] = 1
}
}
return itemMap
}
/**
* calculate price by referring "unitRule" and "specialRule"
*/
private fun calculatePrice(itemMap: MutableMap<String, Int>): Int {
var acc = 0
// calculate special price first to reflect special price appropriately
acc += calculateSpecialPrice(itemMap)
acc += calculateUnitPrice(itemMap)
return acc
}
/**
* calculate special prices by referring "specialRule"
*/
private fun calculateSpecialPrice(itemMap: MutableMap<String, Int>): Int {
var acc = 0
specialRule.forEach {
val item = it.first
val unit = it.second
val price = it.third
if (itemMap.containsKey(item)) {
// while item count is greater than special price unit...
while (itemMap.getByKey(item) >= unit) {
acc += price
val count = itemMap.getByKey(item)
// minus count of item which are referred to calculate special price
itemMap[item] = count.minus(unit)
}
}
}
return acc
}
/**
* calculate special prices by referring "unitPrice"
*/
private fun calculateUnitPrice(itemMap: MutableMap<String, Int>): Int {
var acc = 0
itemMap.forEach {
val price = unitRule.getByKey(it.key)
// while item count is greater than 0...
while (it.value > 0) {
acc += price
val count = itemMap.getByKey(it.key)
itemMap[it.key] = count.minus(1)
}
}
return acc
}
/**
* get value by key assuring return type is "Int", not "Int?"
*/
private fun MutableMap<String, Int>.getByKey(key: String): Int {
return this[key] ?: throw IllegalArgumentException("invalid item passed")
}
}
価格ルール情報の設定
価格ルール情報をCheckOut
に引き渡すため、ユニット価格をMap・スペシャル価格をTripleのListで引き渡すようにしてみました。具体的には下記のようになります。
val co = CheckOut(mutableMapOf("A" to 50, "B" to 30, "C" to 20, "D" to 15),
listOf(Triple("A", 3, 130), Triple("B", 2, 45)))
第一引数のユニット価格の方は自明かと思いますが、第二引数のスペシャル価格の方は補足させてください。一つのスペシャル価格を一つのTripleで対応させており、例えばTriple("A", 3, 130)
は"Aを3つ買うと130セントのスペシャル価格で購入できる"ということを表しています。このようにして作成されたCheckOut
のインスタンスはprice
やscan
メソッドを呼び出すことで商品の価格を計算していくことができます。
price
メソッドとscan
メソッド
price
とscan
はrecordItemCount
でアイテム数を記録し、その記録に基づいてcalculatePrice
で価格を計算するメソッドとなります。
アイテムの記録はMap形式で行うようにしています。例えば"Aを2つ、Bを1つ記録する"としてrecordItemCount("AAB", mutableMapOf())
を呼び出した場合には記録内容は{A=2, B=1}
となります。
両メソッドの差異としてはprice
は計算した価格をそのまま返却する一方、scan
は計算した価格を"total"プロパティに代入します。下記の各テストケースをご覧いただくと分かりやすいかと思います。
val co = CheckOut(
mutableMapOf("A" to 50, "B" to 30, "C" to 20, "D" to 15),
listOf(Triple("A", 3, 130), Triple("B", 2, 45)))
assertEquals(80, co.price("AB"))
co.scan("A")
assertEquals(50, co.total)
co.scan("B")
assertEquals(80, co.total)
calculatePrice
メソッド
それではprice
とscan
の内部で呼び出されるcalculatePrice
メソッドの実装についてみていきます。下記がその実装になります。
private fun calculatePrice(itemMap: MutableMap<String, Int>): Int {
var acc = 0
// calculate special price first to reflect special price appropriately
acc += calculateSpecialPrice(itemMap)
acc += calculateUnitPrice(itemMap)
return acc
}
calculateSpecialPrice
でスペシャル価格について計算し、その後にcalculateUnitPrice
でユニット価格を計算しています。その両価格をacc
に足し合わせることで全ての商品の合計価格として返却します。
calculateSpecialPrice
でitemMap
を参照してスペシャル価格の対象となる商品・個数の組み合わせがあるかを確認します。もし該当がある場合には、スペシャル価格を計算しその算出に用いられた商品個数を減算します。
その上でcalculateUnitPrice
でitemMap
に記録されている各商品の個数に基づいて一個あたりの価格を計算していき、その合計を上記で計算したスペシャル価格の合計と合算することで全てのアイテムの合計価格を算出しています。
テストケース整備
それでは冒頭で参照したテストケースを実行して、意図通りプログラムが動いているか確認してみます。
class CheckOutTest {
@Test
fun testTotals() {
val co = CheckOut(
mutableMapOf("A" to 50, "B" to 30, "C" to 20, "D" to 15),
listOf(Triple("A", 3, 130), Triple("B", 2, 45)))
assertEquals(0, co.price(""))
assertEquals(50, co.price("A"))
assertEquals(80, co.price("AB"))
assertEquals(115, co.price("CDBA"))
assertEquals(100, co.price("AA"))
assertEquals(130, co.price("AAA"))
assertEquals(180, co.price("AAAA"))
assertEquals(230, co.price("AAAAA"))
assertEquals(260, co.price("AAAAAA"))
assertEquals(160, co.price("AAAB"))
assertEquals(175, co.price("AAABB"))
assertEquals(190, co.price("AAABBD"))
assertEquals(190, co.price("DABABA"))
}
@Test
fun testIncremental() {
val co = CheckOut(
mutableMapOf("A" to 50, "B" to 30, "C" to 20, "D" to 15),
listOf(Triple("A", 3, 130), Triple("B", 2, 45)))
assertEquals(0, co.total)
co.scan("A")
assertEquals(50, co.total)
co.scan("B")
assertEquals(80, co.total)
co.scan("A")
assertEquals(130, co.total)
co.scan("A")
assertEquals(160, co.total)
co.scan("B")
assertEquals(175, co.total)
}
}
無事に全てのテストケースを通過したことを確認できました!
まとめ
Checkout
クラスの作成時に渡す価格ルール情報の設定が複雑なものとなってしまっているので、本来であればprimitiveな型ではなく相応しいValueObjectクラスを作成してデータを持たせる、などの対応が好ましいと思いました。
Kotlinらしい書き方ができているかあまり自信がないので、「こう書くのがKotlin wayだよ」などのご指摘がありましたら、コメントお待ちしています。
お読みいただきまして、ありがとうございました!
関連記事一覧
- CodeKataをKotlinでやってみた 〜Karate Chop編〜
- CodeKataをKotlinでやってみた 〜Data Munging編〜
- [CodeKataをKotlinでやってみた 〜Bloom Filters編〜]
(https://qiita.com/Takuyaaaa/items/eaa3848bce3bccdd946f) - [CodeKataをKotlinでやってみた 〜Anagrams編〜]
(https://qiita.com/Takuyaaaa/items/df06e24e6f2c7f8ced35) - [CodeKataをKotlinでやってみた 〜Checkout編〜]
(https://qiita.com/Takuyaaaa/items/0a4b82e30c977444c0bc) - [CodeKataをKotlinでやってみた 〜Sorting it Out編〜]
(https://qiita.com/Takuyaaaa/items/b5210c53bff3ff5f0512) - [CodeKataをKotlinでやってみた 〜Counting Code Lines編〜]
(https://qiita.com/Takuyaaaa/items/cb9143fbcb9e0b2a7822) - [CodeKataをKotlinでやってみた 〜Tom Swift Under the Milkwood編〜]
(https://qiita.com/Takuyaaaa/items/feccf69d2b9d95196a72) - [CodeKataをKotlinでやってみた 〜Transitive Dependencies編〜]
(https://qiita.com/Takuyaaaa/items/9b43473b8feffe1ce9f7) - [CodeKataをKotlinでやってみた 〜Word Chains編〜]
(https://qiita.com/Takuyaaaa/items/2539338252ad7e19ba18) - [CodeKataをKotlinでやってみた 〜Simple Lists編〜]
(https://qiita.com/Takuyaaaa/items/36ef73522bfe8d054448)