前回
前回までは通貨を表すクラスをシンプルにしていくタスクが中心だったが、今回からやっと本題である通貨の足し算に手を着け始める。
現在のコード:
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
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)
第13章 実装を導くテスト
前章で実装した 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
を返す - [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()
の処理の実態が 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
を返す - [x]
Bank.reduce(Money)
- [ ]
Money
を変換して換算を行う [x]Reduce(Bank, String)
ここまでのまとめ
13章にきてあえて書籍の Java コードとは異なる設計 (というか表現?) で実装を書くようにした。これによってこの先の変更の難易度はどのように異なってくるのかや、この設計のメリットやデメリットについて見ていくようにしたい。また12章では Bank
についてクラスを作らず package-level function のみで同等のものを実装したが、これは状態を持つ必要のない機能群についてはあえて先にクラスという枠を作らずに関数の単位でポコポコ作っていって、あとで意味のあるかたまりとかでまとめておくみたいなボトムアップ的アプローチができるのかなーという風に感じた。実際どうなのだろうか。
続き: