nashcft's blog

時々何か書く。

Kotlin で『テスト駆動開発』を進める

記事のまとめ

Part I

1~4章

5~7章

8~11章

12~13章

14~16章

Part II

まだ

Kotlin で『テスト駆動開発』を進める (第14章 - 第16章)

前回

さて Kotlin で『テスト駆動開発』を写経するシリーズも Part I が今回で終わるので一区切りとなる。前回で書籍のコードから設計方針を転換しているので、1つ1つのタスクに対してどのように方針を立て実装していくか、きちんと過程を残すように書きたいと思う。

現在のコード:

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    @Test
    fun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Test
    fun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + five
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }

    @Test
    fun testPlusReturnsSum() {
        val five = dollar(5)
        val result = five + five
        val sum = result as Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }

    @Test
    fun testReduceSum() {
        val sum = Sum(dollar(3), dollar(4))
        val result = reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }

    @Test
    fun testReduceMoney() {
        val result = reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }
}

Bank.kt

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

Money.kt

data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    data class Sum(val augend: Money, val addend: Money) : Expression()
}

TODO リスト:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [ ] Money を変換して換算を行う
  • [x] Reduce(Bank, String)

第14章 学習用テストと回帰テスト

この章ではまず「Money を変換して換算を行う」に取り組む。まずはCHF -> USDの換算に関するテストを追加。

class MoneyTest {

    // ...

    @Test
    fun testReduceMoneyDifferentCurrency() {
        addRate("CHF", "USD", 2)
        val result = bank.reduce(franc(2), "USD")
        assertEquals(dollar(1), result)
    }
}

仮実装の段階なのでとりあえず Bank.kt に addRate() を追加する。

fun addRate(from: String, to: String, rate: Int) {}

このあと書籍では換算処理の記述を Money.reduce() から始めて Bank の中に持っていくために色々するが、現在の私の実装では該当する reduce() の処理は Bank.kt の reduce() の中に存在するので、いきなり Bank.kt に rate() を作って reduce() の中で呼ぶように書くだけでよい。

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> Money(source.amount / rate(source.currency, to), to)
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun rate(from: String, to: String) = if (from == "CHF" && to == "USD")  2 else 1

配列の比較は省略。このあと Pair クラスの作成にかかるが Kotlin には標準で Pair を持っているのでそれを使用することにする。Pair を key, 為替レートを value とする map を Bank に保持させることになり、ここで Bank が状態を保存しておく必要が出てきたので Bank クラスを作成し、これまで Bank.kt に定義していた関数群を Bank のメンバとして持たせるよう変更する。

class Bank {

    private val rates: MutableMap<Pair<String, String>, Int> = HashMap()

    fun reduce(source: Expression, to: String) = when (source) {
        is Money -> Money(source.amount / rate(source.currency, to), to)
        is Expression.Sum -> Money(sum(source.augend, source.addend, to), to)
    }

    private fun rate(from: String, to: String) = if (from == "CHF" && to == "USD")  2 else 1

    private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

    fun addRate(from: String, to: String, rate: Int) {}
}

Bank を作って関数を中に放り込んだためこれまで reduce() を読んでたところがコンパイルエラーになったので修正する。

class MoneyTest {

    // ...

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + dollar(5)
        val reduced = Bank().reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }

    // ...

    @Test
    fun testReduceSum() {
        val sum = Expression.Sum(dollar(3), dollar(4))
        val result = Bank().reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }

    @Test
    fun testReduceMoney() {
        val result = Bank().reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }

    @Test
    fun testReduceMoneyDifferentCurrency() {
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val result = bank.reduce(franc(2), "USD")
        assertEquals(dollar(1), result)
    }
}

テストが通るか確認をして、addRate() で実際に為替レートを格納する処理、および rate() で為替レートを取得を実装する。

class Bank {

    // ...

    private fun rate(from: String, to: String) = rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    // ...

    fun addRate(from: String, to: String, rate: Int) {
        rates.put(Pair(from, to), rate)
    }
}

Map.get() の返却値は V? (今回の場合 Int?) なので、為替レートが未登録の場合は例外を投げるか rate()rate(): Int? として定義するか適当なデフォルト値を返すがということになるが、内部処理で使用しているのでここで null を返されても困るし、例外設計は写経の本筋と離れるので今回は例外を投げるということにしておく。ここでテストを実行すると testReduceMoney() がレッドになる。同じ通貨への為替レートを返さないといけないので、回帰テストを追加してから対応する。

class MoneyTest {

    // ...

    @Test
    fun testIdentityRate() {
        assertEquals(1, Bank().rate("USD", "USD"))
    }
}
class Bank {

    // ...

    fun rate(from: String, to: String) =
            if (from == to) 1 else rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    // ...
}

これで全テストがグリーンに戻り、14章の内容は終了。TODO リストを更新しておく:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)

第15章 テスト任せとコンパイラ任せ

この章で Part I の本題である $5 + 10CHF = $10 に着手することになる。

class MoneyTest {

    // ...

    @Test
    fun testMixedAddition() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val result = bank.reduce(fiveBucks + tenFrancs, "USD")
        assertEquals(dollar(10), result)
    }
}

$5 と 10CHF を Expression として受けているためコンパイルエラーになる。書籍の次ステップの通り一旦これらを Money で受けるようにするとコンパイルが通りテストが... グリーンになる。これは第13章で設計を転換した時に Bank.reduce() の実装を Bank.sum() を介して再帰的に書いていたためで、実はこのあとの書籍の対応と同じだったのだ。

そういうわけで先ほどやろうとした Expression.plus の実現を進める。まずはテストケースを章の最初に示した状態に戻し、Sum のフィールドと Money.times() の返却値の型を Expression にする。

data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int): Expression = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

あとは Expression 自身が plus() を持っていればよいので、Money からそのまま連れてくる。

data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int): Expression = Money(amount * multiplier, currency)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

