nashcft's blog

時々何か書く。

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 から実装されるかもしれない