新訳版『テスト駆動開発』の第Ⅰ部の多国通貨をネタに Smalltalk を学ぶシリーズの第4回です。同書と似た仕様を目指しますが、好みを優先する等の都合で写経にはなっていませんのでどうぞあしからず。(12/24追記:なるべく忠実な写経は別に書きました→こちら )
前回 は既存の Dollar から Franc を複製し、Franc として振る舞いを整えました。
今回は Dollar と Franc に重複した機能を排除するために、両クラスに共通のスーパークラスである Money を作ります。
#####*参考: 多国通貨実装のおおまかな流れ* 1. Dollar を定義 1. Dollar をコピーして Franc を定義 1. **Dollar、Franc の重複を新しく作ったスーパークラスの Money にプルアップ** ← 今ここ 1. Sum(Expression)、Bank を定義して reduce を実現
#Money を作って Dollar と Franc のスーパークラスにする
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- Money を作る
- Dollar と Franc を Money のサブクラスに変更
- Dollar と Franc の amount とアクセッサーを Money に移動
クラス枠内で右クリック → Add Class... を選択。
続けて提示されるクラス定義テンプレートを編集後 OK し、Money クラスを作成します。
Object subclass: #Money
instanceVariableNames: ''
classVariableNames: ''
package: 'TDD-Money'
Add Class... を使わなくても、クラス枠内の余白をクリックするなどして下のコード枠にクラス定義テンプレートを呼び出してそれを編集 → Accept しても同じです。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- Money を作る
- Dollar と Franc を Money のサブクラスに変更
- Dollar と Franc の amount とアクセッサーを Money に移動
無事 Money を作ることができたら、Dollar、Franc のスーパークラスを Object から Money にすげ替えます。
まず、Dollar をクリックして選択しクラス定義式を呼び出しましょう。最初の Object を Money に書き換えて Accept するだけ作業は終わりです。
Money subclass: #Dollar
instanceVariableNames: 'amount'
classVariableNames: ''
package: 'TDD-Money'
Accept と同時に、クラスリスト枠の Money 以下に Dollar が移動するのが分かります。
Franc も同様にします。
Money subclass: #Franc
instanceVariableNames: 'amount'
classVariableNames: ''
package: 'TDD-Money'
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- Money を作る
- Dollar と Franc を Money のサブクラスに変更
- Dollar と Franc の amount とアクセッサーを Money に移動
続いて、Dollar と Franc に重複して存在するインスタンス変数 amount を Money に移動(プルアップ)します。
Dollar もしくは Franc を右クリック → Refactoring → Inst Var Refactoring → Pull Up を選択すると
プルアップするインスタンス変数を尋ねられるので(と言っても一つしかありませんが)amount をクリックして選択します。
リファクタリング作業の確認を促されるので問題なければ OK をクリック。
Dollar、Franc の instanceVariableNames: の引数から amount が消え、
Money subclass: #Dollar
instanceVariableNames: ''
classVariableNames: ''
package: 'TDD-Money'
Money subclass: #Franc
instanceVariableNames: ''
classVariableNames: ''
package: 'TDD-Money'
Money に追加されます。
Object subclass: #Money
instanceVariableNames: 'amount'
classVariableNames: ''
package: 'TDD-Money'
アクセッサー(amount 、amount: )のプルアップ(コマンドは Push up)もほぼ同様の操作です。
まず、Dollar か Franc の accessing → amount を右クリック → Refactoring → Push Up を選択します。
同じ Money のサブクラス(もし Dollar で操作しているなら Franc)にある重複ゲッターも同時に削除するか尋ねられるので Yes とします。
いつもどおりリファクタリング作業内容の確認がでるので問題なければ OK 。
これで Dollar>>amount 、Franc>>amount が削除され、同内容で Money>>amount が作成されます。
Money >> amount
^ amount
コロン付き(引数付き)のメソッドでセッターの amount: についても同様の操作を行ないましょう。
Money >> amount: aNumber
amount := aNumber
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- Money を作る
- Dollar と Franc を Money のサブクラスに変更
- Dollar と Franc の amount とアクセッサーを Money に移動
テストは相変わらず通るはずです。
#currency を導入する
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- Money を作る**
- Dollar と Franc を Money のサブクラスに変更
- Dollar と Franc の amount を Money に移動
- 通貨種(currency)という属性の導入
そろそろテストを書かないと、定規で打ち据えられ続けた手が腫れ上がりそうです。
通貨の概念を導入します。
testCurrency
self assert: 5 USD currency equals: #USD.
self assert: 5 CHF currency equals: #CHF
ここで #USD と #CHF は、シンボルのリテラル式です。シンボルは Smalltalk では一意性が保証された(必然的にイミュータブルな)文字列として振る舞います。
通常はコンパイラがトークンとして解釈する文字列を使うことが多いので先頭に # を付けるだけですが、#'a b c' のように、文字列リテラルの前に # を付ける記法を用いることで、スペースなどのセパレーターや記号を含めたシンボルを生成(生成済みであれば参照)することも可能です。
もちろんテストの結果はレッド(エラー)です。
Money>>currency が未定義なのが原因なのは明らかなので、testCurrency の ○印 でノーティファイアーを呼び出して、
さっさと Create してしまいましょう。
定義先は Money です。
プロトコルは accessing がよいでしょう。
デバッガーが起動し、currency のコンテキストが表示されたらメソッドの中身(メソッド名の currency 以外)を空にしてコンパイル (Accept)し、デバッガーは閉じてしまいます。
これでテストは無事、失敗します。
さて、このイエローをできるだけ手短にグリーンにするにはどうしたらよいでしょうか。ここではこんなふうにしてみます。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- Money を作る**
- Dollar と Franc を Money のサブクラスに変更
- Dollar と Franc の amount を Money に移動
- 通貨種(currency)という属性の導入
- Money>>currency を self subclassResponsibility に
- Money のサブクラスに currency を実装
Smalltalk と言えば、アラン・ケイの「メッセージングのオブジェクト指向」をサポートする言語の筆頭として有名ですが、実は彼の手を離れた Smalltalk-80 以降の、つまり現在“Smalltalk”と呼ばれている言語は、もう一つの「抽象データ型のオブジェクト指向」についても限定的ながらサポートする機構を含んでいます。
その象徴的なもののひとつが、ここで使う subclassResponsibility という例外を挙げるメソッドです。
もちろん遅延結合性が“いのち”の Smalltalk ですから、静的型チェック機構を備えた抽象データ型のオブジェクト指向を主にサポートする言語のようにコンパイル時の型チェックのエラーなどというものを出すようなことはしません(できません…が、静的型チェック機構の試みはあります→「Strongtalk - オプショナルな静的型チェック機能を有したSmalltalk」)。
しかし、抽象クラスを含むスーパークラスに、この subclassResponsibility をコールする内容の抽象メソッドを定義することで、インターフェイスとその実装を意識したプログラミングをほんの少しだけサポートする気概は見せています。
ここでは、Money>>currency の内容を次のように書き換えておくことで、サブクラス(Dollar や Franc)での currency の実装の必要性をコードから、あるいは実行時のエラーによってユーザー(プログラマー)に伝えることができます。
Money >> currency
self subclassResponsibility
もし処理系が、プロトコルブラウザと呼ばれる特殊なクラスブラウザを備えていれば、うっかり再定義するのを忘れたメソッドであることに気付くのにも役立ちます。(残念ながら Pharo にはこの機能はありません。図は Squeak のもの)。
ということで subclassResponsibility エラーで注意されないよう、Dollar>>currency、Franc>>currency を忘れずに実装しましょう。
Dollar >> currency
^ #USD
Franc >> currency
^ #CHF
currency メソッドのプロトコルは、すでに Money で accessing を指定してあるので、いつも通り Categorize all uncategorized で自動的に as yet unclassified を accessing に変更可能なはずです。
さて、テストは?
もちろんグリーンです。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 CHF * 2 = 10 CHF
- Money を作る**
- Dollar と Franc を Money のサブクラスに変更
- Dollar と Franc の amount を Money に移動
- 通貨種(currency)という属性の導入
- Money>>currency を self subclassResponsibility に
- Money のサブクラスに currency を実装
#ここまでのまとめと補足
- インスタンス変数やメソッドのプルアップは、リファクタリング・ブラウザの機能を使うと手間が省ける
- シンボルは一意性を保証された文字列
- subclassResponsibility は抽象データ型のオブジェクト指向を限定的ながらもサポートするためのささやかな機能
#この時点のコード
ファイルアウト形式のソースです。(この形式で 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: #Money
instanceVariableNames: 'amount'
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Money methodsFor: 'accessing' stamp: 'sumim 12/5/2017 17:54'!
currency
self subclassResponsibility
"5 USD currency"! !
!Money methodsFor: 'accessing' stamp: 'sumim 12/5/2017 17:28'!
amount
^ amount! !
!Money methodsFor: 'accessing' stamp: 'sumim 12/5/2017 17:34'!
amount: aNumber
amount := aNumber! !
Money subclass: #Dollar
instanceVariableNames: ''
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Dollar methodsFor: 'accessing' stamp: 'sumim 12/5/2017 18:21'!
currency
^ #USD! !
!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"! !
Money subclass: #Franc
instanceVariableNames: ''
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Franc methodsFor: 'accessing' stamp: 'sumim 12/5/2017 18:26'!
currency
^ #CHF! !
!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 17:21'!
testCurrency
self assert: 5 USD currency equals: #USD.
self assert: 5 CHF currency equals: #CHF! !
!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'! !