最後に TODO リストを更新しておしまい:

  • [x] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [ ] Expression.times

更新内容をみてわかるが、 Expressionplus() を持ってきた時点で Sum もこの関数を使えるようになっているので完了してしまっている。

第16章 将来の読み手を考えたテスト

さて前章で Sum.plus の実装も終わったことにしているが、本当にそうなのか? 本来 Sum.plus を実装するはずだったこの章で追加されるテストで確認をする。

class MoneyTest {

    // ...

    @Test
    fun testSumPlusMoney() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks
        val result = bank.reduce(sum, "USD")
        assertEquals(dollar(15), result)
    }
}

きちんと通った。では次は Expression.times の実装をする。

class MoneyTest {

    // ...

    @Test
    fun testSumTimes() {
        val fiveBucks: Expression = dollar(5)
        val tenFrancs: Expression = franc(10)
        val bank = Bank()
        bank.addRate("CHF", "USD", 2)
        val sum = Expression.Sum(fiveBucks, tenFrancs) * 2
        val result = bank.reduce(sum, "USD")
        assertEquals(dollar(20), result)
    }
}

フィクスチャーは今はスルー。書籍では Expressionインターフェイスなので Sum に実装を書いているが、私の実装では sealed class なので Money.timesExpression に持ってきて、when で分岐させれば十分だろう。

data class Money(val amount: Int, val currency: String) : Expression() {

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    operator fun times(multiplier: Int): Expression = when(this) {
        is Money -> Money(amount * multiplier, currency)
        is Sum -> Sum(augend * multiplier, addend * multiplier)
    }

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

テストも通ったのでこれでOK。

さて最後の「$5 + $5Money を返す」だが、結果的に何もしないことになるので、これで第16章が終わる。

  • [x] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [x] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [x] Expression.times

まとめと振り返りなど

最終的な実装を以下に示す。差分を書き直すのが面倒なので GitHub のレポジトリと合わせるためにテストクラスに雑なフィクスチャー (っぽいもの) を作ったりMoney.currencyenum class Currency とした実装を含めたりしている。

MoneyTest.kt

package money

import money.Money.Companion.dollar
import money.Money.Companion.franc
import money.Money.Currency.CHF
import money.Money.Currency.USD
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    @Test
    fun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Test
    fun testCurrency() {
        assertEquals(USD, dollar(1).currency)
        assertEquals(CHF, franc(1).currency)
    }

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + dollar(5)
        val reduced = Bank().reduce(sum, USD)
        assertEquals(dollar(10), reduced)
    }

    @Test
    fun testPlusReturnSum() {
        val five = dollar(5)
        val sum = (five + five) as Expression.Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }

    @Test
    fun testReduceSum() {
        val sum = Expression.Sum(dollar(3), dollar(4))
        val result = Bank().reduce(sum, USD)
        assertEquals(dollar(7), result)
    }

    @Test
    fun testReduceMoney() {
        val result = Bank().reduce(dollar(1), USD)
        assertEquals(dollar(1), result)
    }

    @Test
    fun testReduceMoneyDifferentCurrency() {
        val bank = Bank()
        bank.addRate(CHF, USD, 2)
        val result = bank.reduce(franc(2), USD)
        assertEquals(dollar(1), result)
    }

    @Test
    fun testIdentityRate() {
        assertEquals(1, Bank().rate(USD, USD))
    }

    private val fiveBucks: Expression = dollar(5)
    private val tenFrancs: Expression = franc(10)

    @Test
    fun testMixedAddition() {
        val bank = setUpBankWithCHF2USDRate()
        val result = bank.reduce(fiveBucks + tenFrancs, USD)
        assertEquals(dollar(10), result)
    }

    @Test
    fun testSumPlusMoney() {
        val bank = setUpBankWithCHF2USDRate()
        val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks
        val result = bank.reduce(sum, USD)
        assertEquals(dollar(15), result)
    }

    @Test
    fun testSumTimes() {
        val bank = setUpBankWithCHF2USDRate()
        val sum = Expression.Sum(fiveBucks, tenFrancs) * 2
        val result = bank.reduce(sum, USD)
        assertEquals(dollar(20), result)
    }

    private fun setUpBankWithCHF2USDRate(): Bank {
        val bank = Bank()
        bank.addRate(CHF, USD, 2)
        return bank
    }
}

Money.kt

package money

data class Money(val amount: Int, val currency: Currency) : Expression() {

    companion object {
        fun dollar(amount: Int) = Money(amount, Currency.USD)
        fun franc(amount: Int) = Money(amount, Currency.CHF)
    }

    enum class Currency {
        USD, CHF
    }
}

sealed class Expression {

    operator fun plus(addend: Expression): Expression = Sum(this, addend)

    operator fun times(multiplier: Int): Expression = when (this) {
        is Money -> Money(amount * multiplier, currency)
        is Sum -> Sum(augend * multiplier, addend * multiplier)
    }

    data class Sum(val augend: Expression, val addend: Expression) : Expression()
}

Bank.kt

package money

import money.Money.Currency

class Bank {

    private val rates: MutableMap<Pair<Currency, Currency>, Int> = HashMap()

    fun reduce(source: Expression, to: Currency) = when (source) {
        is Money -> Money(source.amount / rate(source.currency, to), to)
        is Expression.Sum -> Money(sum(source.augend, source.addend, to), to)
    }

    fun rate(from: Currency, to: Currency) =
            if (from == to) 1 else rates[Pair(from, to)]
                    ?: throw IllegalArgumentException("Unregistered rate: $from to $to")

    private fun sum(augend: Expression, addend: Expression, to: Currency): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

    fun addRate(from: Currency, to: Currency, rate: Int) {
        rates.put(Pair(from, to), rate)
    }
}

