nashcft's blog

時々何か書く。

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:と言ってもここは書籍の実装も振る舞い的には変わらないのでアドバンテージとして見られるかというとそうではない