0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

CodeKataをKotlinでやってみた 〜Checkout編〜

Last updated at Posted at 2021-01-02

今回も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のインスタンスはpricescanメソッドを呼び出すことで商品の価格を計算していくことができます。

priceメソッドとscanメソッド

pricescanrecordItemCountでアイテム数を記録し、その記録に基づいて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メソッド

それではpricescanの内部で呼び出される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に足し合わせることで全ての商品の合計価格として返却します。

calculateSpecialPriceitemMapを参照してスペシャル価格の対象となる商品・個数の組み合わせがあるかを確認します。もし該当がある場合には、スペシャル価格を計算しその算出に用いられた商品個数を減算します。

その上でcalculateUnitPriceitemMapに記録されている各商品の個数に基づいて一個あたりの価格を計算していき、その合計を上記で計算したスペシャル価格の合計と合算することで全てのアイテムの合計価格を算出しています。

テストケース整備

それでは冒頭で参照したテストケースを実行して、意図通りプログラムが動いているか確認してみます。

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だよ」などのご指摘がありましたら、コメントお待ちしています。

お読みいただきまして、ありがとうございました!

関連記事一覧

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?