今回取り組んだ3章に関しては、第13章で実施した設計の転換によって1つの機能に対して実装する箇所が基本的に1箇所に集約されるようになって、書籍と比べてあっちこっち見ずに済み感覚的に実装の負担が少ないように思えたのが印象的だった。とはいえ主な修正箇所が Bank だったこと、そもそもの実装内容が小規模だったことからそれ以外は特に気になるような差異はなかったように思う。まあ sealed class を使うことで分岐における網羅性の保証について無駄なことを考慮しなくて済むので、積極的に分岐を使って実装を集約しやすいという点は前回と今回で割と活きたのではないだろうか。

全体を通して Kotlin という言語に持った印象は、便利機能が標準で入っていたり Java の Object にまつわるボイラープレート的な実装を省略できる機能があったりする点以外はやはり Java ベースの言語で、Kotlin は Java 8 とは異なる方向性の進化をした、順当な「モダンさ」を取り入れた Java という感じだった。これは data class やsealed class を用いて色々な実装の手間が省けた以外では記述方法が異なる場所があるだけでおおよそ Java の実装と変わらない形に落ち着いたなあという所感によるところが大きいと思う。ただ Bank の実装で見たように、Java とは異なり最初からクラスやオブジェクトという構造を単位として作らなくてよく、関数という機能単位で実装を進められることは、ボトムアップなアプローチができるという点で設計のやりようが柔軟になっているかもしれない。この辺は実際に Kotlin でアプリなどを開発している現場が延べでどのくらい package-level function を作ってるのか聞いてみたい所である。

TDD に関しては、これまで聞きかじったり仕事でもなんとなくそれっぽくやってみていたりしたこともあってサイクルやテンポがとても自然に感じられた。ただ設計についての考え方や実装の粒度については学ぶところが多く、特に大きな流れの中で小さなサイクルを回しながら前進しつつ、ゴールにきちんと向かっていくという第5章から第11章までの流れがとても印象的だった。

そういえば途中で振り返りで触れるぞーと言っていた話題があったが、sealed class や enum を使ってバリエーションを開発側で制限するのって、例えばこの多国通貨のシステムがパッケージなりライブラリなりとして提供された時の使い勝手的にどうなのかなーというもので、ユーザ定義で増やせる方が使いやすいとかでも予期しないエラーがどうのこうのとかそういう感じの話だったのだが、TDDとはそこまで関係ないなーと今になって思ったのでこれだけ。最後に currencyenum class にしたのはやっぱそっちの方が楽だよねという気持ちがあって、でも最後にやっても何の恩恵にもあずかれないのであった。

Part II 以降については別にやりたいことを進めているため、読み進めているだけで写経は一旦お休みという状態である。こっちも写経の様子をブログにできたらとは思っている。

Kotlin で『テスト駆動開発』を進める (第12章 - 第13章)

前回

前回までは通貨を表すクラスをシンプルにしていくタスクが中心だったが、今回からやっと本題である通貨の足し算に手を着け始める。

現在のコード:

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five.times(2))
        assertEquals(dollar(15), five.times(3))
    }

    @Test
    fun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Test
    fun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }
}

Money.kt

data class Money(private val amount: Int, val currency: String) {

    fun times(multiplier: Int) = Money(amount * multiplier, currency)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

第12章 設計とメタファー

まずは長くなった TODO リストから未完了で必要なタスクを取り出し、新しい TODO も追加する:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10

まずは同じ通貨の足し算から。追加するテストは以下の通り。

class MoneyTest {

    // ...

    @Test
    fun testSimpleAddition() {
        val sum = dollar(5).plus(dollar(5))
        assertEquals(dollar(10), sum)
    }
}

そして仮実装を書く。

data class Money(private val amount: Int, val currency: String) {

    // ...

    fun plus(addend: Money) = Money(amount + addend.amount, currency)
    // ...
}

書籍ではここで多国通貨間の計算をどう表現するかという議論とメタファーに関するお話が始まり、「式 (expression)」のメタファーを採用するという結果に着地しするので、それをテストコードに反映させる。

class MoneyTest {

    // ...

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five.plus(five)
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }
}

書籍では reduce() を「銀行の責務」として Bank クラスを作りそこに実装するのだが、今の所持つ状態も無さそうだししばらく package-level function でいいかという判断をして様子を見ることにした。

新しいものが追加されたのでそれらを実装していく。
まずは Expressionインターフェイスとして作成。

// Expression.kt
interface Expression

Moneyplus() 関数の返却値型を Expression に変更し、 Money 自身にも Expression を実装する。

data class Money(private val amount: Int, val currency: String) : Expression {

    // ...

    fun plus(addend: Money): Expression = Money(amount + addend.amount, currency)
    // ...
}

reduce() については、とりあえず Bank.kt ファイルを作成してそこに package-level function として定義して、テストを通すための仮実装をしておく。なお、実際に呼び出す際は reduce() だけでよいが、記事内では便宜上 Bank.reduce() と呼ぶ。

// Bank.kt
fun reduce(source: Expression, to: String) = Money.Companion.dollar(10)

これで12章の内容は終わりなのだが、ちょっとだけ寄り道をしたい。
今回新しく plus() 関数が追加されたが、以前から times() 関数があり、そういえばこの辺は四則演算を表す関数名だということを思い出す。通貨の計算も「通貨」の概念がある以外は分量の計算だし数値と同じように演算子でできたらいいなーと思い調べてみれば Operator overloading ができるみたいなのでこのタイミングで書き換えてしまおう。Operator overloading をするには対象となる関数定義に operator キーワードを加えるだけでよい。

data class Money(private val amount: Int, val currency: String) : Expression {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Money(amount + addend.amount, currency)

    // ...
}

すると現在のテストコードは以下のように書き直せる。

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five * 2)
        assertEquals(dollar(15), five * 3)
    }

    // ...

    @Test
    fun testSimpleAddition() {
        val five = dollar(5)
        val sum = five + five
        val reduced = reduce(sum, "USD")
        assertEquals(dollar(10), reduced)
    }
}

