nashcft's blog

時々何か書く。

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 もできるのだが