TDD Boot Camp 札幌 2.0 のお題を、自分ならどう書く?とその解説
TDD Boot Camp 札幌 2.0 に参加してきました。
1.0(の特に二日目)のときは与えられた Java のお題を即興で Smalltalk に変換し半ばゲリラ的に Smalltalk を使わせていただいた経緯があったのですが、今回は事前にお題のレガシーコードを公開してもらい Smalltalk向けに書き直す時間的余裕があったのと、さいわい Smalltalk を希望してくださる方が多くいらっしゃったのでチームも組むことができ、きちんとしたかたちで参加できたのがよかったです。チーム加わってくださった皆さん、ありがとうございました。
事前の密かな目論見としては、仕様化テストを書いた後、自明なバグの修正と Smalltalk の強力なイントロスペクションのデモを兼ねた不要なクラス変数宣言の排除、Databaseインスタンスがファイルを開きっぱなしにせず、必要なときに適宜開いて閉じる仕様変更のためのリファクタリングまで完了できればなぁ…と思っていたのですが、テストフレームワークの動きの把握をなおざりにしていたのが災いして、知っていつつも結果的には @shuji_w6e の仕掛けた罠にはまるかたちで時間をくってしまい、大胆なリファクタリングまで時間切れでたどり着けなかったのが心残りでした。
まあでも、参加チームメンバー各位のポテンシャルの高さに支えられながら、前回同様、ほぼ初心者のメンバー構成で一定の成果物を出すことができたのと同時に、ともすればよくわからないものとスルーされがちな Smalltalkの存在を示したり、既に知っている人にも、その言語部分のシンプルさと環境のサポートの厚さの片鱗をそこそこアピールできたみたいなのでよかったと思います。
処理系はできればリファクタリングブラウザが組み込みの Pharo を使いたかったのですが、仕様化テストは日本語で書いた方が直感的なのに対して、Pharo は日本語の特に入力周りがまだちょっとうまく動かない感じであったので、断念しました。リファクタリングブラウザを駆使し、動的言語では難しいと言われている変数名やメソッド名の変更とかバシバシやりたかったのですがねぇ…。残念。
TDDに関しては、じつは 1.0のときからしっくりきていなかった(あるいは単に話を聞いていなかっただけかも―^^;)な仮実装のともすれば場当たり的に見える側面について、id:t-wada さんに「仮実装はテストのテスト」だから重要!と明確な指針を示していただくことができ、自分の中では「ああ、そうか。Smalltalk で、メタクラスのクラスの Metaclass において、そのクラスである Metaclass class が、同時に Metaclass のインスタンスでもあることで、メタメタメタ…と無限に続くのを防ぐあれといっしょね」、と、例によってよく分からない喩えをしつつ完全に納得がいったのが最大の収穫でした。
いつもどおり、恒例の Smalltalk無双―というか傍若無人を笑って大目に見てくださる @shuji_w6e さんや、遠方から示唆に富むお話をしにきていただく id:t-wada さんには、ほんとうにありがとうございます。「テスト駆動開発入門」の Smalltalk 写経企画とかやりたいですねー。
ということで、標記の件。
自分ならどう書く?と言っても、お題のレガシーコードの仕様化テストは前述のとおり当日チームで終わっていて、すでに bitbucket にも置いてあるとおりですので、ここではテストではなく実装の方、つまり、仕様はほぼそのままで、改めて自分で書き起こすならどうするかの方で。
Smalltalk の特徴を活かして、まあ、ふつうはやらねーな的な実装もちょこちょこ取り入れてみました。下に、ファイルアウト(環境からのコード抽出操作)時に付加される冗長な情報を排除した整形したコードを示します。メソッド毎のコピペ&コンパイルで機能しなくもないですが、じっさいに環境にファイルイン(環境へのコード読み込み。デスクトップへのドロップイン操作で可能)するのは bitbucket の TDDBC-Sap02-Library2.st のほうを使っていただければ、と。
まず Book の焼き直しである Book2 から。
▶ バイト列化、および、バイト列からのインスタンス生成
オリジナルの Book はフィールドアクセスと、ハッシュ(Smalltalk では辞書)のキーになれるように(っても、そういう使い方はされていないんですが―)#hash および #= が定義されていただけでしたが、オーソドックスに、Database の #toBytes: および #toEntry: を移動し、インスタンスに対する asByteArray の送信ででバイト列に、クラスに対する newFrom: aByteArray の送信でインスタンスを作れるようにしました。
また、#newFrom: の上流(実体)にはバイト列ストリームを引数にとる #readFrom: というメソッドを新しく作りました。このメソッドは、引数として渡されたストリーム(データ、今回の場合バイト値、の逐次読み書きが可能な外部イテレーターで、Smalltalk では固定長の配列のラッパーとして用いることで追記を可能にするのによく利用します。また、Smalltalk ではファイルオブジェクトもこのストリームとして表現されています)を受けて、現在の位置から連なっているバイト列を必要なだけ読み取ってインスタンスを作る、という作業をさせています。
#newFrom: ではわざわざバイト列をストリームでラップしてから渡しているのでありがたみがわかりませんが、引数がファイルストリームの場合、バイト列を切り出すことなく直接ファイルから必要なぶんだけ読んでインスタンスを作ることができるので、このような場合に威力を発揮します。これは Number class>>#readFrom: からのアイデアです。
▶ フィールド、および、フィールドのバイトサイズの動的なアクセス
フィールドアクセスは、いちいちアクセッサーを定義するのではなく、ひとひねりして情報を集約化と #doesNotUnderstand: を使った動的なしくみを取り入れました。まず、#fieldsSpecメソッドを定義。ここにフィールド名とバイト列化した際のバイト数を交互に記した配列リテラルを持たせます。インスタンスは、知らないメッセージ(つまり、未定義のメソッド名を含むセレクタに持つメッセージ)を受け取ると、この #fieldsSpec に記載のあるフィールド名で始まるメッセージだけを動的に処理し、あとは super doesNotUnderstand: message として本来の例外をあげます。
メッセージに含まれたセレクターが、フィールド名そのままであればゲッター、コロンが付けばセッター、Sizeが付加されればバイト列化時のサイズを変えさせています。これらの処理は #access: で行ないます。
また、前後しますが、各フィールドデータのバイト列化時のサイズおよびデータストアのファイル名はデータベースではなく Book2 に持たせるようにしました。必要なことは Book2 が知っているというスタイルです。
▶ 抽象クラス、および、トレイトの作成と利用
YAGNIの原則には反しますが、Book 以外のデータベースも容易に作れるように、Record という抽象クラスを設けて Book2 の主要なメソッドを移動しました。また、無駄にトレイトを作って、Record のサブクラス間で一部スペック(在庫状況のステータス関連)関係のメソッドを共有させてみました。
このトレイトの利用に伴い、CirculationStatus の実装方法も変更しています。元の移植版では、オリジナルのレガシーコードで使われている Java の enum の雰囲気を醸し出すために、ちょっとめずらしいプール辞書を介したプール変数の共有で実現することで、コンパイル時にチェックができるようにしてみたわけですが、いかんせん、プール辞書を共有しないコンテキストでは正しいエラーにはならないのでさっさと諦め、status のセッターだけオーバーライド(前述の動的にアクセスする機構が働くので、実際にはただの実装ですが)し、そこで入力をチェックする実装にしてあります。
Object subclass: #Record Record "compairing">>= other self == other ifTrue: [^true]. self class ~= other class ifTrue: [^false]. ^(self fieldsSpec reject: #isInteger) allSatisfy: [:field | (self perform: field) = (other perform: field)] Record "compairing">>hash | prime | prime := 31. ^self class instVarNames inject: 1 into: [:result :field | prime * result + (self perform: field) hash] Record "initialization">>initialize self fieldsSpec pairsDo: [:field :size | self instVarNamed: field put: '']. Record "accessing">>access: message | fieldName sizeSuffix | fieldName := message selector. sizeSuffix := 'Size'. (fieldName endsWith: sizeSuffix) ifTrue: [ fieldName := fieldName allButLast: sizeSuffix size. ^self fieldsSpec after: fieldName]. fieldName endsWithAColon ifTrue: [ fieldName := fieldName allButLast. ^self instVarNamed: fieldName put: message arguments first]. ^self instVarNamed: fieldName Record "accessing">>datastoreName self subclassResponsibility Record "accessing">>doesNotUnderstand: message | fields | fields := self fieldsSpec reject: [:each | each isInteger]. ^(message selector beginsWithAnyOf: fields) ifTrue: [self access: message] ifFalse: [super doesNotUnderstand: message] Record "accessing">>fieldsSpec self subclassResponsibility Record "accessing">>firstField ^self perform: self fieldsSpec first Record "converting">>asByteArray ^ByteArray streamContents: [:ss | self fieldsSpec pairsDo: [:sel :size | | bytes | bytes := ((self perform: sel) asString convertToEncoding: 'utf-8') asByteArray. ss nextPutAll: (bytes forceTo: size paddingWith: 0)]] Record "private">>data: byteArray | stream | stream := byteArray readStream. self fieldsSpec pairsDo: [:sel :len | | str | str := ((stream next: len) copyUpTo: 0) asString convertFromEncoding: 'utf-8'. self perform: sel, ':' with: str]. self status: self status asSymbol Record "private">>readFrom: byteStream self fieldsSpec pairsDo: [:sel :len | | str | str := ((byteStream next: len) copyUpTo: 0) asString convertFromEncoding: 'utf-8'. self perform: sel, ':' with: str] Record "printing">>printOn: stream stream nextPutAll: self class name. (self fieldsSpec reject: #isInteger) do: [:field | stream space; nextPutAll: field, ': ', (self perform: field) storeString] Record class "instance creation">>newFrom: byteArray ^self readFrom: byteArray readStream Record class "instance creation">>readFrom: byteStream ^self new readFrom: byteStream Record class "accessing">>datastoreName ^self new datastoreName
Record subclass: #Book2 uses: TRecordStatusAccess instanceVariableNames: 'id title author isbn status' Book2 "accessing">>datastoreName ^'book.bin' Book2 "accessing">>fieldsSpec ^#(id 8 title 512 author 128 isbn 16 status 16) Book2 "initializing">>initialize super initialize. id := '0'. self initializeStatus Book2 class "instance creation">>id: id title: title author: author isbn: isbn status: status ^self new id: id; title: title; author: author; isbn: isbn; status: status; yourself
Trait named: #TRecordStatusAccess TRecordStatusAccess "initialization">>initializeStatus self status: #STOCKED TRecordStatusAccess "initialization">>readFrom: byteStream super readFrom: byteStream. self status: self status asSymbol. ^self TRecordStatusAccess "accessing">>status: statusSym (self statusSyms includes: statusSym) ifFalse: [^self error: 'unknown status']. super status: statusSym TRecordStatusAccess "accessing">>statusSyms ^#( "入荷待ち" BACKORDERED "在庫有" STOCKED "貸出中" LENDING "抹消" ERASURED )
続いて、Database の焼き直しの Database2 について。
ファイルは開きっぱなしにはせず、ファイル名とアクセス中の場所だけ保持して、適宜、オープン→データ取得(あるいは書き込み)→閉じる を繰り返す仕様に変更しました。アクセス中の場所については、オリジナルのレガシーコードでほとんど利用されていなかった offset を使い回しています。あと、1日目に行なったのと同様に、同じ ID のレコード(のバイト列)を #add: する際はファイルの同じ場所に書き込む仕様変更も加えています。
主だった機能はレコードオブジェクトの方に移動(委譲)してしまったので、大量のクラス変数も(そもそもクラス変数の使用は可能な限り避けるべきですが―)不要となり、かなりすっきりしました。また、レコードのクラスは #recordClass に持たせておき、インスタンス生成などの必要があれば、このメソッドを介するようにしています。たとえば、DVD をレコードにした DvdDatabase であれば、当該メソッドをオーバーライドし、Book2 の代わりに DVD を返させるだけで実現できます。
Object subclass: #Database2 instanceVariableNames: 'indexDict offset' Database2 "initialization">>initialize indexDict := Dictionary new. offset := 0. FileStream fileNamed: self datastoreName do: [:file | | length position bytes record | file binary. [file atEnd] whileFalse: [ position := offset := file position. record := self recordClass readFrom: file. indexDict at: record firstField put: position]]. Database2 "accessing">>datastoreName ^self recordClass datastoreName Database2 "accessing">>list FileStream fileNamed: self datastoreName do: [:file | file binary. ^indexDict keys sort collect: [:idStr | file position: (indexDict at: idStr). self recordClass readFrom: file]] Database2 "accessing">>recordClass ^Book2 Database2 "adding">>add: aRecord | position | FileStream fileNamed: self datastoreName do: [:file | file binary. position := indexDict at: aRecord firstField ifAbsent: [offset]. file position: position. file nextPutAll: aRecord asByteArray. offset := file position max: offset]. indexDict at: aRecord firstField put: position. ^aRecord Database2 "finding">>find: idStr | position | position := indexDict at: idStr ifAbsent: [^nil]. FileStream fileNamed: self datastoreName do: [:file | file binary; position: position. ^self recordClass readFrom: file]
前述の、DVD および、そのデータベースである DvdDatabase ならば、次のような差分コードで実現可能です。
Record subclass: #DVD uses: TRecordStatusAccess instanceVariableNames: 'id title seller asin status' DVD "accessing">>datastoreName ^'dvd.bin' DVD "accessing">>fieldsSpec ^#(id 8 title 512 seller 256 asin 16 status 16) DVD "initialization">>initialize super initialize. id := '0'. self initializeStatus DVD class uses: TRecordStatusAccess classTrait instanceVariableNames: '' DVD class "instance creation">>id: id title: title seller: seller asin: asin status: status ^self new id: id; title: title; seller: seller; asin: asin; status: status; yourself
Database2 subclass: #DvdDatabase DvdDatabase "accessing">>recordClass ^DVD
じつははずかしながら,以上のは開催前に組んだレガシーコードでして(ぉぃ…)、テスト時に使用したいデータストア用のファイル名を外から与えにくくなってしまっています。サブクラスを作って #datastoreName をオーバーライドし、それをテストに用いるのがよさそうですが、例によって「耳から手突っ込んで奥歯ガタガタ言わす」スタイルの、普通のお行儀のいい言語ではマネしにくいちょっと邪悪な方法を使ってみましたので、こちらも紹介します。
メソッドオブジェクトがファーストクラスなのをいいことに、#setUp でメソッド辞書を書き換えて、自前で用意したメソッド Database2Test<<#datastoreName に差し替えてしまっています。なんとおそろしいことを!w
TestCase subclass: #Database2Test instanceVariableNames: 'savedMethod recordClass db' Database2Test "private">>datastoreName ^'testdb.bin' Database2Test "running">>setUp super setUp. recordClass := Database2 new recordClass. savedMethod := recordClass methodDict at: #datastoreName. recordClass methodDict at: #datastoreName put: self class >> #datastoreName. FileDirectory default deleteFileNamed: self datastoreName. db := Database2 new Database2Test "running">>tearDown recordClass methodDict at: #datastoreName put: savedMethod. super tearDown