...乗算をする時の左辺と右辺が違う型の値なのが見た感じ微妙かもしれないけど慣れの問題のような気もする。まあ終わった TODO にも $5 * 2 = $10 とかあるし、よりこれに近い記述ができてるからよいということにする。ところで現状だと乗算の順序が固定されてしまうので、不満があれば Int に対して以下のように extension function を追加するということができる。この写経では使わないけど。

operator fun Int.times(money: Money) = money.times(this)

第13章 実装を導くテスト

前章で実装した plus() の重複を取り除くために先へ進める。まずは TODO の追加から:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す

Sum クラスを追加するためのテストを書く。とりあえずキャストなど含め書籍のコードそのままっぽく。

class MoneyTest {

    // ...

    @Test
    fun testPlusReturnsSum() {
        val five = dollar(5)
        val result = five + five
        val sum = result as Sum
        assertEquals(five, sum.augend)
        assertEquals(five, sum.addend)
    }
}

Sum を追加する。使い方がまだはっきりしないのでとりあえず普通の class で宣言しておく。

// Sum.kt
class Sum(val augend: Money, val addend: Money) : Expression

Sum を返すように plus() の実装を修正する。

data class Money(private val amount: Int, val currency: String) {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    // ...
}

Sum の準備ができたので reduce()リファクタリングを始める。まずは現状の reduce() では失敗するようなテストを追加する。

class MoneyTest {

    // ...
    @Test
    fun testReduceSum() {
        val sum = Sum(dollar(3), dollar(4))
        val result = reduce(sum, "USD")
        assertEquals(dollar(7), result)
    }
}

Bank.reduce() を修正するが、記事が長くなってきたので書籍では2段階かけてるところを一気に進める。

fun reduce(source: Expression, to: String): Money {
    val sum = source as Sum
    return sum.reduce(to)
}

Sum.reduce() の追加:

class Sum(val augend: Money, val addend: Money) : Expression {

   fun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

外部から参照されるようになったので Money.amountprivate を外す:

data class Money(val amount: Int, val currency: String) : Expression {

    // ...
}

ここで TODO を1つ追加:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [ ] Bank.reduce(Money)

この後も一気に進める。やることは Bank.reduce() 内で型キャストを行わなくて済むように、Expression インターフェイスreduce() を定義することだ。

class MoneyTest {

    // ...

    @Test
    fun testReduceMoney() {
        val result = reduce(dollar(1), "USD")
        assertEquals(dollar(1), result)
    }
}
interface Expression {

    fun reduce(to: String): Money
}
data class Money(val amount: Int, val currency: String) : Expression {

    // ...

    override fun reduce(to: String): Money = this

    // ...
}
class Sum(val augend: Money, val addend: Money) : Expression {

  override fun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

これで Bank.reduce() では Expression.reduce() を呼ぶだけでよくなる。

fun reduce(source: Expression, to: String): Money = source.reduce(to)

これでこの章での実装は終わり。最後に TODO を消したり足したりしておく:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [ ] Money を変換して換算を行う
  • [ ] Reduce(Bank, String)

第14章に入る前に

さて、単に写経をするだけならこれで第13章は終わりだが、一度ここで現在の実装を確認したい。

// Bank.kt
fun reduce(source: Expression, to: String): Money = source.reduce(to)
// Expression.kt
interface Expression {

    fun reduce(to: String): Money
}
// Money.kt
data class Money(val amount: Int, val currency: String) : Expression {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    override fun reduce(to: String): Money = this

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}
// Sum.kt
class Sum(val augend: Money, val addend: Money) : Expression {

  override fun reduce(to: String): Money {
        val amount = augend.amount + addend.amount
        return Money(amount, to)
    }
}

(テストコード省略)

この構成、多分 Java 的には特に違和感のない構成なのかもしれないが、私がここまで進めた時は以下のような疑問を抱いた:

  • なんで reduce() の実処理が Expression の実装にあるのか
    • Bank だけが処理の詳細を知っていればよいのでは?
    • Kotlin だったらこういう分岐って以前やった sealed class と when のパターンマッチングでいい感じに1ヶ所にまとめて表現できるよね?

ということでこのシリーズでもおなじみの Elm 版を参考に以下のように書き直した。

// Bank.kt
fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount
// Money.kt
data class Money(val amount: Int, val currency: String) : Expression() {

    operator fun times(multiplier: Int) = Money(amount * multiplier, currency)

    operator fun plus(addend: Money): Expression = Sum(this, addend)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

sealed class Expression {

