記事のまとめ
Part I
1~4章
5~7章
8~11章
12~13章
14~16章
Part II
まだ
記事のまとめ
1~4章
5~7章
8~11章
12~13章
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 + $5
が Money
を返すBank.reduce(Money)
Money
を変換して換算を行うReduce(Bank, String)
この章ではまず「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 の場合)$5 + $5 = $10
$5 + $5
が Money
を返すBank.reduce(Money)
Money
を変換して換算を行うReduce(Bank, String)
この章で 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 リストを更新しておしまい:
$5 + 10CHF = $10
(レートが 2:1 の場合)$5 + $5 = $10
$5 + $5
が Money
を返すBank.reduce(Money)
Money
を変換して換算を行うReduce(Bank, String)
Sum.plus
Expression.times
更新内容をみてわかるが、 Expression
に plus()
を持ってきた時点で Sum
もこの関数を使えるようになっているので完了してしまっている。
さて前章で 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.times
を Expression
に持ってきて、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 + $5
が Money
を返す」だが、結果的に何もしないことになるので、これで第16章が終わる。
$5 + 10CHF = $10
(レートが 2:1 の場合)$5 + $5 = $10
$5 + $5
が Money
を返すBank.reduce(Money)
Money
を変換して換算を行うReduce(Bank, String)
Sum.plus
Expression.times
最終的な実装を以下に示す。差分を書き直すのが面倒なので GitHub のレポジトリと合わせるためにテストクラスに雑なフィクスチャー (っぽいもの) を作ったりMoney.currency
を enum 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とはそこまで関係ないなーと今になって思ったのでこれだけ。最後に currency
を enum class
にしたのはやっぱそっちの方が楽だよねという気持ちがあって、でも最後にやっても何の恩恵にもあずかれないのであった。
Part II 以降については別にやりたいことを進めているため、読み進めているだけで写経は一旦お休みという状態である。こっちも写経の様子をブログにできたらとは思っている。
前回
前回までは通貨を表すクラスをシンプルにしていくタスクが中心だったが、今回からやっと本題である通貨の足し算に手を着け始める。
現在のコード:
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") } }
まずは長くなった 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
Money
の plus()
関数の返却値型を 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)
前章で実装した plus()
の重複を取り除くために先へ進める。まずは TODO の追加から:
$5 + 10CHF = $10
(レートが 2:1 の場合)$5 + $5 = $10
$5 + $5
が Money
を返す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.amount
の private
を外す:
data class Money(val amount: Int, val currency: String) : Expression { // ... }
ここで TODO を1つ追加:
$5 + 10CHF = $10
(レートが 2:1 の場合)$5 + $5 = $10
$5 + $5
が Money
を返す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 + $5
が Money
を返すBank.reduce(Money)
Money
を変換して換算を行うReduce(Bank, String)
さて、単に写経をするだけならこれで第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
だけが処理の詳細を知っていればよいのでは?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()
の処理の実態が Money
と Sum
に散ってしまっているのを解決しようとして、書いてある通り Expression
を sealed class としてそれのサブクラスが持つ reduce()
の実装を Bank.reduce()
内で when
を使ってまとめた。 これは最後に TODO に追加した Reduce(Bank, String)
のように Bank
をわざわざ連れまわすこともなくなるし、 "reduce" という処理を見るときにBank
だけ見れば済むのでよいというところが私には嬉しい。
この辺は手続き的な分岐じゃなくて宣言的な検査だから〜など説いて書籍の実装のような操作対象のオブジェクトに振る舞いを持たせる設計と今回のパターンマッチングを用いた設計を比較してどうのこうの書こうと思ったが、今回使ったようなレベルのパターンマッチングと if
文などによる分岐の差*1とは...? となり、そもそも私がパターンマッチングについて十分な理解をしていないなあと思ったので不用意なことは書かないことにする。あえて何か書くとしたら、上で変更した後の実装の方が自分のものの整理のしかたと合ってて好ましいので可能ならばそちら側に倒す実装をするが、とはいえ Java のようにシステム上それを安全に実装することができない環境下であればそちらの流儀に合わせるだろうなあ、という所感くらい。
ところで Expression
のサブクラスについて Sum
を Expression
の内側で宣言しているのは、具体的なものを表す 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
を返すので、返ってきた Money
の amount
を足し合わせて返すことで大元の 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 + $5
が Money
を返すBank.reduce(Money)
Money
を変換して換算を行うReduce(Bank, String)
13章にきてあえて書籍の Java コードとは異なる設計 (というか表現?) で実装を書くようにした。これによってこの先の変更の難易度はどのように異なってくるのかや、この設計のメリットやデメリットについて見ていくようにしたい。また12章では Bank
についてクラスを作らず package-level function のみで同等のものを実装したが、これは状態を持つ必要のない機能群についてはあえて先にクラスという枠を作らずに関数の単位でポコポコ作っていって、あとで意味のあるかたまりとかでまとめておくみたいなボトムアップ的アプローチができるのかなーという風に感じた。実際どうなのだろうか。
続き:
別段忙しくてニャーンであるとかそういう状態ではないのだけど全然日記的なもの書けてなかったり GitHub ガーデニングも真っ白殺風景になってたりしていますが私は元気です。
何していたかというと会社では毎日無理ない程度に進捗を出してるだけなのに帰宅すると疲れ果ててひたすら寝るということをしていたり起きている間は ÉKRITS Books I 読んでたりしていて、先月色々あった分の疲れを身体が認識し始めたのかなーと適当に考えて静かに過ごしているというかそうしかできないというか。
そういえば Packt Publishing がセールで電子書籍を $5 で売ってて、まだやってるっぽいので気になるけど Packt の本のクオリティに不安があって躊躇してる的なのがあったらいいタイミングだと思う。
私はというと気がついたら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) } }
この章ではまず times()
関数を纏める準備として返却値の型を Money
に変更しているが、これは既に第6章で実装はともかく Money
に times()
関数を移すところまで達成している。
次は 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) } }
テストの方は直接 Dollar
や Franc
のコンストラクタを呼んでいた部分が factory method に置き換わっただけなので割愛する。
この変更で直接 Money
のサブクラスを呼ぶ必要がなくなり、これらを削除する準備が進んだ。
TODO リストに1つ項目を追加して次の章に進む:
testFrancMultiplication
を削除する?この章ではこれまでサブクラスで表していた通貨の概念を別の方法で表現することで、サブクラスを消す準備を万全にしていく。
testFrancMultiplication
を削除する?この章は同じことを表現するのにやや寄り道っぽいことをしていて既読組からしたら冗長に感じるかもしれないが、「過程を眺める」のがこのシリーズの動機なので、章のタイトルになっている「歩幅の調整」の様子も残しておきたい。
まずはテストの追加。
class MoneyTest { // ... @Test fun testCurrency() { assertEquals("USD", dollar(1).currency()) assertEquals("CHF", franc(1).currency()) } }
メソッド呼び出しでどの通貨かわかるようにしたいようなので、Money
に currency()
関数を追加する。書籍の方ではサブクラスにも実装を追加しているが、私の実装では 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") } }
この章の目標は 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()
を自分で実装してないしどうしようもないのでパスした。
もう Dollar
と Franc
は必要なくなったので削除し、直接 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
のリファクタリングが終わり、本題である通貨の足し算に進めるようになった。
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 として定義する方法もある
週の頭に今週これやろうとメモってたことに対する振り返り
やってない 😇
来週末までに終わらせる。
第I部の終盤だし1章1章が結構かかるかなーという予想に反して分量が少なかったので勢いで進めてたら第I部終わった。
7章まで書いた:
レポジトリを作ってプロジェクトのセットアップをするところまでは終わらせた。Chapter 1 の内容に対する進捗としては半分。
欲を出してこっちも Kotlin でやろうとして色々調べていたら時間がかかってしまった。
今回は第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) }
これまでは1種類の通貨に関して注目していたが、この章から本題となる「多国通貨の足し算」を行えるようにするための準備が始まる。
まずはもう1つの通貨としてフラン (Franc) を追加し、これまで扱ってきたUSドルと同じように動作させたい:
$5 + 10CHF = $10
(レートが 2:1 の場合)$5 * 2 = $10
amount
を private にDollar
の副作用をどうする?Money
の丸め処理をどうする?equals()
hashCode()
null
との等値性比較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 の場合)$5 * 2 = $10
amount
を private にDollar
の副作用をどうする?Money
の丸め処理をどうする?equals()
hashCode()
null
との等値性比較Dollar
と Franc
の重複equals
の一般化times
の一般化この章では前章で作ってしまった重複を消していくために、まず共通の親クラスを作りそれを継承して equals()
の一般化を行う、という進め方をする。
ところで私は Dollar
, Franc
とも data class として実装したので、equals()
を自分で実装しておらず共通化するものがないのだった。
$5 + 10CHF = $10
(レートが 2:1 の場合)$5 * 2 = $10
amount
を private にDollar
の副作用をどうする?Money
の丸め処理をどうする?equals()
hashCode()
null
との等値性比較Dollar
と Franc
の重複equals
の一般化times
の一般化とはいえ後のことも考えて、書籍の内容に則り Money
クラスを作成し Dollar
と Franc
に継承させ、ついでに本章で追加される 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 の主な特徴は以下の通り:
when
式と合わせて Union Type っぽくパターンマッチすることができる
when
式で検査対象に sealed class を渡して is <class>
で分岐させると、その sealed class を継承したクラスだけ並べれば網羅されたことになり、 else
を書く必要がない上のコードでは times()
は 3. によって関数内で when
式を用いて自身をパターンマッチにかけ、マッチしたクラスに計算を適用したものを返すように書くことで1つの式にまとめ上げることができ、 Money
の関数として持ち上げることに成功している。パターンマッチのケースも Money
を継承した Dollar
と Franc
だけでよい。このような方針で2017年現在の*1 Java でやろうとすると times()
メソッド内では instanceof
を用いた分岐フローが積み重なる嫌なコードになってしまうのでそんなアプローチを採用するわけにはいかず、まだ Money
に持ってくることはできない。
また、上記以外にも 2. によって Money
クラスを直接インスタンス化して使用することを防いでおり、ユーザが不用意な使用をできないようにもなった。これはこの一連の記事の最後で小話に使うかもしれない。
$5 + 10CHF = $10
(レートが 2:1 の場合)$5 * 2 = $10
amount
を private にDollar
の副作用をどうする?Money
の丸め処理をどうする?equals()
hashCode()
null
との等値性比較Dollar
と Franc
の重複equals
の一般化times
の一般化 -> WIPDollar
と Franc
を比較するという内容。ここも 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 の場合)$5 * 2 = $10
amount
を private にDollar
の副作用をどうする?Money
の丸め処理をどうする?equals()
hashCode()
null
との等値性比較Dollar
と Franc
の重複equals
の一般化times
の一般化 -> WIPFranc
と Dollar
を比較する今回は通貨のクラスを1つ増やし、それらに共通の親クラスを作って継承させるところまで進めた。第6章では書籍に載ってるコードの構成から離れて sealed class を用いた実装を行ってみた。今回は主な特徴の紹介程度の使い方ではあるが、その便利さから今後も使われることになるので覚えておいてほしい。
続き:
*1:Java もパターンマッチを実装する提案 (http://openjdk.java.net/jeps/305) が上がっていて、早くて 10 から実装されるかもしれない