前回
今回は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章で実装はともかく 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つ項目を追加して次の章に進む:
- [ ] $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()) } }
メソッド呼び出しでどの通貨かわかるようにしたいようなので、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") } }
第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章 不要になったら消す
もう 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
のリファクタリングが終わり、本題である通貨の足し算に進めるようになった。
- [ ] $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 として定義する方法もある