    data class Sum(val augend: Money, val addend: Money) : Expression()
}

テストコードに関しては import のしかたによっては Sum を参照するところが Expression.Sum になる程度で、これはどっちで参照するようにするかはお好みなので特に変更無しということで省略。

コードだけではあんまりなので解説をすると、まず Bank.reduce() の処理の実態が MoneySum に散ってしまっているのを解決しようとして、書いてある通り Expression を sealed class としてそれのサブクラスが持つ reduce() の実装を Bank.reduce() 内で when を使ってまとめた。 これは最後に TODO に追加した Reduce(Bank, String) のように Bank をわざわざ連れまわすこともなくなるし、 "reduce" という処理を見るときにBank だけ見れば済むのでよいというところが私には嬉しい。
この辺は手続き的な分岐じゃなくて宣言的な検査だから〜など説いて書籍の実装のような操作対象のオブジェクトに振る舞いを持たせる設計と今回のパターンマッチングを用いた設計を比較してどうのこうの書こうと思ったが、今回使ったようなレベルのパターンマッチングと if 文などによる分岐の差*1とは...? となり、そもそも私がパターンマッチングについて十分な理解をしていないなあと思ったので不用意なことは書かないことにする。あえて何か書くとしたら、上で変更した後の実装の方が自分のものの整理のしかたと合ってて好ましいので可能ならばそちら側に倒す実装をするが、とはいえ Java のようにシステム上それを安全に実装することができない環境下であればそちらの流儀に合わせるだろうなあ、という所感くらい。

ところで Expression のサブクラスについて SumExpression の内側で宣言しているのは、具体的なものを表す Money と異なり Sum は抽象的な概念で Money のとは異なるレイヤーにあるというか、「式」というメタファーにおける内部的な表現のように感じられたので、その微妙な違いを表現してみたかったからで、特に何か構文上の制約を利用して何かしようとしているわけではない。

Bank.reduce()Sum に対する処理と sum() 関数については、これは Elm 版で書かれている実装をほぼそのまま持ってきている。

fun reduce(source: Expression, to: String) = when (source) {
    is Money -> source
    is Sum -> Money(sum(source.augend, source.addend, to), to)
}

private fun sum(augend: Expression, addend: Expression, to: String): Int =
            reduce(augend, to).amount + reduce(addend, to).amount

reduce()Sum 側の処理で呼んでいる sum() はその中で reduce() を呼んでおり、これが Money を返すので、返ってきた Moneyamount を足し合わせて返すことで大元の reduce() で各フィールドの amount が足し合わされた Money ができる、という再帰っぽい流れになっている。というか実際再帰になってて、先のネタバレをすると Sum のフィールドは Money 型だがこれが後に Expression 型になるので Sum を抱えた Sum が投げつけられることも起こりうるのだが、そんな時でも上の実装がそのまま使えるようになっている*2。また sum() については private な package-level function なので宣言された Bank.kt の中でしか参照することができないようになっており、内部詳細に当たる箇所もきちんと隠すことができている。

この修正により1つやらなくて良くなった項目があるので TODO リストを更新する:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [ ] $5 + $5 = $10
  • [ ] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [ ] Money を変換して換算を行う
  • [x] Reduce(Bank, String)

ここまでのまとめ

13章にきてあえて書籍の Java コードとは異なる設計 (というか表現?) で実装を書くようにした。これによってこの先の変更の難易度はどのように異なってくるのかや、この設計のメリットやデメリットについて見ていくようにしたい。また12章では Bank についてクラスを作らず package-level function のみで同等のものを実装したが、これは状態を持つ必要のない機能群についてはあえて先にクラスという枠を作らずに関数の単位でポコポコ作っていって、あとで意味のあるかたまりとかでまとめておくみたいなボトムアップ的アプローチができるのかなーという風に感じた。実際どうなのだろうか。

続き:

nashcft.hatenablog.com

*1:今回のように sealed class と when の組み合わせでは考慮すべき場合を型レベルで狭められるという利点はあるが

*2:と言ってもここは書籍の実装も振る舞い的には変わらないのでアドバンテージとして見られるかというとそうではない

最近書いてないやつ

別段忙しくてニャーンであるとかそういう状態ではないのだけど全然日記的なもの書けてなかったり GitHub ガーデニングも真っ白殺風景になってたりしていますが私は元気です。

何していたかというと会社では毎日無理ない程度に進捗を出してるだけなのに帰宅すると疲れ果ててひたすら寝るということをしていたり起きている間は ÉKRITS Books I 読んでたりしていて、先月色々あった分の疲れを身体が認識し始めたのかなーと適当に考えて静かに過ごしているというかそうしかできないというか。
そういえば Packt Publishing がセールで電子書籍を $5 で売ってて、まだやってるっぽいので気になるけど Packt の本のクオリティに不安があって躊躇してる的なのがあったらいいタイミングだと思う。

www.packtpub.com

私はというと気がついたら11冊買ってたのでセールは恐ろしい。

Kotlin で『テスト駆動開発』を進める (第8章 - 第11章)

前回

今回は11章まで進める。前回からの Money, Dollar, Franc にまつわるリファクタリングの続きで、今回分で一区切りがつく内容となっている。

現在のコードは以下の通り:

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = Dollar(5)
        assertEquals(Dollar(10), five.times(2))
        assertEquals(Dollar(15), five.times(3))
    }

    @Test
    fun testEquality() {
        assertTrue(Dollar(5) == Dollar(5))
        assertFalse(Dollar(5) == Dollar(6))

        assertTrue(Franc(5) == Franc(5))
        assertFalse(Franc(5) == Franc(6))

        assertFalse(Dollar(5).equals(Franc(5)))
    }

    @Test
    fun testFrancMultiplication() {
        val five = Franc(5)
        assertEquals(Franc(10), five.times(2))
        assertEquals(Franc(15), five.times(3))
    }
}

Money.kt

data class Dollar(private val amount: Int) : Money(amount)
data class Franc(private val amount: Int) : Money(amount)

sealed class Money(private val amount: Int) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier)
        is Franc -> Franc(amount * multiplier)
    }
}

第8章 実装を隠す

この章ではまず times() 関数を纏める準備として返却値の型を Money に変更しているが、これは既に第6章で実装はともかく Moneytimes() 関数を移すところまで達成している。

次は Money のサブクラスのインスタンスを生成する時に直接それらを参照しないよう、それぞれの static factory method を作成する。Kotlin では Java のようにクラスに static method を設けることはできないが、同じような挙動をする関数を作る方法はいくつか用意されている*1。今回は companion object を使用する方法で実装する (以下のコードは Money 内部のみを抜粋したもの)。

sealed class Money(private val amount: Int) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier)
        is Franc -> Franc(amount * multiplier)
    }

    companion object {
        fun dollar(amount: Int): Money = Money.Dollar(amount)
        fun franc(amount: Int): Money = Money.Franc(amount)
    }
}

テストの方は直接 DollarFranc のコンストラクタを呼んでいた部分が factory method に置き換わっただけなので割愛する。

この変更で直接 Money のサブクラスを呼ぶ必要がなくなり、これらを削除する準備が進んだ。

