新訳版『テスト駆動開発』の第Ⅰ部の多国通貨をネタに Smalltalk を学ぶシリーズの第6回です。同書と似た仕様を目指しますが、好みを優先する等の都合で写経にはなっていませんのでどうぞあしからず。(12/24追記:なるべく忠実な写経は別に書きました→こちら )
前回 は、Dollar、Franc を廃止して、代わりに具象クラス化した Money を使うことができるように整えました。
今回から、いよいよ当初の目的である異種通貨同士の加算の実現を目指します。ただ同書の流れからは前回まで以上に大きく外れて、換算レートのオブジェクト化(MoneyXRate, money exchange rate)とそれを用いた換算について考えてみます。
加えて次回以降の Sum についても同書で試された再帰的な実装ではなく、第17章でちょっとだけ触れられている別の実装(MoneyBag)をケント・ベックはこんな感じで考えていたのではないかな…と勝手な想像を巡らしながらこれを試してみる予定です。
#####*参考: 多国通貨実装のおおまかな流れ* 1. Dollar を定義 1. Dollar をコピーして Franc を定義 1. Dollar、Franc の重複を新しく作ったスーパークラスの Money にプルアップ 1. **Sum(Expression)、Bank を定義して reduce を実現** ← 今ここ
同書を読んでいない方向けに補足:Sum(当シリーズでは MoneyBag)は換算前の異種通貨同士の加算(式, expression)の保持を、Bank は異種通貨間の換算レートの管理、および、Sum の換算を伴う簡約(reduce)それぞれ担当します。
10 CHF * (1 USD / 2 CHF) で USD への換算は可能では?
同書は異種通貨加算を念頭にこれを保持する Sum から発想をスタートし、簡約(reduce)に付随する処理として換算(exchange)を考えています。
しかし、簡約時に換算が必須の異種通貨加算のことはいったん忘れて、たとえば 10 CHF を USD にレート(USD/CHF = 1/2)で換算する場合、
10 CHF * (1 USD / 2 CHF) "=> 5 USD "
という Money のみの乗除算で表現するのも面白そうだということに気がつきました。この方向で掘り下げてみましょう。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 10 CHF * (1 USD / 2 CHF) = 5 USD
いずれ 1 USD / 2 CHF が換算レートである MoneyXRate(厳密には~のインスタンス。他の回も含め、“~のインスタンス”は面倒なので断り無く省略していることがあります。こだわる向きはご容赦を…)を返すよう実装するのですが、その前に、数値で割った場合と同種通貨同士で割った場合は換算レートを返す必要はないので、普通に割り算の結果を返す実装にします。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 10 CHF * (1 USD / 2 CHF) = 5 USD
- 2 USD / 2 = 1 USD
- 2 USD / 1 USD = 2
まずテストから。
MoneyTest >> testDividedByNum
self assert: 2 USD / 2 equals: 1 USD
MoneyTest の ○印 をクリックして全テストを走らせるとレッド(エラー)になります。
Money>> / が未定義だからなのは明らかですが、念のための確認と、その道すがら Money>> / の定義を済ませてしまいたいので、テストメソッド一覧で唯一赤くなっている testDividedByNum の ○印 をクリックしてこのテストのみを走らせます。
はたしてノーティファイアーは Money>> - の未定義を告げて来ますので、Create で作ります。
定義先を問われるので Money を選択します。
プロトコルについては、arithmetic とタイプして OK します。なお、/ のようなよく使うメソッドは as yet classified(未分類)のまま OK しておいて、あとでプロトコルリスト枠の右クリック → Categorize all uncategorized で自動分類も可能です。
self shouldBeImplemented という内容のスタブメソッドが定義され、そのコンテキストでデバッガーが開くので、当該メソッドをテストをイエロー(失敗)に変えるために空のメソッド(あるいは self を返すメソッド)に書き換えてコンパイル(Accept)します。なお、仮引数は aMoney から divisor に変更しました。なお、ここで提示するコードの冒頭の クラス名 >> の部分はメソッドのコードの入力(あるいはコピペ)の際は含める必要はありません。念のため。
Money >> / divisor
デバッガーを閉じて、再び MoneyTest の全テストを走らせる(○印 クリック)と、バーはレッド(エラー)からイエロー(失敗)に変わっているはずです。
改めて、Money>> - を呼び出し、今度はテストがグリーン(成功)になるように 1 USD を返すコードに内容を変えてコンパイルします(仮実装)。^ は Smalltalk ではリターンです。
Money >> / divisor
^ 1 USD
テストの結果がグリーンになることを確認して、重複を排除します(リファクタリング)。
これからいろいろな divisor が来るので、とりあえず予防的に Money 以外が divisor に来た場合はエラーになるようにもしておきましょう。
Money >> / divisor
divisor isNumber
ifTrue: [ ^ amount / divisor perform: currency ].
self error: 'unsupported divisor'
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 10 CHF * (1 USD / 2 CHF) = 5 USD
- 2 USD / 2 = 1 USD
- 2 USD / 1 USD = 2
次は同種通貨同士の割り算です。
MoneyTest >> testDividedBySameCurrency
self assert: 2 USD / 1 USD equals: 2
テストはエラーで、testDivideSameCurrency のみに走らせて確認すると、先ほど設置したエラーだと分かります。
Mnney>> / を Number の divisor にも対応できるようにこのように変えます。
Money >>/ divisor
divisor isNumber
ifTrue: [ ^ amount / divisor perform: currency ].
divisor currency == currency
ifTrue: [ ^ amount / divisor amount ].
self error: 'unsupported divisor'
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 10 CHF * (1 USD / 2 CHF) = 5 USD
- 2 USD / 2 = 1 USD
- 2 USD / 1 USD = 2
isNumber を真似て isMoney を仕込む
isNumber というのは Ojbect と Number に次のようにシンプルな実装で実現されている testing メソッドです。Money>> / コード内の isNumber を選択して 右クリック → Code search ... → implementors of it で定義先を一覧として呼び出せます。
Object >> isNumber
"Overridden to return true in Number, natch"
^ false
Number >> isNumber
^ true
オープンクラスかそれに準ずる機能(組み込みのを含めて既存のクラスに手を加えられる)を持つ言語限定で、なおかつ静的型ファンからすれば、動的型言語のつまらないバッドノウハウかも…ですが、この testing メソッドは、いちいち ~ class == クラス名 などとしなくてよいので便利です。(ただし乱用は禁物か。)
あると何かと便利そうなので Money にも用意してみましょう。まずテストを書きます。
MoneyTest >> testIsMoney
self deny: 1 isMoney.
self assert: 1 USD isMoney
MoneyTest の全テストを走らせるとエラーになるので、赤くなった testIsMoney のみ走らせて isMoney を Create します。デバッガー内からは複数箇所に同時には同名メソッドは定義できないので、とりあえずここでは定義先を Object、プロトコルは(本来は testing なのですが、現在作業中の TDD-Money パッケージに含めたいので)*TDD-Money にします。あたまの * をお忘れなく。
Object >> isMoney
^ false
デバッガーを閉じて、クラスブラウザを見ると、クラスリスト枠に Object が追加され、さらに *TDD-Money プロトコルに isMoney も見つかるはずです。
Money>>isMoney も忘れずに作成しておきます。こちらは本来の testing プロトコルに分類します。
まず Object から Money に切り替えて、そのプロトコルリスト枠で 右クリック → Add Protocol... を選択し、testing とタイプして入力して OK を押します。
改めて下のコード枠に次のコードを入力してコンパイル(Accept)します。
Money >> isMoney
^ true
これでテストは通るはずです。
異種通貨同士の除算で MoneyXRate を生成
さて、次は本題の異種通貨同士の除算の場合です。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 10 CHF * (1 USD / 2 CHF) = 5 USD
- 2 USD / 2 = 1 USD
- 2 USD / 1 USD = 2
- 1 USD / 2 CHF は MoneyXRate を返す
テストを書きます。
MoneyTest >> testDivideDifferentCurrencyReturnsMoneyXRate
| aMoneyXRate |
aMoneyXRate := 1 USD / 2 CHF.
self assert: aMoneyXRate class equals: MoneyXRate
コンパイルしようとすると、MoneyXRate が未宣言であることをコンパイラーに咎められるので Define new class を選択します。MoneyXRate のクラス定義式のテンプレートが提示されますがそのまま OK します。
Object subclass: #MoneyXRate
instanceVariableNames: ''
classVariableNames: ''
package: 'TDD-Money'
MoneyXRate がクラスブラウザに追加され、testDivideDifferentCurrencyReturnsMoneyXRate のコンパイルが通ったら、MoneyTest の全テストを走らせます。
結果はエラーですが、Money>> / のサポートしない divisor を告げるために設置したものなので問題ありません。
テストを成功させる仮実装に変えて Money>> / をコンパイルしなおします。
Money >> / divisor
divisor isNumber
ifTrue: [ ^ amount / divisor perform: currency ].
divisor isMoney
ifFalse: [ ^ self error: 'unsupported divisor' ].
^ divisor currency == currency
ifTrue: [ amount / divisor amount ]
ifFalse: [ MoneyXRate new ]
テストの結果はグリーンです。
さて、MoneyXRate には「どの通貨からどの通貨への換算時か?(fromToCurrency)」と「そのレートは?(xRate)」の2つの情報を保持させます。
前回同様、set で始まるコンストラクターパラメーターメソッド(ベパp27)を private プロトコルに設け、MoneyXRate のインスタンス生成時に使いましょう。
MoneyXRate >> setFromToCurrency: anAssociation xRate: aNumber
fromToCurrency := anAssociation.
xRate := aNumber
この MoneyXRate>>setFromToCurrency:xRate: をコンパイル(Accept)をしようとすると、fromToCurrency、xRate それぞれについて未宣言であることをコンパイラーに指摘されますので、つど Declare new instance variable を選んで宣言を代行してもらいます。
MoneyXRate>>setFromToCurrency:xRate: のコンパイルが通ったら、それをコールするように Money>> / も書き換えます。
Money >> / divisor
divisor isNumber
ifTrue: [ ^ amount / divisor perform: currency ].
divisor isMoney
ifFalse: [ ^ self error: 'unsupported divisor' ].
^ divisor currency == currency
ifTrue: [ amount / divisor amount ]
ifFalse: [ MoneyXRate new
setFromToCurrency: divisor currency -> currency
xRate: amount / divisor amount ]
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 10 CHF * (1 USD / 2 CHF) = 5 USD
- 2 USD / 2 = 1 USD
- 2 USD / 1 USD = 2
- 1 USD / 2 CHF は MoneyXRate を返す
MoneyXRate を使った通貨換算
では次にてこの MoneyXRate を使って通貨換算をしてみましょう。
テストを書きます。
MoneyTest >> testExchangeMoney
| aMoneyXRate |
aMoneyXRate := 1 USD / 2 CHF.
self assert: 10 CHF * aMoneyXRate equals: 5 USD
テストはエラー(レッド)です。改めて testExchangeMoney を走らせて原因を探ると、Money>> * からおかしな方向へ処理が進んでしまっていることが分かります。multiplier が Number 以外を想定していないこと(エラーを出すことを含め)が原因のようですね。
Money>> * を書き換えて対処します。仮実装も済ませてしまいましょう。
Money >> * multiplier
multiplier isNumber ifTrue: [ ^ (amount * multiplier) perform: currency ].
^ 5 USD
テストを走らせてグリーンを確認してから重複を排除します。換算したい通貨(currency)と換算レートの分母(内部的には fromToCurrency の key)が一致しているかもここでチェックしておく必要がありそうです。
Money >> * multiplier
multiplier isNumber
ifTrue: [ ^ amount * multiplier perform: currency ].
self assert: multiplier fromCurrency == currency.
^ amount * multiplier xRate perform: multiplier toCurrency
未定義のメソッドをいろいろコールしてしまっているので、テストは失敗します。
赤くなった testExchangeMoney のみを走らせて、未定義を指摘されたメソッドを順に定義してゆきましょう。
未定義を指摘されるのは順に fromCurrency 、xRate 、toCurrency の3つのメソッドです。いずれも定義先はもちろん MoneyXRate で、プロトコルは accessing にして次のように定義します。
MoneyXRate >> fromCurrency
^ fromToCurrency key
MoneyXRate >> xRate
^ xRate
MoneyXRate >> toCurrency
^ fromToCurrency value
一つの定義が終わったら、デバッガーを閉じて再度 testExchangeMoney を走らせてもよいですし、デバッガーを閉じずにそのまま Proceed(デバッガーウインドウのタイトルバーすぐ下に並んでいるボタン列の左端にある)しても構いません。
toCurency 定義後に勢い余って Proceed してしまうとスレッド(テストの run )が終了し、あらかじめ用意されていたレッドバーが出てしまいますが、もう一度 testExchangeMoney か MoneyTest の ○印 をクリックすればちゃんとグリーンが確認できます。
どうやら Pharo の SUnit は、ここで多用しているいわゆる“デバッガー駆動開発”※でレッドやイエローが修正されることを想定していないようです。なんだかちょっと残念ですね。
※ 目指す Smalltalk 式を、そこでコールするメソッドが定義済みか否かは問わず記述・評価し、その後ノーティファイアーで未定義を指摘されるメソッドを順にデバッガー内で定義 → Proceed を繰り返し、最後にエラーが全て解消してデバッガーを抜けるとコードが完成している…、そんな Smalltalk お家芸とも言える特殊な開発サイクル体験をこう呼ぶことがあります。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 10 CHF * (1 USD / 2 CHF) = 5 USD
- 2 USD / 2 = 1 USD
- 2 USD / 1 USD = 2
- 1 USD / 2 CHF は MoneyXRate を返す
MoneyXRate>>printOn: 、 = 、hash 、の整備
最後に MoneyXRate を値として扱うために再定義すべきメソッドをいくつか片付けておきましょう。
まず printOn: の再定義から済ませましょう。プロトコルは printing です。
MoneyXRate >> printOn: aStream
aStream
print: xRate;
space;
nextPutAll: self toCurrency;
nextPutAll: ' / ';
print: 1;
space;
nextPutAll: self fromCurrency
これでノーティファイアなどの表示が分かりやすくなります。
改めて、MoneyXRate>> = 、hash の定義は以下のとおりです。いずれもプロトコルは comparing です。
MoneyXRate >> = other
^ other class == self class
and: [ other fromToCurrency = fromToCurrency and: [ other xRate = xRate ] ]
MoneyXRate >> hash
^ fromToCurrency hash bitXor: xRate hash
ついでというわけではないですが、第3回で気になった Money>>hash も直してしまいましょう。
Money >> hash
^ currency hash bitXor: amount hash
#ここまでのまとめと補足
- 演算子のオーバーロード(Smalltalk には演算子というものはないので、ごく普通に二項メソッドの追加というだけですが…)はスポっとはまると気持ちいい反面、落とし穴もあるので使うときは慎重にしないといけないようです。
- 調子に乗って通貨の単位を含めた分数(MoneyFraction?)まで実装したくなりますが、物理計算のように 1 / USD2 みたいな値に意味ががあるわけではないのと、典型的 YAGNI なのでやめておくのが吉のようです。
- Pharo の SUnit フレームワークは、意外や Smalltalk のお家芸である“デバッガー駆動開発”を想定していないため、せっかくの作業の流れを分断される辛みがある(正しい色のバーを出すには、デバッガーを閉じてから再度テストを走らせないといけない)。
- isNumber のような testing メソッドは、実装がとてもシンプルで重宝しますが、多用しないよう要注意です。
- パッケージに含まれていないクラスへのメソッド追加でも、プロトコルを「*パッケージ名」にしておくことでパッケージに含めることが可能。反面、本来の相応しいプロトコルにできないのでメソッドを探すときに少し困ります。
- MoneyXRateの等価性テストしていなくて、MoneyXRate>>fromToCurrency の実装を忘れているのに気付きませんでした。すみません。(次回やります)
#この時点のコード
ファイルアウト形式のソースです。(この形式で Smalltalk のソースを読むことはあまり推奨されません。為念)
Pharo では、いったんこのコードをテキストファイルに保存してから、そのファイルを Pharo 内から Tools → File Browser で開き、Filein ボタンをクリックすると読み込めます。
Squeak でも読み込みや実行が可能なコードにもなっています。Pharo 同様に File List で開くか、デスクトップクリック → Workspace で開いたワークスペースなどにコピペし、alt/cmd + shift + g などで file it in するとクラスブラウザから閲覧したり、デスクトップクリック → Test Runner でテストも試せます(ただし使い勝手は Pharo に比べるとかなり落ちます^^;)。
!Number methodsFor: '*TDD-Money' stamp: 'sumim 12/7/2017 18:16'!
USD
^ Money amount: self currency: #USD! !
!Number methodsFor: '*TDD-Money' stamp: 'sumim 12/7/2017 18:16'!
CHF
^ Money amount: self currency: #CHF! !
!Object methodsFor: '*TDD-Money' stamp: 'sumim 12/14/2017 18:03'!
isMoney
^ false! !
Object subclass: #Money
instanceVariableNames: 'amount currency'
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Money methodsFor: 'accessing' stamp: 'sumim 12/7/2017 17:32'!
currency
^ currency! !
!Money methodsFor: 'accessing' stamp: 'sumim 12/5/2017 17:28'!
amount
^ amount! !
!Money methodsFor: 'arithmetic' stamp: 'sumim 12/14/2017 18:06'!
* multiplier
multiplier isNumber
ifTrue: [ ^ amount * multiplier perform: currency ].
self assert: multiplier fromCurrency == currency.
^ amount * multiplier xRate perform: multiplier toCurrency! !
!Money methodsFor: 'arithmetic' stamp: 'sumim 12/14/2017 18:05'!
/ divisor
divisor isNumber
ifTrue: [ ^ amount / divisor perform: currency ].
divisor isMoney
ifFalse: [ ^ self error: 'unsupported divisor' ].
^ divisor currency == currency
ifTrue: [ amount / divisor amount ]
ifFalse: [ MoneyXRate new
setFromToCurrency: divisor currency -> currency
xRate: amount / divisor amount ]! !
!Money methodsFor: 'comparing' stamp: 'sumim 12/7/2017 17:50'!
= other
^ other currency == currency and: [ other amount = amount ]! !
!Money methodsFor: 'comparing' stamp: 'sumim 12/14/2017 18:08'!
hash
^ currency hash bitXor: amount hash! !
!Money methodsFor: 'printing' stamp: 'sumim 12/7/2017 18:08'!
printOn: aStream
aStream print: amount; space; nextPutAll: currency! !
!Money methodsFor: 'private' stamp: 'sumim 12/7/2017 17:41'!
setAmount: aNumber currency: aSymbol
amount := aNumber.
currency := aSymbol! !
!Money methodsFor: 'testing' stamp: 'sumim 12/14/2017 18:04'!
isMoney
^ true! !
!Money class methodsFor: 'instance creation' stamp: 'sumim 12/7/2017 18:15'!
amount: aNumber currency: aSymbol
^ self new setAmount: aNumber currency: aSymbol; yourself! !
Object subclass: #MoneyXRate
instanceVariableNames: 'fromToCurrency xRate'
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!MoneyXRate methodsFor: 'accessing' stamp: 'sumim 12/14/2017 18:07'!
toCurrency
^ fromToCurrency value! !
!MoneyXRate methodsFor: 'accessing' stamp: 'sumim 12/14/2017 18:06'!
fromCurrency
^ fromToCurrency key! !
!MoneyXRate methodsFor: 'accessing' stamp: 'sumim 12/14/2017 18:06'!
xRate
^ xRate! !
!MoneyXRate methodsFor: 'comparing' stamp: 'sumim 12/14/2017 18:08'!
= other
^ other class == self class
and: [ other fromToCurrency = fromToCurrency and: [ other xRate = xRate ] ]! !
!MoneyXRate methodsFor: 'comparing' stamp: 'sumim 12/14/2017 18:08'!
hash
^ fromToCurrency hash bitXor: xRate hash! !
!MoneyXRate methodsFor: 'printing' stamp: 'sumim 12/14/2017 18:08'!
printOn: aStream
aStream
print: xRate;
space;
nextPutAll: self toCurrency;
nextPutAll: ' / ';
print: 1;
space;
nextPutAll: self fromCurrency! !
!MoneyXRate methodsFor: 'private' stamp: 'sumim 12/14/2017 18:05'!
setFromToCurrency: anAssociation xRate: aNumber
fromToCurrency := anAssociation.
xRate := aNumber! !
TestCase subclass: #MoneyTest
instanceVariableNames: ''
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/7/2017 17:28'!
testCurrency
self assert: 5 USD currency equals: #USD.
self assert: 5 CHF currency equals: #CHF! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/14/2017 18:04'!
testDivideDifferentCurrencyReturnsMoneyXRate
| aMoneyXRate |
aMoneyXRate := 1 USD / 2 CHF.
self assert: aMoneyXRate class equals: MoneyXRate! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/14/2017 18:00'!
testDividedByNum
self assert: 2 USD / 2 equals: 1 USD! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/14/2017 18:02'!
testDividedBySameCurrency
self assert: 2 USD / 1 USD equals: 2! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/5/2017 12:55'!
testEquality
self assert: 5 USD = 5 USD.
self assert: 5 USD ~= 6 USD.
self assert: 5 CHF = 5 CHF.
self assert: 5 CHF ~= 6 CHF.
self assert: 5 USD ~= 5 CHF! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/14/2017 18:05'!
testExchangeMoney
| aMoneyXRate |
aMoneyXRate := 1 USD / 2 CHF.
self assert: 10 CHF * aMoneyXRate equals: 5 USD! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/7/2017 17:30'!
testFiveCHFReturnsAMoney
self assert: 5 CHF class equals: Money! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/7/2017 17:30'!
testFiveUSDReturnsAMoney
self assert: 5 USD class equals: Money! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/14/2017 18:03'!
testIsMoney
self deny: 1 isMoney.
self assert: 1 USD isMoney! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/5/2017 12:56'!
testMultiplication
self assert: 5 USD * 2 equals: 10 USD.
self assert: 5 CHF * 2 equals: 10 CHF! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/7/2017 17:28'!
testPrintString
self assert: 5 USD printString equals: '5 USD'.
self assert: 5 CHF printString equals: '5 CHF'! !