新訳版『テスト駆動開発』の第Ⅰ部の多国通貨をネタに Smalltalk を学ぶシリーズの第5回です。同書と似た仕様を目指しますが、好みを優先する等の都合で写経にはなっていませんのでどうぞあしからず。(12/24追記:なるべく忠実な写経は別に書きました→こちら )
-
テスト駆動開発でお試しする Pharo Smalltalk・目次
前回 は Dollar、Franc のスーパークラスとして Money を作り、Dollar と Franc から重複を排除しました。
今回は、さらにその作業を進め、Dollar と Franc の役割を Money に持たせ、不要になった Dollar と Franc は消してしまいます。
#####*参考: 多国通貨実装のおおまかな流れ* 1. Dollar を定義 1. Dollar をコピーして Franc を定義 1. **Dollar、Franc の重複を新しく作ったスーパークラスの Money にプルアップ** ← 今ここ 1. Sum(Expression)、Bank を定義して reduce を実現
Dollar と Franc の重複を排除する
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- = と hash を Money にプルアップ(同一コードなのでそのまま)
- * と printOn: を Money にプルアップ(コードを一致させてから)
= と hash のプルアップ(コマンドは Push up)は、前回のアクセッサー(amount 、amount: )のときと同様です。右クリック → Refactoring → Push up → Do you want to remove duplicate subclass methods? に Yes → リファクタリング作業の確認で問題なければ Ok 。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- = と hash を Money にプルアップ(同一コードなのでそのまま)
- * と printOn: を Money にプルアップ(コードを一致させてから)
* と printOn: の方は、Dollar には USD、Franc には CHF がメッセージ送信であったり、文字列であったりでそれぞれ使われているので、これをなんとかして排除しないと一緒にプルアップできません。
通貨種(curreny)の導入で、USD も CHF も currency というメッセージで問い合わせが可能ですので、これを使って * と printOn: を Dollar でも Franc でも同じコードになるように書き換えましょう。
Dollar >> * multiplier
^ (amount * multiplier) perform: self currency
Franc >> * multiplier
^ (amount * multiplier) perform: self currency
Dollar >> printOn: aStream
aStream print: amount; space; nextPutAll: self currency
Franc >> printOn: aStream
aStream print: amount; space; nextPutAll: self currency
書き換えたら、念のためテストが通るか確認し、
あとはこれまで通り、右クリック → Refactoring → Push up → Do you want to remove duplicate subclass methods? に Yes → リファクタリング作業の確認で問題なければ Ok です。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- = と hash を Money にプルアップ(同一コードなのでそのまま)
- * と printOn: を Money にプルアップ(コードを一致させてから)
Dollar、Franc には currency しか残っていないので、それぞれのプロトコルリスト枠で 右クリック → Remove empty protocols して不要なプロトコルを削除しておきます。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD も 5 CHF も(Dollar や Franc ではなく)Money を返す
Dollar と Franc はもはや、currency で #USD を返すか、#CHF を返すかの違いしかなくなりました。違いが返す値だけで、ロジックでないのであれば、もはやクラスである必要はなくなります。
まず、5 USD や 5 CHF が Dollar や Franc ではなく Money を返すようにしましょう。最初の仕事はテスト(testFiveUSDReturnsADollar 、testFiveCHFReturnsAFranc )の書き換えです。
とりあえず、メソッド名を相応しいものに変えたいのですが、Smalltalk ではメソッド名を変えてコンパイルすると元のメソッドは手つかずのまま、編集後の名前での新しいメソッドの生成(複製)になってしまうので要注意です。古い名前のメソッドの削除(右クリック → Remove... )を忘れずに行なう必要があります。
ここでは、リファクタリング・ブラウザの機能を使ってメソッドの名前の変更をします。クラスリスト枠で 右クリック → Rename method (all) を選択。
メソッド名の新しい名前を尋ねられたら、testFiveUSDReturnsADollar なら testFiveUSDReturnsAMoney、testFiveCHFReturnsAFranc なら testFiveCHFReturnsAMoney と書き換え OK します。
いつもどおりリファクタリングの作業内容の確認を促されるので、問題なければ Ok 。
前述の操作(新しい名前でメソッドを複製し、古い名前の元のメソッドを削除)をしてくれているだけだとわかります。
テストの内容も忘れずに変更しておきましょう。
MoneyTest >> testFiveUSDReturnsAMoney
self assert: 5 USD class equals: Money
MoneyTest >> testFiveCHFReturnsAFranc
self assert: 5 CHF class equals: Money
テストが失敗することも確認します。
このテストを通すには、いろいろとかたづけないといけないことがありそうです。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD も 5 CHF も(Dollar や Franc ではなく)Money を返す
- 抽象メソッドの Money>>currency を具象化(その道すがらインスタンス変数 currency 追加)
Money を具象クラスとして利用するなら、抽象メソッドを排除する必要があります。前回 subclassResponsibility を使って作った Money>>currency がそれに当たります。
通貨種情報は、インスタンスごとに適切な値を返なければならないので、同名のインスタンス変数を追加し、それを返すメソッドにします。
Money >> currency
^ currency
currency という変数が無いので、テンポラリ変数に(とりあえず)するか、インスタンス変数を新たに用意するかを尋ねられるので後者を選択します。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD も 5 CHF も(Dollar や Franc ではなく)Money を返す
- 抽象メソッドの Money>>currency を具象化(その道すがらインスタンス変数 currency 追加)
何か色々と忘れているような気がしますが(ぉぃぉぃ…)、少なくとも Money から抽象メソッドは排除できたので、Number>>USD、CHF を書き換えます。
Number >> USD
^ Money new amount: self; yourself
Number >> CHF
^ Money new amount: self; yourself
テストを走らせましょう。
赤々とエラー(レッド)が点ります。
MoneyTest のメソッドリスト枠で詳細を確認すると、testFiveUSDReturnsAMoney と testFiveCHFReturnsAMoney は成功しましたが、入れ替わりで testtestCurrency と testEquality は失敗(イエロー)、testMultiplication と testPrintString はエラー(レッド)だと分かります。
testEquality を除き他の失敗やエラーは全て同根で、Money>>currency が nil を返していることが原因です。インスタンス生成時に必要な情報を与えていないのですから当然ですね。
currency のセッターを定義してもよいのですが、amount と同じくこれらを外部から書き換えることは好ましくありません。そこで、インスタンス生成時のみに使用する private な setAmount:currency: メソッドを定義します。
“private な”などと言っても、Smalltalk の言語仕様的にはメソッドは全ていわゆる“public”と決まっているので、他言語で言うところの private にできるわけではありません。
では何かというと、private プロトコルに分類してユーザー(プログラマ)に対して、メソッド外からのの仕様を推奨しない旨を伝えるということを指します。
参考:コンストラクターパラメーターメソッド・パターン(ベパp27) 初期化に必要なインスタンス変数値を一度に設定できるメソッドを作りましょう。setで始めて設定したい変数名が分かる名前にしましょう。private プロトコルに置きましょう。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD も 5 CHF も(Dollar や Franc ではなく)Money を返す
- 抽象メソッドの Money>>currency を具象化(その道すがらインスタンス変数 currency 追加)
- private(?) な setAmount:currency: を作成(amount のセッター amount: は削除)
まず、プロトコルリスト枠の右クリック → Add protocol... を選び、
「private」をタイプして入力して OK し、private プロトコルを追加します。
private プロトコルが追加され、下にメソッド定義のテンプレートテキストが表示されたら次のコードに置き換えてコンパイルします。
Money >> setAmount: aNumber currency: aSymbol
amount := aNumber.
currency := aSymbol
なお、このように初期化時のみに使用する private なセッターには、Smalltalk では通常の public なセッターには使わない set をあえて頭に付ける慣習があります。
改めて Number>>USD、CHF をこの setAmount:currency: を使って書き換えます。
Number >> USD
^ Money new setAmount: self currency: #USD; yourself
Number >> CHF
^ Money new setAmount: self currency: #CHF; yourself
テストを走らせるとエラーは消え、testEquality 以外はすべて成功します。
testEquality の ○印 をクリックして走らせると、ノーティファイアが現れます。
Debug ボタンを押してデバッガーを起動すると 5 USD ~= 5 CHF が失敗していることがわかります。
Money>> = を確認すると、プルアップしたにもかかわらず相変わらずクラスを比較しているので、currency の比較に変えます。
Money >> = other
^ other currency == currency and: [ other amount = amount ]
これでテストは通ります。
ただ、インスタンス変数 currency へのアクセスが直接アクセスと Money>>currency を介した間接アクセスが混在してしまっているのがよくありません。
参考:同じ変数については 直接アクセス・パターン (ベパp99, 実パp58)か 間接アクセス・パターン(ベパp101, 実パp59)のどちらかひとつに決めましょう(混在させない)。継承して拡張するつもりがないなら間接アクセス・パターンはコードを読みにくくするだけなのでやめましょう。
Money>>currency を使っているメソッド(senders)を調べて直接アクセスに変えてしまいましょう。
Money>>currency を右クリック → Senders of ... を選択すると、現在 Smalltalk 環境内で currency をコールしているメソッドは 5 つあることが分かります。
それらのうち、Money クラスのメソッドで、self currency とコールしているメソッド(Money>> * と printOn:)を見つけたら、self を削除し currency のみに変えてコンパイル(Accept)します。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD も 5 CHF も(Dollar や Franc ではなく)Money を返す
- 抽象メソッドの Money>>currency を具象化(その道すがらインスタンス変数 currency 追加)
- private(?) な setAmount:currency: を作成(amount のセッター amount: は削除)
#ちょっとした整理
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD も 5 CHF も(Dollar や Franc ではなく)Money を返す
- 抽象メソッドの Money>>currency を具象化(その道すがらインスタンス変数 currency 追加)
- private(?) な setAmount:currency: を作成
- 不要になった amount: を削除
- 不要になった Dollar と Franc を削除
- インスタンス生成クラスメソッド amount:currency: で記述の簡素化
セッターの Money>>amount: はもはや不要なので(そして存在することが好ましくないので)このタイミングで削除しておきましょう。
ここで Remove it で、たんに削除するのではなく、Remove, then browse senders を選んでおくと、amount: の senders を列挙してくれるので、Money 関係に影響がないことを同時に確認できます。
Dollar と Franc ももはや不要ですので同様に削除します。
Dollar を右クリック → Remove... すると、
リファクタリング作業の確認があるので Ok します。
Franc も同様にします。
Money のインスタンス生成時の、
Money new setAmount: 5 currency: #USD; yourself
という記述は長くて、そもそも private プロトコルの setAmount:currency: を外ではあまり使いたくないので、これをクラスメソッドの amount:currency: にまとめてしまいましょう。
Money class >> amount: aNumber currency: aSymbol
^ self new setAmount: aNumber currency: aSymbol; yourself
Java などの static メソッド(クラスメソッドとも呼ぶ)と違い、Smalltalk のクラスメソッドは、インスタンスとしてのクラスのインスタンスメソッドで、クラスのクラスであるメタクラスに定義します。
メタクラスは一部の例外を除き原則として無名なので、便宜的に「クラス名 class」と呼称します。たとえば、Money のメタクラスなら Money class です。ちなみにこれはメタクラスを参照するための Smalltalk 式も兼ねています(Money class を評価するとメタクラス自体が返り値として得られます)。
クラスブラウザでメタクラスの定義やそこに定義されたメソッドとそのプロトコルを見るには、クラスリスト枠の下にある「Class」ボタンをクリックして“クラスサイド”に切り替えます。
切り替わったかどうかがわかりにくいのですが、どうやら Class の前にある (C) のマークが出ているときが“インスタンスサイド”(通常のクラスのブラウズ)で (C) のマークが消えたときが“クラスサイド”(メタクラスのブラウズ)のようです。
クラスにメソッドを定義するのと同様に、プロトコルリスト枠の no message(既存メソッドがある場合は -- all --)をクリックしてメソッド定義テンプレートを下のコード枠に呼び出した状態でコードを入力しコンパイル(Accept)すればクラスメソッドが定義できます。
プロトコルが as yet unclassified になっていますが、自動振り分けは期待できないので、右クリック → Rename... で「instance creation」に変えておきます。
最後に再びクラスリスト枠の Class をクリックして、メタクラスではなく通常のクラスをブラウズできる“インスタンスサイド”に戻し、Number>>USD、CHF をもう一度、書き換えましょう。
Number >> USD
^ Money amount: self currency: #USD
Number >> CHF
^ Money amount: self currency: #CHF
テストは問題なく通ります。
TODO リスト
- 5 USD + 10 CHF = 10 USD (レートが 2 : 1 の場合)
- 5 USD * 2 = 10 USD
- 5 USD も 5 CHF も(Dollar や Franc ではなく)Money を返す
- 抽象メソッドの Money>>currency を具象化(その道すがらインスタンス変数 currency 追加)
- private(?) な setAmount:currency: を作成
- 不要になった amount: を削除
- 不要になった Dollar と Franc を削除
- インスタンス生成クラスメソッド amount:currency: で記述の簡素化
#ここまでのまとめと補足
- インスタンス生成時に与えなければならない初期値は、set で始まるコンストラクターパラメーターメソッド(ベパp27)を作って、private にしておく
- Smalltalk で言う“private”は、プロトコルを private にして注意を喚起するだけ( プラグマとか、メソッド名を pvt から始めるとかコンパイル時にチェックさせる試みもある)
- インスタンスがコールするメソッドをクラスに定義するように、クラスメソッド(クラスがコールするメソッド)はメタクラス(クラスのクラス)に定義
- メタクラスは原則無名なので「クラス名 class」と呼称(参照も同じ)
- メタクラスは原則無名なので、クラスブラウザにはクラスを編集するインスタンスサイドと、メタクラスを編集するクラスサイドがある
#この時点のコード
ファイルアウト形式のソースです。(この形式で 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 subclass: #Money
instanceVariableNames: 'amount currency'
classVariableNames: ''
poolDictionaries: ''
category: 'TDD-Money'!
!Money methodsFor: 'private' stamp: 'sumim 12/7/2017 17:41'!
setAmount: aNumber currency: aSymbol
amount := aNumber.
currency := aSymbol! !
!Money methodsFor: 'accessing' stamp: 'sumim 12/5/2017 17:28'!
amount
^ amount! !
!Money methodsFor: 'accessing' stamp: 'sumim 12/7/2017 17:32'!
currency
^ currency! !
!Money methodsFor: 'arithmetic' stamp: 'sumim 12/7/2017 18:07'!
* multiplier
^ (amount * multiplier) perform: currency! !
!Money methodsFor: 'comparing' stamp: 'sumim 12/7/2017 17:50'!
= other
^ other currency == currency and: [ other amount = amount ]! !
!Money methodsFor: 'comparing' stamp: 'sumim 12/7/2017 17:22'!
hash
^ 0! !
!Money methodsFor: 'printing' stamp: 'sumim 12/7/2017 18:08'!
printOn: aStream
aStream print: amount; space; nextPutAll: currency! !
!Money class methodsFor: 'instance creation' stamp: 'sumim 12/7/2017 18:15'!
amount: aNumber currency: aSymbol
^ self new setAmount: aNumber currency: aSymbol; yourself! !
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/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/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/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'! !