TODO リストに1つ項目を追加して次の章に進む:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] Dollar と Franc の重複
  • [x] equals の一般化
  • [ ] times の一般化 -> WIP
  • [x] Franc と Dollar を比較する
  • [ ] 通過の概念
  • [ ] testFrancMultiplication を削除する?

第9章 歩幅の調整

この章ではこれまでサブクラスで表していた通貨の概念を別の方法で表現することで、サブクラスを消す準備を万全にしていく。

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] Dollar と Franc の重複
  • [x] equals の一般化
  • [ ] times の一般化 -> WIP
  • [x] Franc と Dollar を比較する
  • [ ] 通過の概念
  • [ ] testFrancMultiplication を削除する?

この章は同じことを表現するのにやや寄り道っぽいことをしていて既読組からしたら冗長に感じるかもしれないが、「過程を眺める」のがこのシリーズの動機なので、章のタイトルになっている「歩幅の調整」の様子も残しておきたい。

まずはテストの追加。

class MoneyTest {

    // ...

    @Test
    fun testCurrency() {
        assertEquals("USD", dollar(1).currency())
        assertEquals("CHF", franc(1).currency())
    }

}

メソッド呼び出しでどの通貨かわかるようにしたいようなので、Moneycurrency() 関数を追加する。書籍の方ではサブクラスにも実装を追加しているが、私の実装では sealed class と when 式によって Money クラス内の変更だけで済む。

sealed class Money(private val amount: Int) {

    fun currency() = when (this) {
        is Dollar -> "USD"
        is Franc -> "CHF"
    }

    // ...
}

実装した後、メソッドじゃなくてフィールド変数でよくない? となり、書籍も実際そういう流れなのでそのようにしていく。これは実際 Java のコードでは currency() は消えていないのだけど、これが実質 currency をそのまま返す getter になってて、フィールドの値をそのまま返す getter だけ生えてるって val で定義したフィールドそのものじゃんとなり、 Kotlin 的にはフィールドにアクセスする記法が普通っぽいのでそれに従うという判断による。カスタム getter/setter を作っても使い方はフィールドアクセスと一緒だし。

まずは data class の方から。

data class Dollar(private val amount: Int,
                  val currency: String = "USD") : Money(amount)
data class Franc(private val amount: Int, 
                 val currency: String = "CHF") : Money(amount)

Kotlin では関数の引数やコンストラクタに書かれたプロパティにデフォルト値を設定できるので上記のような書き方ができる。これだけだと factory method が Money 型として返している都合上使っている側はキャストしないと currency を参照できないので Money にも currency を生やす。

data class Dollar(private val amount: Int,
                  override val currency: String = "USD") : Money(amount, currency)
data class Franc(private val amount: Int, 
                 override val currency: String = "CHF") : Money(amount, currency)

sealed class Money(private val amount: Int, open val currency: String) {
    // ...
}

コンストラクタの中では abstract にはできないので open にして、サブクラスの currency には override をつけ、スーパークラスに渡すように変更した。わざわざ override させる意義はないのだけど、スーパークラスのフィールドが private じゃないのでサブクラスの同名フィールドはアクセス範囲を狭められないし、かといって使わないからってサブクラスのフィールド名を雑なものにするのもちょっと... という気持ちもあり、まああとで消すのだし今はいいかということでこのまま進める。

次は通貨の種類を表す文字列を 外部から渡すようにしてコンストラクタの差異 (= デフォルト引数) を無くそうというもの。

data class Dollar(private val amount: Int,
                  override val currency: String) : Money(amount, currency)
data class Franc(private val amount: Int, 
                 override val currency: String) : Money(amount, currency)

sealed class Money(private val amount: Int, open val currency: String) {

    // ...

    companion object {
        fun dollar(amount: Int): Money = Money.Dollar(amount, "USD")
        fun franc(amount: Int): Money = Money.Franc(amount, "CHF")
    }
}

これによってサブクラスの currency に対するデフォルト値がなくなったため times() 関数内でも何か値をあげるようにしないといけなくなり、それを回避するために書籍に倣って factory method を呼ぶように変更する

sealed class Money(private val amount: Int, open val currency: String) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> dollar(amount * multiplier)
        is Franc -> franc(amount * multiplier)
    }

    // ...
}

これでこの章でやることは終わり。途中 Money.kt の中身は変更に関係ない部分を省略して記述したため、小さいファイルとはいえ一応全体を載せておく。

data class Dollar(private val amount: Int,
                  override val currency: String) : Money(amount, currency)
data class Franc(private val amount: Int, 
                override val currency: String) : Money(amount, currency)

sealed class Money(private val amount: Int, open val currency: String) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> dollar(amount * multiplier)
        is Franc -> franc(amount * multiplier)
    }

    companion object {
        fun dollar(amount: Int): Money = Money.Dollar(amount, "USD")
        fun franc(amount: Int): Money = Money.Franc(amount, "CHF")
    }
}

第10章 テストに聞いてみる

この章の目標は times() メソッドを Money クラス内に引き上げることなのだが、ご存知の通り既に Money にいるので、やることは times() の実装を修正するだけである。具体的には factory method を呼ぶようにしたのをやめて、コンストラクタに自身の currency を与えるようにする。

sealed class Money(private val amount: Int, open val currency: String) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier, currency)
        is Franc -> Franc(amount * multiplier, currency)
    }

    // ...
}

これで times() の分岐後の処理が同じ形になったので、分岐の意味も殆ど無くなったし分岐を消して Money を返すようにする... と言いたいところだが、 Money は sealed class なので直接インスタンス化できない。ここでは一度見送って、次の章に進む。
ところでこの章で追加される予定だったテストケースは equals() を自分で実装してないしどうしようもないのでパスした。

第11章 不要になったら消す

もう DollarFranc は必要なくなったので削除し、直接 Money を使えるようにする。Dollar たちと同じように使えるようにするため、 Money は sealed class から data class へ変更する。

data class Money(private val amount: Int, val currency: String) {

