新訳版『テスト駆動開発』の第Ⅰ部の多国通貨をネタに Smalltalk を学ぶシリーズの第2回です。同書と似た仕様を目指しますが、好みを優先する等の都合で写経にはなっていませんのでどうぞあしからず。(12/24追記:なるべく忠実な写経は別に書きました→こちら )
前回 は 5 USD というメッセージ式が Dollar のインスタンスを返すところまでを実装しました。
今回はこの Dallar のインスタンスをもう少しまともなものにしてゆきます。
#####参考: 多国通貨実装のおおまかな流れ
- Dollar を定義 ← 今ここ
- Dollar をコピーして Franc を定義
- Dollar、Franc の重複を新しく作ったスーパークラスの Money にプルアップ
- Sum(Expression)、Bank を定義して reduce を実現
Dollar の等価性
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
まずは等価性のテストを書きます。
MoneyTest >> testEquality
self assert: 5 USD = 5 USD
MoneyTest の ○印をクリックしてテストを実行すると今回はレッド(エラー)にはならずイエロー(失敗)になります。
等価性チェックを行なう = メソッドは(ProtoObject を除く)全オブジェクトのスーパークラスである Object に定義済みで、デフォルトの振る舞いでは同一オブジェクトかどうかを返します。このため、先の testEquality はエラーにはなりませんでした。
Object >> = anObject
"Answer whether the receiver and the argument represent the same
object. If = is redefined in any subclass, consider also redefining the
message hash."
^self == anObject
しかし、最初の 5 USD と二番目の 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: ) を追加
まず、不等価も念のためテストに追加しておきます。
MoneyTest >> testEquality
self assert: 5 USD = 5 USD.
self assert: 5 USD ~= 6 USD
ちなみに ~= は不等価チェックです。デフォルト実装は次のコードになっていて、テンプレートメソッド・パターン(デパp347)により、= のみの再定義で機能します。
Object >> ~= anObject
"Answer whether the receiver and the argument do not represent the
same object."
^self = anObject == false
インスタンス変数は、クラス定義の instanceVariableNames: '' の引数っぽくなっている文字列に(複数あるときはスペースで区切って)指定すればいいのですが、今回は機能の紹介を兼ねて Pharo のリファクタリング機能を使ってみましょう。
Dollar クラスを右クリック → Refactoring → Inst Var Refactoring → Add を選びます。
Provide the following information で追加したいインスタンス名を求められるので、inst を消して amount に変え OK します。
すると(今回は amount の追加だけなので項目はひとつしか出ませんが)リファクタリングに伴う作業の一覧が表示され、本当に実行してよいか確認を促されるのでそのまま OK します。
Dollar を二度クリックして再度選択する必要がありますが、先述の instanceVariableNames: の引数が '' から 'amount' に変わっているのが確認できるはずです。
Smalltalk のインスタンス変数は Java などのそれとは違い、インスタンスメソッド外から直接アクセスする手段がありません。インスタンス外から値を読み取ったり設定する必要があるときは、通常は、あらかじめアクセッサーメソッドを定義しておき、それらを介する必要があります(アクセッサーメソッドなんかなくとも Smalltalk の強力なリフレクション機能を使って済ませる手もありますが、それはまた別の話)。
Pharo では、インスタンス変数の追加したときと同じ Refactoring メニューにインスタンス変数のアクセッサーを自動生成する機能もあるのでこれを使いましょう。
Dollar クラスを右クリック → Refactoring → Inst Var Refactoring → Accessors を選びます。
アクセッサーを自動生成したいインスタンス変数を指示するように促されるので amount をクリックして選択します。
続けて、ゲッター(インスタンス変数と同名のメソッド)、セッター(インスタンス変数に : を付けた引数をひとつとるメソッド)、どちらのメソッドを生成するかを尋ねてくるので、そのまま OK します。
accessing プロトコルが新たに設けられ、そこに amount 、amount: が定義されます。メソッドの中身の確認ついでに、amount: の仮引数が anObject なのがあんまりなので、aNumber とでもしておきましょう。
Dollar >> amount: aNumber
amount := aNumber
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: ) を追加
準備は整ったので改めて Dollar オブジェクトの等価性チェック Dollar>> = を定義しまます。Dollar クラスのプロトコルリスト枠にある -- all -- をクリックして、下のコードペインに(Dallar クラスの定義式ではなく)メソッド定義用テンプレートが現れたのを確認して次のように = メソッドを定義します。
Dollar >> = other
^ other class == self class and: [ other amount = amount ]
プロトコルは as yet classified に分類されるので、同枠内で右クリック → Categorize all uncategorized を選んで、comparing に自動割り当てしてもらいます。
テストは相変わらず失敗するはずですが、レッドになってしまっていないことは確認しておきます。
最後に、Number>>USD が amount をセットした適切な Dallar を返すように修正します。
Number >> USD
^ Dollar new amount: self; yourself
「 ; yourself 」というのは、直前の式の結果を無視してレシーバーを返す「カスケード式」と呼ばれる“おまじない”(シンタックスシュガー)です。次のように書くのと意味は同じです。
Number >> USD
| aDollar |
aDollar := Dollar new.
aDollar amount: self.
^ aDollar
これでテストは通るはずです。
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: ) を追加
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: ) を追加
テストを書きます。
MoneyTest >> testMultiplication
self assert: 5 USD * 2 = 10 USD
コンパイルは通るのですが、下で Pharo が何かサジェスチョン「Use assert:equals: instead of assert: and = 」をしてくれていますのでレンチアイコンをクリックして従ってみましょう。
いちおう、どんなふうに変えるか(赤部分を削って緑部分を追加)を教えてくれるみたいですね。問題なさそうなので OK します。
テストを実行します。
Dollar>> * は定義されていませんから、もちろんレッドです。
イエローも仮実装もすっとばして一気にグリーンにします。Dollar>> = のときと同じように、Dollar クラスのプロトコルリスト枠にある -- all -- をクリックして次のコードをコンパイルしてメソッドを生成しましょう。
Dollar >> * multiplier
^ (amount * multiplier) USD
コンパイルが終わったら、プロトコルリスト欄で 右クリック → Categorize all uncategorized もお忘れなく。( * は arithmtic プロトコルに分類されます。)
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: の再定義
#ここまでのまとめと補足
- 等価チェックは = 、同一チェックは ==
- = のデフォルト動作(未定義時動作)は ==
- = を再定義したら hash も再定義(忘れていたのでそのうちやる)
- ; でメッセージを区切って連ねる「カスケード」という記法を使うと、テンポラリ変数を介してメッセージを畳みかけて送る処理と同等の記述ができる
- 式; yourself で、式のレシーバーを無条件に(式が何を返そうがそれを無視して)返すことができる
- メソッド名がよく利用されるものなら、Categorize all uncategorized で適切なプロトコルを推測して分類してくれる(でも全部は任せられないので、普段から調べて慣習を理解したり、自分で思いついたりできるようにしよう)
- 外部からはアクセッサーをコールするメッセージ式を介してしかインスタンス変数にはアクセスできない
- 引数をとるメソッドの名前はコロンを含み、コロンまで含めてひとつのメソッド名(例:amount: 、assert: 、printOn: 、assert:equals: 等々)でコロンなしの同名メソッドとは別物(例: amount と amount: 等)。さらに言うと同名のインスタンス変数とメソッド(アクセッサーメソッド)も別物
- インスタンスの文字列表現のデフォルトは 不定冠詞 + クラス名(an Object や a Dollar)なので、状態を持つようになったら 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! !
Object subclass: #Dollar
instanceVariableNames: 'amount'
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Dollar methodsFor: 'accessing' stamp: 'sumim 12/3/2017 17:06'!
amount: aNumber
amount := aNumber! !
!Dollar methodsFor: 'accessing' stamp: 'sumim 12/3/2017 17:06'!
amount
^ amount! !
!Dollar methodsFor: 'comparing' stamp: 'sumim 12/3/2017 17:20'!
= other
^ other class == self class and: [ other amount = amount ]! !
!Dollar methodsFor: 'arithmetic' stamp: 'sumim 12/3/2017 21:40'!
* multiplier
^ (amount * multiplier) USD! !
TestCase subclass: #MoneyTest
instanceVariableNames: ''
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/3/2017 11:18'!
testFiveUSDReturnsADollar
self assert: (5 USD isKindOf: Dollar)! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/3/2017 12:56'!
testEquality
self assert: 5 USD = 5 USD.
self assert: 5 USD ~= 6 USD! !
!MoneyTest methodsFor: 'tests' stamp: 'sumim 12/3/2017 17:45'!
testMultiplication
self assert: 5 USD * 2 equals: 10 USD! !