新訳版『テスト駆動開発』の第Ⅰ部の多国通貨をネタに Smalltalk を学ぶシリーズの第3回です。同書と似た仕様を目指しますが、好みを優先する等の都合で写経にはなっていませんのでどうぞあしからず。(12/24追記:なるべく忠実な写経は別に書きました→こちら )
前回 は Dollar の仕様を整えました。今回はその Dollar をまるごとコピーして Franc を生成し、Dollar 同様に Franc としてきちんと機能するように整えます。まずそのまえに前回の積み残しから。
#####*参考: 多国通貨実装のおおまかな流れ* 1. Dollar を定義 1. **Dollar をコピーして Franc を定義** ← 今ここ 1. Dollar、Franc の重複を新しく作ったスーパークラスの Money にプルアップ 1. Sum(Expression)、Bank を定義して reduce を実現
hash と printOn: の再定義を済ませる
等価性チェックの = とペアで再定義することが強く推奨される hash と、文字列表現を司る printOn: を Dollar に再定義(オーバーライド)します。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD が Dollar を返す
- 5 USD = 5 USD
- 5 USD ~= 6 USD
- インスタンス変数 amount を定義
- amount のアクセッサーメソッド(amount 、amount: ) を追加
- hash の再定義
- printOn: の再定義
ケント・ベックが 0 を返すひどい仮実装で通していたので、簡単のためこれを踏襲します(ぉぃ!)。
ちなみに hash は、等価なら同じハッシュ値を返すことが必須で、あとは適度にばらけた値を返すのが望ましいようです。普通に実装するなら、amount の hash か、Franc との差別化を考えて、付加情報の 'USD' やクラス名の 'Dollar' の hash と amount hash の XOR をとるのがよいかもしれません。
= と同じプロトコルなので Dollar → comparing の順にクリックして選択後、下のコード枠にメソッド定義のテンプレートが表示されるのを確認してから、次のコードに置き換えてコンパイルします。
Dollar >> hash
^ 0
同様に、オブジェクトの文字列表現がデフォルトの a Dollar のままではデバッグ等で不便なので、amount という状態を持ったこのタイミングでこちらも再定義しておきましょう。
まずテストを書きます。MoneyTest → tests の順にクリックして選択し、次のコードをテンプレートに置き換えるかたちで下のコード枠に入力しコンパイルします。
MoneyTest >> testPrintString
self assert: 5 USD printString equals: '5 USD'
MoneyTest の ○印 をクリックしてテスト失敗(イエロー)を確認します。
失敗した testPrintString の ○印をクリックすると TestFailure: Got 'a Dollar' instead of '5 USD' というノーティファイアが現れます。
気のせいか、これまでよりエラー表示が丁寧になったように感じるのは、Pharo のサジェスチョンに従って assert: の代わりに assert:equals: を使ったからのようです。Debug ボタンを押してデバッガーを起動したときも、一番下に比較を並べて表示してくれる親切仕様になっています。
ちょうど実行中の式のメッセージ部分が選択状態にあることですし、ここで少し脱線して、この「assert: 5 USD printString equals: '5 USD'」というメッセージを送ることでコールされるメソッド(assert:equals:)の定義がどうなっているかを確認してみましょう。
デバッガー内のコード枠内で右クリック → Code search... → implementors of it を選択すると選択範囲をメッセージとする(引数を含んでいても大丈夫です)メソッドの一覧が表示されます。
あいにく同名のメソッドが複数存在しますが※、TAssertable トレイト(トレイトは、クラスに use させて使うメソッド集合を表わす言語機能で特に Pharo では積極的に活用されている)に定義されている assert:equals: が該当するメソッドのようです。
同様に、assert: という文字列(assert:equals: の一行目のメッセージパターンの部分や assert:description: コールのための記述の一部でも、新たにタイプして入力したテキストでも assert: という文字列なら何でも OK。それをコールするリーガルなコードの一部である必要は全くない。為念)を選択した状態で implementors of it(Ctrl + M)すると、TAssertable>>assert: の定義も見ることができます。assert:equals: と何が違うか比べてみると面白いと思います。
ただ、Smalltalk 環境のコード非実行時解析機能(Implementors of it、Senders of it 等々)は動的型言語の中ではかなり充実している方ですが、たとえそれらを駆使しても動的型コードの動きを実行せずに追っかける作業は(今回のように同名メソッドが引っかかってきやすいなど)いろいろしんどいことがあるので、可能であればデバッガーによる実行時情報も適宜活用することを強くお薦めします。
※たとえば今回の例なら、TestFailure: Got 'a Dollar' instead of '5 USD' のノーティファイアから起動(Dubug ボタンをクリック)したデバッガーで、コンテキストを初期化( Restart ボタンをクリック)してから、Over → Over → Into で TAssertable(実際はそれを use した TestAsserter)>>assert:equals: に迷わずたどり着くことができます。デバッガーの簡単な使い方はこの続きで。
そんなわけで脱線ついでに、testPrintString に書いた printString がどうして printOn: に関係するのか疑問に思われる向きもあるでしょうから、デバッガーの機能を紹介がてら printString の動きも追ってみましょう。
先ほどのデバッガー内か、あるいはすでにデバッガーを閉じてしまったならクラスブラウザの MoneyTest>>testPrintString の定義の画面でもかまいませんので 5 USD printString を選択して右クリック → Debug it を実行します。
すると新たにデバッガーが起動します。
USD メッセージは今は興味がないので、Over か Through し、printString メッセージがハイライトしたら Into ボタンをクリックします。すると、Object>>printString の実装(とその実行コンテキスト)に表示が切り替わります。
さらに Into をクリックすると、Object>>printStringLimitedTo: の実行コンテキストに切り替わり、ここでようやく printOn: がコールされていることが分かります。
そんなわけで、printOn: を再定義した結果は printString を介して、より簡単に確認できるというわけです。
閑話休題。肝心の文字列表現の実装に戻りましょう。
短い式(Dollar なら、数値に USD というメッセージを送る)で生成でき、それが当該インスタンスの特徴を必要十分に表せている場合は、その生成式をそのまま文字列表現とするのが慣習ですので Dollar>>printOn: もそのようにコードします。
いつもどおり、プロトコルリスト枠の -- all -- をクリックして下のコード枠の内容を次のコードに置き換えてコンパイルします。仮実装は省きます。
Dollar >> printOn: aStream
aStream print: amount; space; nextPutAll: 'USD'
"5 USD"
「-- all --」をクリックしてからメソッドを定義するとプロトコルがデフォルトの as yet unclassified になってしまいますが、再定義(オーバーライド)のときはたいてい(プロトコル枠内)右クリック → Categorize all uncategorized で適切なプロトコルを割り当ててくれます。printOn: は printing です。
Smalltalk でシングルクオートが文字列リテラルで、ダブルクオートで括ったものはコメントになります。このように "5 USD" などとコメントを加えておけば、このダブルクオート内の式を選択して print it(Ctrl + P)することで結果を手軽に試せて便利です。(この場合は、そのまま 5 USD が返ります)。
テストも通るか必ず確認しておきましょう。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD が Dollar を返す
- 5 USD = 5 USD
- 5 USD ~= 6 USD
- インスタンス変数 amount を定義
- amount のアクセッサーメソッド(amount 、amount: ) を追加
- hash の再定義
- printOn: の再定義
Franc(フラン)を Dollar のコピーで定義
ネタ本同様に Dollar のコピーで Franc を定義しましょう。
まず、Dollar を右クリック → Copy... を選択します。
複製後のクラス名を尋ねてきますので Dollar を消して Franc とします。
Franc がクラスリスト枠に追加されるのを確認したら、テストを書きます。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- 5 CHF が Franc を返す
- 5 CHF の文字列表現は '5 CHF'
- 5 CHF = 5 CHF
- 5 CHF ~= 6 CHF
まず、USD のときと同様に、数値に CHF というメッセージを送ったときに Franc のインスタンスを返すようにします。
MoneyTest>>testFiveUSDReturnsADollar を CHF 用の testFiveCHFReturnsAFranc に書き換えてコンパイルするとテストを増やせます。
MoneyTest >> testFiveCHFReturnsAFranc
self assert: (5 CHF isKindOf: Franc)
MoneyTest の ○印クリックでエラー(レッド)を確認します。
Number>>CHF メソッドも USD のコピーで済ませましょう。まず、Number → USD の順にクリックして Number>>USD のメソッド定義を下のコード枠に呼び出します。
クラスと違い、メソッドのコピーはコマンドを必要としません。一行目の太字で示されたメソッド名を変更してコンパイルするだけです。
Number >> CHF
^ Dollar new amount: self; yourself
なお、この CHF などと違って引数をとるメソッド(記号のみからなる二項メソッドや、コロンをメソッド名に含むメソッド)の場合は、一行目の太字の部分(メッセージパターン)が仮引数の宣言も兼ねるので、それ以外のメソッド名に関わる部分に手を加えたときだけ複製になります。
さて、メソッド名だけ CHF に変えて複製しても中身は USD のときのままでは Dollar のインスタンスが返ってしまいます。しかしここでは、テストを失敗させるためあえてそのままにします。
MoneyTest の ○印 をクリックすると、エラー(レッド)は解消し、テストはもくろみ通り失敗します。
Dollar を Franc に直して再コンパイルすれば、今度はテストが成功します。
CHF
^ Franc new amount: self; yourself
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- 5 CHF が Franc を返す
- 5 CHF の文字列表現は '5 CHF'
- 5 CHF = 5 CHF
- 5 CHF ~= 6 CHF
続いて、文字列表現を確認します。これは testPrintString に Franc 向けテストを追加すればよいでしょう。
MoneyTest >> testPrintString
self assert: 5 USD printString equals: '5 USD'.
self assert: 5 CHF printString equals: '5 CHF'
コンパイルしたら MoneyTest の○印をクリックします。
テストは失敗するので、次に testPrintString の黄色くなった ○印 をクリックして原因を探ります。
Dollar からコピーした Franc>>printOn: がそのままなのが原因のようです。サクッと直します。
Franc >> printOn: aStream
aStream print: amount; space; nextPutAll: 'CHF'
"5 CHF"
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- 5 CHF が Franc を返す
- 5 CHF の文字列表現は '5 CHF'
- 5 CHF = 5 CHF
- 5 CHF ~= 6 CHF
#Franc の等価性チェックのサポート
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- 5 CHF が Franc を返す
- 5 CHF の文字列表現は '5 CHF'
- 5 CHF = 5 CHF
- 5 CHF ~= 6 CHF
等価性のチェックのサポートと乗算サポートのためのテストも文字列表現と同様に既存のテストへの書き足して済ませましょう。
まず等価性チェックのテストです。
MoneyTest >> 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
このテストは何もしなくても通ってしまいます。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- 5 CHF が Franc を返す
- 5 CHF の文字列表現は '5 CHF'
- 5 CHF = 5 CHF
- 5 CHF ~= 6 CHF
#Franc の乗算サポート
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- 5 CHF が Franc を返す
- 5 CHF の文字列表現は '5 CHF'
- 5 CHF = 5 CHF
- 5 CHF ~= 6 CHF
最後に乗算サポートのテストです。
MoneyTest >> testMultiplication
self assert: 5 USD * 2 equals: 10 USD.
self assert: 5 CHF * 2 equals: 10 CHF
こちらは失敗します。
testMultiplication の ○印 をクリックして原因を調べると、Franc>>printOn: のときと同じ理由だと分かります。
Franc>> * を Franc 向けに手直しし、MoneyTest の ○印 でグリーンを確認します。
Franc >> * multiplier
^ (amount * multiplier) CHF
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- 5 CHF が Franc を返す
- 5 CHF の文字列表現は '5 CHF'
- 5 CHF = 5 CHF
- 5 CHF ~= 6 CHF
#ここまでのまとめと補足
- Pharo Smalltalk で等価性を assert: する代わりに assert:equals: を使うと、失敗時のノーティファイアーやデバッガーの表示が親切になる
- デバッガーで Into は次にコールされるメソッドのコンテキストへ移動、Over/Through は次のコンテキストには切り替えずにステップ実行。いずれも最後はそのコンテキストを抜けて呼び出し元コンテキストに戻る
- デバッガーのコード表示枠は、クラスブラウザのコード表示枠と同じ機能、つまりコードの書き換えとコンパイルが可能(ただし実行中コンテキストは破棄される。スレッドは維持されるので Proceed で継続は可能)
- printString 等、自作オブジェクトの表現文字列生成は printOn: を内部的にコールしている(ので printOn: を再定義すればよい)
- メソッド定義を書き換えてコンパイルした場合、メソッド名(セレクターとも言う)が変わらなければ置き換え(更新)、メソッド名が変わるときは追加になる
- 追加したテストがそのまま通るのは(問題ない場合もあるが)概して良くない兆候なので要注意
#この時点のコード
ファイルアウト形式のソースです。(この形式で 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/3/2017 17:27'!
USD
^ Dollar new amount: self; yourself! !
!Number methodsFor: '*TDD-Money' stamp: 'sumim 12/5/2017 12:52'!
CHF
^ Franc new amount: self; yourself! !
Object subclass: #Dollar
instanceVariableNames: 'amount'
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Dollar methodsFor: 'accessing' stamp: 'sumim 12/3/2017 17:06'!
amount
^ amount! !
!Dollar methodsFor: 'accessing' stamp: 'sumim 12/3/2017 17:06'!
amount: aNumber
amount := aNumber! !
!Dollar methodsFor: 'arithmetic' stamp: 'sumim 12/3/2017 21:40'!
* multiplier
^ (amount * multiplier) USD! !
!Dollar methodsFor: 'comparing' stamp: 'sumim 12/3/2017 17:20'!
= other
^ other class == self class and: [ other amount = amount ]! !
!Dollar methodsFor: 'comparing' stamp: 'sumim 12/5/2017 12:42'!
hash
^ 0! !
!Dollar methodsFor: 'printing' stamp: 'sumim 12/5/2017 12:44'!
printOn: aStream
aStream print: amount; space; nextPutAll: 'USD'
"5 USD"! !
Object subclass: #Franc
instanceVariableNames: 'amount'
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Franc methodsFor: 'accessing' stamp: 'sumim 12/5/2017 12:48'!
amount
^ amount! !
!Franc methodsFor: 'accessing' stamp: 'sumim 12/5/2017 12:48'!
amount: aNumber
amount := aNumber! !
!Franc methodsFor: 'arithmetic' stamp: 'sumim 12/5/2017 12:57'!
* multiplier
^ (amount * multiplier) CHF! !
!Franc methodsFor: 'comparing' stamp: 'sumim 12/5/2017 12:48'!
= other
^ other class == self class and: [ other amount = amount ]! !
!Franc methodsFor: 'comparing' stamp: 'sumim 12/5/2017 12:48'!
hash
^ 0! !
!Franc methodsFor: 'printing' stamp: 'sumim 12/5/2017 12:54'!
printOn: aStream
aStream print: amount; space; nextPutAll: 'CHF'
"5 CHF"! !
TestCase subclass: #MoneyTest
instanceVariableNames: ''
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!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/5/2017 12:49'!
testFiveCHFReturnsAFranc
self assert: (5 CHF isKindOf: Franc)! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/3/2017 11:18'!
testFiveUSDReturnsADollar
self assert: (5 USD isKindOf: Dollar)! !
!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/5/2017 12:52'!
testPrintString
self assert: 5 USD printString equals: '5 USD'.
self assert: 5 CHF printString equals: '5 CHF'! !