    fun times(multiplier: Int) = Money(amount * multiplier, currency)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

とてもスッキリした。あとは不要になっているテストを削除する。対象は

  • testEquality() 内で Franc 同士の比較をしていた assertion (USD, CHF を表現するクラスが同一になったので重複する検査となったため)
  • testFrancMultiplication() (これも重複)

そして USD と CHF の比較が失敗するという assertion で何も書き足さなくても == が使えるようになったのでそのように変更する。

これで Moneyリファクタリングが終わり、本題である通貨の足し算に進めるようになった。

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [x] Dollar と Franc の重複
  • [x] equals の一般化
  • [x] times の一般化
  • [x] Franc と Dollar を比較する
  • [x] 通過の概念
  • [x] testFrancMultiplication を削除する?

ここまでのまとめ

第11章終了時点でのコード:

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = dollar(5)
        assertEquals(dollar(10), five.times(2))
        assertEquals(dollar(15), five.times(3))
    }

    @Test
    fun testEquality() {
        assertTrue(dollar(5) == dollar(5))
        assertFalse(dollar(5) == dollar(6))

        assertFalse(dollar(5) == franc(5))
    }

    @Test
    fun testCurrency() {
        assertEquals("USD", dollar(1).currency)
        assertEquals("CHF", franc(1).currency)
    }
}

Money.kt

data class Money(private val amount: Int, val currency: String) {

    fun times(multiplier: Int) = Money(amount * multiplier, currency)

    companion object {
        fun dollar(amount: Int) = Money(amount, "USD")
        fun franc(amount: Int) = Money(amount, "CHF")
    }
}

ここまで Money にまつわるリファクタリングを続けてきたが、実装の過程においては「times() の一般化」は sealed class で実装したことによって殆どやることがなかった点と、 currency の実装周りで Java 版とは異なる苦労をしたのが私の中で印象的だった一方、出来上がったコードは第4章時点のものに近いもの、つまり Java 版とあまり変わらないものに落ち着いているのも興味深い。次回以降も極力 Kotlin の機能を使うようにして Java 版との違いを見ていくようになっているのでお楽しみに。
ところで Elm 版の記事では今回追加した通貨の概念を型として表現するように変更を加えており、 Kotlin*2では enum class を用いて同様の表現ができるのだが、写経をやってた当時色々考えて結局最後に変更するまで文字列のままでいた。これは本筋とは関係ないので前回の Money を sealed class で実装した時のこととまとめて一連の記事の最後に振り返りみたいな感じで触れるつもり。まあ同じ理由だし、 sealed class はクラスの enum のようなものだし。

続き:

*1:この記事で採用している companion object の他に package level function として定義する方法もある

*2:というか Java もできるのだが

週末までにやったこと

週の頭に今週これやろうとメモってたことに対する振り返り

TODO に書いてあったこと

Binder transaction buffer のサイズについて再調査 & 記事の加筆修正

やってない 😇
来週末までに終わらせる。

TDD_kotlin: 14章を終わらせる

第I部の終盤だし1章1章が結構かかるかなーという予想に反して分量が少なかったので勢いで進めてたら第I部終わった。

Kotlin で『テスト駆動開発』をやった過程についてブログを書く (1~4章?)

7章まで書いた:

"Reactive Android Programming" の写経レポジトリを作成して chapter 1 まで終わらせる

レポジトリを作ってプロジェクトのセットアップをするところまでは終わらせた。Chapter 1 の内容に対する進捗としては半分。
欲を出してこっちも Kotlin でやろうとして色々調べていたら時間がかかってしまった。

書いてないことでやったこと

Kotlin で『テスト駆動開発』を進める (第5章 - 第7章)

前回 nashcft.hatenablog.com

今回は第5章から第7章まで進める。
これまでの過程で書いたコードは以下の通り。前回の脚注で触れているが実際に私が取り組んだ時のものに対して順序の再構成やコードのブラッシュアップなどを行っているため、レポジトリのコードやコミットの流れとは異なっている部分がある。

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = Dollar(5)
        assertEquals(Dollar(10), five.times(2))
        assertEquals(Dollar(15), five.times(3))
    }

    @Test
    fun testEquality() {
        assertTrue(Dollar(5) == Dollar(5))
        assertFalse(Dollar(5) == Dollar(6))
    }
}

Dollar.kt

data class Dollar(private val amount: Int) {

    fun times(multiplier: Int) = Dollar(amount * multiplier)
}

第5章 あえて原則を破るとき

これまでは1種類の通貨に関して注目していたが、この章から本題となる「多国通貨の足し算」を行えるようにするための準備が始まる。
まずはもう1つの通貨としてフラン (Franc) を追加し、これまで扱ってきたUSドルと同じように動作させたい:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [ ] 5CHF * 2 = 10CHF

この章で行うことは Franc クラスに対して Dollar に対して書いたものと同様のテストを追加し、 Dollar をコピペして Franc クラスを作るというもの。短時間で確実に前進できる手段を選ぶためならばこんな行いも許容される。ただしその後きっちりリファクタリングをするならば、だが。

class MoneyTest {

    // ...

    @Test
    fun testFrancMultiplication() {
        val five = Franc(5)
        assertEquals(Franc(10), five.times(2))
        assertEquals(Franc(15), five.times(3))
    }
}
data class Franc(private val amount: Int) {

    fun times(multiplier: Int) = Franc(amount * multiplier)
}

さて、これまでは殆どの場合テストを書くところからコードをリファクタリングするところまでをセットに進めてきたが、この点に関してはこれから第11章にかけてのより大きな流れでリファクタリングをしていくことになるので、とにかく先に進もう。

犯した罪を追加した現時点での TODO リスト:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] DollarFranc の重複
  • [ ] equals の一般化
  • [ ] times の一般化

第6章 テスト不足に気づいたら

