今回は第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
- [ ]
Dollar
とFranc
の重複 - [ ]
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
- [ ]
Dollar
とFranc
の重複 - [x]
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 の主な特徴は以下の通り:
- 宣言されたファイルの中でしか sealed class を継承することができない
- Constructor がデフォルトで private で、private でない constructor を持つことができない
- = インスタンス化できない
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 の場合) - [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
第7章 疑念をテストに翻訳する
Dollar
と 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 の場合) - [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
を比較する - [ ] 通過の概念
今回は通貨のクラスを1つ増やし、それらに共通の親クラスを作って継承させるところまで進めた。第6章では書籍に載ってるコードの構成から離れて sealed class を用いた実装を行ってみた。今回は主な特徴の紹介程度の使い方ではあるが、その便利さから今後も使われることになるので覚えておいてほしい。
続き:
*1:Java もパターンマッチを実装する提案 (http://openjdk.java.net/jeps/305) が上がっていて、早くて 10 から実装されるかもしれない