nashcft's blog

時々何か書く。

最近もやもや考えてる事

DroidKaigi 2018 が終わり続く三連休も過ぎ去って体調が低空飛行なりに業務に復帰しつつ考えてる事:

スプリント期間の短縮をしたい

今のチームというか会社では全体的に1スプリント2週間で回しているのだけど、土日を挟む事で勢いがリセットされてしまって2週目はいつもダレているような気がしている。(ダレてるのは) 自分だけかもしれないけど。
今週はその2週目にあたる週で、先週と比べて明らかにタスクの流れが滞っている。自分が持っていったタスクが一旦片付いたので昨日今日は勤務時間の半分くらいずつを使って溜まってしまっていたPRを全部レビューしてLGTMしたりコメントつけたりしたのだが、だいたい半分くらいしかその後のアクションが行われず、まあこれはうちのチームのPRとそのレビューがだらしない傾向にあるのも一因なのだけどなんだかなーという気持ちになったのでこのような事を考え出した。
それでスプリントを2週間ではなく1週間にすれば毎週新しいスプリントなので毎週リフレッシュされるしいいんじゃないかなーとか、計画とレビューが毎週発生するようになってMTGの時間がーという風にも考えられるがスプリントが短い分その中でこなすべきタスク量も2週間分と比較して少なくなって、MTG1回あたりの時間も短くなるんじゃないかとか、1週間でできるタスクの大きさに制限する事でより見積もりがしやすくなるんじゃないかとか考えていた。
とまあこの程度でまだ社内にうまく提案するまでまとまってないのでWIP。

PRとレビューがだらしない

上でもちょっと出たけどだらしないというのはどういうことかというと1つのPRが大きくなりがちだったりレビューもそれに対するリアクションも遅めで、典型的なコードレビューが開発のボトルネックになっている環境。
でかいPRというのは本当に厳しくて、まあレビュイーについてもコードを見て気持ちになってしまったというのはわかるのだけど見きれないよ... となり、レビューもだんだん雑になってきて結果的に無をしてマージ、みたいなことになる。
これは今ペアプロ・モブプロを導入しようとしている最中で、それで解決に持っていきたい。

会話の中で「話すべきこと」と「話したいこと」の分別がつけられずに発言すること

これは自分にも身に覚えがあるので自戒でもあるのだけど、相談事をしている最中に自分語りみたいなのを始めて相談や質問の回答が返ってくるまで数分以上かかったり、もしくはこちらから促さないと返ってこなかったり、真面目に議論している最中に突然脱線させて帰ってこなくなったりみたいなことが発生すると本当につらい。
これはやってしまっている本人は気づいてないというか、文脈を読み違えてしまった故の悲劇というか、意識しないと誰でもやってしまうように思われる。多分発生したら直接指摘するしかないのだと思うが、本人は自然に話をしているつもりなのだろうから忍びなく思ってしまう。故意だったら質が悪い。

年明けから今日まで

継続的に文章を書くという試みとはなんだったのかという様子ですね。とりあえず年が明けてからのことを書きます。

1月上旬〜中旬

年末年始休暇が終わっていきなり気分が沈み始め、業務の進捗も出なかったことが追い打ちになって完全に精神が終了していました。この時期Twitterではずっとダメになったとか終わってしまったとか呟いてたと思います。現実ではよくわからなかったのでリムスキーコルサコフ管弦楽法のテキストを買って読んでました。
ところでこの時期割と頻繁にチームの同僚に心情の吐露をしていたような気がするのですが、振り返ってみるとうちのケースにおいてはあまり良くなかったなあという反省があり、具体的には人の面倒を見ることに明るくない人間に感情に関する曖昧な共有をしてもただ困らせことになるだけだということです。この辺は同僚に対する愚痴ではなく弊チームの成熟度合いや体制プロセスetcと様々な要素が絡んでおり何かが悪いというより不運な事故が発生したという認識で、むしろ相手からしたら唐突に「私はしらみだ...」とだけ言ってくる同僚なんてどうしろってんだという感じだったでしょう。

1月下旬〜今日

下旬くらいになってからだんだん精神が自然回復してきて、業務の進捗も出るようになりました。そういえば弊社でもやっとAndroidアプリ開発にKotlinが導入されるようになってやっと文明の火を得たという心持ちです。
このくらいに昼食を外食からCOMPとプロテインミックスしたものに変えてみたり、『シリコンバレー式 自分を変える最強の食事』を読んで朝食をココナッツオイルコーヒー+αにするなどしてみたところ日中に頭がはっきりした状態を保てるようになって、それにつられて気分もだいぶ良くなりだいたい本調子に戻りました。最近はその辺が面白くて毎日食事の調整をしながら体調を観察して継続的に良い調子を保てる食事メニューを考えています。
最近はチームにペアプロ・モブプロを導入しようと色々やっています。弊チームは性格的にプルリクとコードレビューがだらしなくなりやすい (1つのプルリクが巨大になる、レビューが五月雨でマージされるまで時間がかかるなど) のでその辺の解決になるといいなーとか考えながら進めています。

とりあえず書きたいことをとにかく書き出そうという書き方をしたので脈絡のない乱文になりましたが何かしら文字に起こせて満足したのでいいやという所感です。溜めに溜めてビッグバンリリースをするから酷い事になるという様を表しているということにします。

ところで諸々のメモ書き的なものは使い勝手からScrapboxの方でやろうというということにしていて、こっちでは何かしら文章の体裁をとったものを投稿する時に使うことにしました。とはいえこっちもまだろくなこと書いていません。

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