この章では前章で作ってしまった重複を消していくために、まず共通の親クラスを作りそれを継承して equals() の一般化を行う、という進め方をする。
ところで私は Dollar, Franc とも data class として実装したので、equals() を自分で実装しておらず共通化するものがないのだった。

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] DollarFranc の重複
  • [x] equals の一般化
  • [ ] times の一般化

とはいえ後のことも考えて、書籍の内容に則り Money クラスを作成し DollarFranc に継承させ、ついでに本章で追加される assertion も足しておく。

// Dollar.kt
data class Dollar(amount: Int) : Money(amount) {

    fun times(multiplier: Int) = Dollar(amount * multiplier)
}

// Franc.kt
data class Franc(amount: Int) : Money(amount) {

    fun times(multiplier: Int) = Franc(amount * multiplier)
}

// Money.kt
open class Money(private val amount: Int)

Kotlin のクラスはデフォルトで final なので継承できるようにするためには open を足しておく必要があることに注意。

ところで実際に写経をやっていた時は書籍と Elm 版記事の両方を開きながら進めていて、この章に取り組んでいた時に Elm 版の同じ章のところを見返してあー Union Type いいよなーパターンマッチいいよなー使いたいなーってなって探したら sealed class というのがあってそのものズバリな感じの使用感だったのでそれを使った。これによって times() の宣言自体は Money の中で行えるため、 Elm 版と同じく共通化を一歩先に進められるようになる。

// Money.kt
package money

data class Dollar(private val amount: Int) : Money(amount)
data class Franc(private val amount: Int) : Money(amount)

sealed class Money(private val amount: Int) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier)
        is Franc -> Franc(amount * multiplier)
    }
}

Sealed class の主な特徴は以下の通り:

  1. 宣言されたファイルの中でしか sealed class を継承することができない
  2. Constructor がデフォルトで private で、private でない constructor を持つことができない
  3. when 式と合わせて Union Type っぽくパターンマッチすることができる
    • when 式で検査対象に sealed class を渡して is <class> で分岐させると、その sealed class を継承したクラスだけ並べれば網羅されたことになり、 else を書く必要がない

上のコードでは times() は 3. によって関数内で when 式を用いて自身をパターンマッチにかけ、マッチしたクラスに計算を適用したものを返すように書くことで1つの式にまとめ上げることができ、 Money の関数として持ち上げることに成功している。パターンマッチのケースも Money を継承した DollarFranc だけでよい。このような方針で2017年現在の*1 Java でやろうとすると times() メソッド内では instanceof を用いた分岐フローが積み重なる嫌なコードになってしまうのでそんなアプローチを採用するわけにはいかず、まだ Money に持ってくることはできない。
また、上記以外にも 2. によって Money クラスを直接インスタンス化して使用することを防いでおり、ユーザが不用意な使用をできないようにもなった。これはこの一連の記事の最後で小話に使うかもしれない。

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] DollarFranc の重複
  • [x] equals の一般化
  • [ ] times の一般化 -> WIP

第7章 疑念をテストに翻訳する

DollarFranc を比較するという内容。ここも data class としてそれぞれを実装したため異なるクラスで比較しても失敗するようになっている。
ところでここでテストを追加する時、 Dollar(5) == Franc(5) と記述すると以下のメッセージと共にコンパイルエラーが発生することに気がつく。

Operator '==' cannot be applied to 'Dollar' and 'Franc'

これを Dollar(5).equals(Franc(5)) とするとコンパイルが通るし結果も本章で期待するものになる。== は前回書いたように a?.equals(b) ?: (b === null) の糖衣構文のようなものだがどうやら完全に同じ挙動をするわけではなく、直系の親子クラスでないとコンパイルレベルで受け付けてくれないようだ。気持ち悪いけど今回はここだけ as Money をつけるなり equals() を使うなりしておくことにする。

ここまでのまとめ

現在のコードの様子:

MoneyTest.kt

class MoneyTest {

    @Test
    fun testMultiplication() {
        val five = Dollar(5)
        assertEquals(Dollar(10), five.times(2))
        assertEquals(Dollar(15), five.times(3))
    }

    @Test
    fun testEquality() {
        assertTrue(Dollar(5) == Dollar(5))
        assertFalse(Dollar(5) == Dollar(6))

        assertTrue(Franc(5) == Franc(5))
        assertFalse(Franc(5) == Franc(6))

        assertFalse(Dollar(5).equals(Franc(5)))
    }

    @Test
    fun testFrancMultiplication() {
        val five = Franc(5)
        assertEquals(Franc(10), five.times(2))
        assertEquals(Franc(15), five.times(3))
    }
}

Money.kt

package money

data class Dollar(private val amount: Int) : Money(amount)
data class Franc(private val amount: Int) : Money(amount)

sealed class Money(private val amount: Int) {

    fun times(multiplier: Int) = when (this) {
        is Dollar -> Dollar(amount * multiplier)
        is Franc -> Franc(amount * multiplier)
    }
}

TODO リスト:

  • [ ] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 * 2 = $10
  • [x] amount を private に
  • [x] Dollar の副作用をどうする?
  • [ ] Money の丸め処理をどうする?
  • [x] equals()
  • [x] hashCode()
  • [x] null との等値性比較
  • [x] 他のオブジェクトとの等値性比較
  • [x] 5CHF * 2 = 10CHF
  • [ ] DollarFranc の重複
  • [x] equals の一般化
  • [ ] times の一般化 -> WIP
  • [x] FrancDollar を比較する
  • [ ] 通過の概念

今回は通貨のクラスを1つ増やし、それらに共通の親クラスを作って継承させるところまで進めた。第6章では書籍に載ってるコードの構成から離れて sealed class を用いた実装を行ってみた。今回は主な特徴の紹介程度の使い方ではあるが、その便利さから今後も使われることになるので覚えておいてほしい。

続き:

*1:Java もパターンマッチを実装する提案 (http://openjdk.java.net/jeps/305) が上がっていて、早くて 10 から実装されるかもしれない