第6章:時間が無いのに変更しなければなりません
はじめに
時間がない中、機能追加を行う際にはどうしたらいいかについての説明。既存のクラスをテストで保護せずに変更を行うテクニックの紹介。
このテクニックを使うと、明確な新しい責務と古い責務を分離できる。
既存のクラスをテストで保護する時間を取れないという理由だけで新しいクラスを作成する場合がある。
- このテクニックを使うと、「古い大きなクラスの残骸+新しいクラス&メソッドがある」という状態になる。
- 古い残骸を避けるのをやめ、テストで保護するようになるらしい。
スプラウトメソッド(Sprout Method)
既存コードに対してある新規の実装が必要になったとき、その追加の処理を既存コードにベタ書きするのではなく、そのコードをメソッドとして記述する手法。そのメソッドは、新しい機能を必要とする場所から呼び出す。
呼び出し側のコードをテストで保護するのは難しいかもしれないが、「新しいコード」に対してはテストを書いて保護することができる。
■実例
既存コードに対して「ただエントリを追加するのではなく、新しいエントリかどうかをチェックするコードを書く」必要が出た場合の例。
修正を既存コードにベタ書きした例が以下。
public class TransactionGate { … public void postEntries(List entries) { List entriesToAdd = new LinkedList(); for(Iterator it = entries.iterator(); it.hasNext();) { Entry entry = (Entry)it.next(); if(!transactionBundle.getListManager().hasEntry(entry)) { entry.postDate(); entriesToAdd.add(entry); } } transactionBundle.getListManager().add(entriesToAdd); } … }
■既存コードに追加修正をベタ書きすることの問題点
- 新しく追加したコードと既存のコードの区別がない。
- 日付の設定(postDate)と重複エントリのチェック(transactionBundle.getListManager().hasEntry(entry))という2つの操作が混じっている。
- 一時変数の利用(これ自体は必ずしも悪ではない)により、重複しないエントリのすべてに対して何らかの修正を行う必要性が出たとき、今後もpostEntriesメソッドに対してコードが追加されていくことになりかねない。
■解決策
重複エントリを除外する処理は、それを責務に持つ別メソッドを定義して扱う。
一時変数はあるものの、修正前よりは可読性はある。
public class TransactionGate { … List uniqueEntries(List entries) { List result = new ArrayList(); for(Iterator it = entries.iterator(); it.hasNext(); ) { Entry entry = (Entry) it.next(); if(!transactionBundle.getListManager().hasEntry(entry)) { result.add(entry); } } } … public void postEntries(List entries) { List entriesToAdd = uniqueEntries(entries); for(Iterator it = entriesToAdd.iterator(); it.hasNext();) { Entry entry = (Entry)it.next(); entry.postDate(); } transactionBundle.getListManager().add(entriesToAdd); } … }
■長所
古いコードと新しいコードを明確に区別できるようになる。
- 古いコードをすぐにテストで保護できなくても、少なくとも変更部分を独立して理解できる。
- 新しいコードと古いコードのインタフェースも明確になる。
- 影響を受ける変数をすべて把握できるため、その状況において正しいコードであるかの判断もしやすくなる。
■短所
単に新しいメソッドとして新しい機能を加えているだけ。
- もとのメソッドはテストで保護されるわけでもなく、改善するわけでもない。保護するのはあくまで「新しいメソッド」。
- とはいえ、少なくとも追加した部分についてはテストで保護されているし、古いコードと新しいコードの区別ができるようになることにもつながる。
スプラウトクラス(Sprout Class)
変更に必要な機能を別のクラスとして切り出し、そのクラスをもとのクラスから利用する方法。以下のいずれかの場合においてスプラウトクラスは作成される。
- 変更のために、あるクラスにまったく新しい責務を持たせたい場合。
- 変更対象のオブジェクトを、テストハーネス内で生成できる方法が無い(あるいはそれを見つけるのに時間がかかって想定時間内に終わらない)場合。
- テストハーネスの中でコンパイルできるのならスプラウトメソッドの適用ができるがそれができない。
- サーブレットクラス等。
■長所
コードを直接書き換える方法よりも、確信をもって変更を進められる。
- 変更点をテストできるので、少なくとも新しく作ったところについてはテストで動作が保証できる。
■短所
仕組みが複雑になる。
- 本来1つのクラスにあるべき処理がバラバラになる。
ラップメソッド(Wrap Method)
振る舞いを追加する際のテクニックの一つ。ラップメソッドには2つの側面があるが、いずれも「新しい要件を追加しつつ、接合部を導入できる方法」。
■側面①
呼び出し元はラップされたメソッド内でどんな修正があったのかを気にする必要なく、やりたい処理を委託できる。そしてそれは成功する。
public class Employee { … private void dispatchPayment() { Money amount = new Money(); for(Iterator it = timecards.iterator(); it.hasNext(); )) { Timecard card = (Timecard) it.next(); if(payPeriod.contains(date)) { amount.add(card.getHours() * payRate); } } payDispatcher.pay(this, date, amount); } public void pay() { logPayment(); dispatchPayment(); } public void logPayment() { … } }
<追加要求に応えるための変更点>
- payメソッドの名前をdispatchPaymentに変更してprivateにした。
- 同名の新しいpayメソッドを作り、そこでは支払いを記録してから(logPaymentを実行してから)支払情報を送る(dispatchPaymentを実行する)。
<ミソ>
- 「元のメソッドと同じ名前のメソッドを新しく作り、古いコードに処理を委譲する」ことで、payメソッドの呼び出し側は変更内容を知ることも気にする必要もない。payメソッドを実行したら処理は成功する。
■側面②
まだどこからも呼び出されていない新しいメソッドを追加した場合、そのクラスの利用者はやりたい処理を選択できる。
public class Employee { public void makeLoggedPayment() { logPayment(); pay(); } public void pay() { … //支払処理 } public void logPayment() { … //支払いを記録 } }
<変更点>
makeLoggedPaymentメソッドを新規定義。
⇒Employeeクラスの利用者は、いずれかの方法で支払いを行うかを選ぶことができる。
# 側面①の場合良くも悪くもpayを呼ぶと「支払処理」と「支払記録」の両方が実行されるようになる。
■長所
既存メソッドの長さは変わらない。
既存の機能から新しい機能を明確に独立させられる。
- 1つの目的のためのコードが別の目的のためのコードとまじりあってしまうこともない。
■短所
メソッドの名前を変えるとき、不適切な名前を付けてしまいがち。
- payからdispatchPaymentにしたが、そもそもこの名前も好ましいとは言えない。
ラップクラス(Wrap Class)
既存のクラスに変更を加えずに、システムに新しい振る舞いを追加する手法。既存のメソッドを使う「別のもの」(=クラス)に振る舞いを追加する手法が紹介されている。
早い話が、「Decoratorパターン」。
■ラップクラスのメリット
既存の実装を変更することなく、新しい振る舞いを定義することができる。
- 必要な一連の操作を定義した抽象クラスを作成する。
書籍ではEmployeeクラスの実装をもとに説明がされている。既存実装に「支払をした事実を記録する」という要求を受けて実装を追加する。
- payメソッドを持つ別のクラスを作る。
- そのクラスのオブジェクトはEmployeeオブジェクトを保持し、payメソッドで記録を行い、支払いを実行するためにEmployeeオブジェクトに処理を委譲する。
ラップする側のクラス(LoggingEmployee)は、クラスの呼び出し側にラッパーを意識させないようにするため、ラップ対象のクラス(Employee)と同じインタフェースを持つ必要がある。
■Decoratorパターンを使う際に注意しないといけないこと
- ラップする対象の少なくとも1つは、ラッパーではない「基本的な」クラスにする必要がある。
- 書籍の例だと「ACMEController」。
- 使いすぎないようにする。
- Decoratorの中にさらにDecoratorが、そのDecoratorにはさらに別のDecoratorが含まれていて…というコードがあると面倒くさいし、ややこしい。
第5章:ツール
はじめに
リファクタリングツールの利用による自動リファクタリングの利点と考慮しないといけない点についての説明。そして、自動リファクタリングをする際は、それによる既存の振る舞いが変わってしまわないことの確認が必要であることの説明がされている。
その確認のためのテスティングフレームワークについての説明がされている。
リファクタリングツール
■利点リファクタリングツールを使うことでリファクタリングの作業はとても容易になることが期待される。
■考慮すべきこと
ツールによってサポートするリファクタリングのレベルはまちまち。
リファクタリングツールによる変更を行う際は、それによって振る舞いが変わらないことを検証するべき。
Bill OpdykeのSmalltalkリファクタリングブラウザをはじめとして、初期の多くのJavaリファクタリングツールもそうだった。
主流でないツールの中にはそのチェックをきちんと行ってくれないものもある。
結果、リファクタリング時にバグを作りこんでしまうこともありうる。
■著者が新しいリファクタリングツールに出会ったときに検討すること
- メソッドを抽出して同じクラスにもともと存在するメソッドと同じ名前を付けたときにエラーになるか。
- 基底クラスのメソッドの名前を付けた場合にきちんと検出してくれるか。
検出してくれないのであれば、メソッドのオーバーライドによってコードを壊してしまう危険がある。
■リファクタリングツールを適用した例と心得
<適用前>
getValueメソッドが実行されるのはループに入る前の1回だけ。
変数alphaは1回だけインクリメントされる。
<適用後>
getValueで返される値が12で固定だからか、ループ内でgetValueが呼ばれるようになった。
結果、変数alphaはループ回数である10回インクリメントされる。
⇒既存の振る舞いが変わってしまう。
<心得>
自動リファクタリングを始める前には、自分のコードに対するテストを準備しておくことが大切。
テストなしで実施できる自動リファクタリングもあるが、ツールが何をチェックして何をチェックしないかを確認する必要がある。
モックオブジェクト
テスト対象クラスが依存している別のクラスに、自分のコードをテストするために正しい値を返すようにするためのオブジェクト。単体テストハーネス
GUIやWebインタフェースを使ってテストするツールはあるが、思っている以上に大変。⇒そのツールのラーニングコストとか導入とかに時間をとられるから?
UIはたいていテストを書くのに適した場所ではない。
⇒UIは変更されることが多く、テスト対象の機能から離れすぎているから。
UIベースのテストが失敗したとき、その原因を解明するのは困難。
書籍ではxUnitテスティングフレームワークについて述べられている。
- プログラマは自分が開発に使っている言語でテストを書ける。
- すべてのテストは独立して走る。
- テストをスイートにまとめ、要求に応じて実行/再実行することができる。
※テストスイート:ソフトウェアテストの目的や対象ごとに複数のテストケースをまとめたもの。
https://www.itmedia.co.jp/im/articles/1111/07/news187.html
■JUnit
JUnit3を使ったコードの説明。
ただ、「TestCaseクラスのサブクラスとすること」「メソッドの先頭を"test"とすること」という制約は、JUnit4で廃止されている。アノテーションを利用する。
@Beforeアノテーション
- public voidなメソッドに付与することで、各テストメソッドを実行する前に集実行してくれる。
- JUnit3におけるsetUpメソッド。
setUpメソッド
- テストメソッドを実行する前に各テストオブジェクトで実行されるメソッド。
- TestCaseクラスに定義されているメソッド。
@Afterアノテーション
- public voidなメソッドに付与することで、各テストメソッドを実行した後に都度集実行してくれる。
- JUnit3におけるtearDownメソッド。
tearDown
- 各オブジェクトのテストメソッドの後で実行される。
テストの実行が終わった後で何か特別な処理をする必要がある場合はこのメソッドを呼ぶ。
- TestCaseクラスに定義されているメソッド。
@Testアノテーション
- public voidなメソッドに付与することでJUnitにテストメソッドであると認識させる。
参考:https://qiita.com/tsukakei/items/b892409cf982f1951933
■JUnit以外のテスティングフレームワーク
CppUnitLineとNUnitは省略。
それ以外のフレームワークについては以下を参照。
www.xprogramming.com
※Softwareセクションを見るように書かれているが、アクセスしてみた感じ、見当たらない。
FIT(Framework for Integrated Test)
統合テスト用のテスティングフレームワーク。
システムに関する文書を書き、その中にシステムの入力と出力について記述した表を含めることができれば & その文書をHTMLとして保存できれば、それをもとにFITフレームワークがテストを実行してくれる。
ソフトウェアを書く人とソフトウェアが何をすべきかの使用可を担当する人の間のコミュニケーションを促進できる。
Fitnesse
wiki上に構築されたFIT。
参考: http://www.fitnesse.org
まとめ
以下の内容についての説明がされている。- そして、自動リファクタリングをする際は、それによる既存の振る舞いが変わってしまわないことの確認が必要であることの説明。
- その確認のためのテスティングフレームワークについての説明。
FITは使ったことが無いのでわからないが、結局「どの粒度でテストをするかに応じてxUnitを使うのか、FIT(またはFitnesse)を使うのか」が変わる認識。
単体テストなのであればxUnitを使ってテストすればいいし、受入テストレベルであればFIT(またはFitnesse)でテストすればいい。
FIT自体初めて聞いた言葉なのでこれについては調べてみるのもいいかも感。
第4章:接合モデル
はじめに
単体テストのために行わなければいけないこととしてたくさんの依存関係の排除が挙げられる。いかにして、クラス間の依存関係が排除されている状態を作り出すか、そのための方法のヒントが書かれている。
書籍では以下の例を挙げて説明されている。
■前提
・Initという関数がある。
・その中で、ある条件を満たすとPostReceiveErrorという関数を呼ぶ。
・PostReceiveErrorは別のサブシステムとやり取りするグローバル関数であり、そのサブシステムがテストで非常に扱いにくい。
■問いかけ
テストのときはPostReceiveErrorを呼び出さずにInit関数をテストしたい。
本番ではPostReceiveErrorを呼ぶ必要がある。
どうやってInit関数をテストする?
接合部と許容点
■接合部(Seam)その場所を直接編集しなくても、プログラムの振る舞いを変えることができる場所。
接合部は、その振る舞いを変更できる場所(許容点)を持つ。
■許容点(Enabling Point)
どのふるまいを使うかを決定する場所。
接合部の種類
よく使われているのはオブジェクト接合部。リンク接合部とプリプロセッサ接合部はメンテが難しいとのこと。
種類 | 概要 | 許容点は何か |
---|---|---|
リンク接合部 | JavaであればCLASSPATHを変更して、同じ名前のクラスだけど、プロダクトコードで呼び出されるパスとテスト時に呼び出されるパスを切り替える。 | CLASSPATH、makefile、IDEの設定等 |
プリプロセッサ接合部 | CやC++でよく使われる。プリプロセッサを使うことでふるまいを変更する。書籍ではプログラムの振る舞いを再定義したlocaldefs.hというヘッダファイルを作成してそれを該当コードでincludeしていた。 | プリプロセッサ定義 |
オブジェクト接合部 | オブジェクト指向の特性を活かしてふるまいを変える。振る舞いを変えたいメソッドを持つAbstractクラスやインタフェースを定義して、抽象に依存させるようにする。抽象を継承した、テスト用に振る舞いを変えたクラスを定義してテスト時にそのオブジェクトを使うようにさせる。こうすることで、「テストコードではこの処理はさせたくないけど、プロダクトコードでは実行させないといけない処理を切り替える」ことができるようになる。 | オブジェクトを生成すると決めた場所 |
まとめ
依存を排除してテストしやすくするための方法として3種類の接合部がある。そのなかでよく使われているのはオブジェクト接合部。
抽象に依存させ、振る舞いを変えたいメソッド定義を上書きした具象クラスを用意する。
テストのときは、振る舞いを上書きした具象クラスへの依存をさしこむことで、この章での問いかけである「テストのときは "事情によりテストしたくない関数" を呼び出さずに "テスト対象の関数" をテストしたい。本番では "事情によりテストしたくない関数" を呼ぶ必要がある。どうやって "テスト対象の関数" をテストする?」を実現する。
「メソッドに振る舞いを変える」という点では3章と似ているが、3章との違いは以下のような認識。
3章:振る舞いを変えることで、外から見えない、オブジェクトが持っている状態が正しいかを確認する。
4章:振る舞いを変えることで、テスト対象のメソッドに含まれる特定の処理を、テストの時だけ実行させずにテスト対象のメソッドをテストする。
参考
第3章:検出と分離
はじめに
実際は複数のオブジェクトが依存し合っているので、「ある特定のクラスのテスト」をやりにくい。結局、システムのほとんど全体をテストハーネスに含めることになってしまう。
クラスをテストハーネスに入れるために、依存関係を排除する必要がある。
・テストを作成するためには対象とするクラスからほかのクラスへの影響を把握する必要がある。
→他のクラスに成りすまして影響を直接検出する。
テストを整備する際に依存関係を排除する理由
依存関係の排除が必要な理由は以下の2ケース。<検出>
コードの計算した値にアクセスできないときに、それを検出するために依存関係を排除する。
検出のための方法は「協調クラスの擬装」のみ。(後述)
<分離>
コードをテストハーネスに入れて実行することすらできないとき、分離するために依存関係を排除する。
分離のための方法は25章に記されている。
協調クラスの擬装
<擬装オブジェクト(fake object)>クラスのテストを行うときに、その協調クラスになりすますオブジェクト。
あるコードだけを独立して実行し、何が行われるかを確認したい場合、たいていは他のコードに対する依存関係を排除する必要がある。
→その「他のコード」が、確認したい作業の影響を簡単に検出できるための方法だから。
そこ(確認したい作業の影響を受ける場所)を別のコードと置き換えることで変更対象部分のテストを書けるようにする。
この「別のコード」が、「擬装オブジェクト」。
書籍で取り上げられている例
■Saleクラス・scanメソッドを有する。
・バーコードを受け取り、それに紐づく商品名と値段をレジの画面に表示する。
■問題点
・正しい文字列が表示されるかの確認が困難。
→レジ画面のAPIに対する呼び出しがSaleクラスの奥深くに存在すると、確認作業は困難。
→画面に対する影響を「検知」するのが困難。
■改善後
・画面表示の責務をArtR56Displayクラスに移譲する。
package my.app; import my.app.application_if.Display; public class ArtR56Display implements Display { @Override public void showLine(String line) { //本来ならレジのAPIをたたく何かしらの処理があるはず。 System.out.println(line); } }
・ArtR56DisplayクラスはDisplayインタフェース #show を実装している。
package my.app.application_if; public interface Display { public void showLine(String line); }
・Displayインタフェースを実装したFakeDisplayクラスを作成する。
package my.app; import my.app.application_if.Display; public class FakeDisplay implements Display { private String lastLine = ""; @Override public void showLine(String line) { this.lastLine = line; } public String getLastLine() { return this.lastLine; } }
・プロダクトコードではSale #scanでArtR56Display #showを呼ぶ。
package my.app; import my.app.application_if.Display; public class Sale { private Display display; public Sale(Display display) { this.display = display; } public void scan(String barcode) { String itemName = Item.getInstance().getItemName(barcode); String price = Item.getInstance().getPrice(barcode); String itemLine = itemName + " " + price; this.display.show(itemLine); } }
・テストコードではSale #scanでFakeDisplay #showを呼ぶ。
package my.app; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; class SaleTest { @Test void testDisplayAnItem() { // set up setUp(); FakeDisplay display = new FakeDisplay(); Sale sale = new Sale(display); // execute sale.scan("0001"); // check result assertEquals("item_A 1000", display.getLastLine()); } private void setUp() { Item.getInstance().addNewItem("0001", "item_A", "1000"); Item.getInstance().addNewItem("0002", "item_B", "2000"); } }
■メリット
インタフェースに依存させることで、実際の表示こそしないものの、「何が表示されるのか」「出力される内容が正しいか」を確認することができる。
ArtR56Display #showを実行(プロダクトコード)
- レジの画面に品名と値段を表示する。
FakeDisplay #showを実行(テストコード)
- 画面に出力される内容が正しいかを確認する。
まとめ
テストを整備するために依存関係の排除が必要。排除が必要になる理由には「検出」と「分離」がある。
<検出>
コードの計算した値にアクセスできないときに、それを検出するために依存関係を排除する。
検出のための方法は「協調クラスの擬装」のみ。(この章で述べられた)
<分離>
コードをテストハーネスに入れて実行することすらできないとき、分離するために依存関係を排除する。
分離のための方法は25章に記されている。
バーコードを読み取ってレジに品名と値段を表示する例としてSaleクラスを用いた説明があった。
scanメソッド内で品名と値段を取得し、それをレジ画面に出すという内容だが、それだと画面に対する影響を検知するのが困難。
→Saleが持っている品名と値段という情報が外からアクセスできない。
→依存関係の排除が必要。
改善後では画面表示の部分はArtR56Displayに責務を委託し、それの擬装オブジェクトであるFakeDisplayを定義している。
これらはDisplayインタフェースを実装したものである。
SaleクラスからはDisplayインタフェースに対する依存を持たせ、プロダクトコードではArt56RDisplayを、テストコードではFakeDisplayを呼ぶことで、「レジ画面に表示する内容が正しいかどうかの確認」を行うことができる。
(SaleTestとFakeDisplayを参照)
目で見ないとテストできない部分はテストコードに起こしようがないと思っていたが、そうではなさそうという印象。
「実際に意図した文字列がレジ画面に表示されるかを確認する」のではなく、「表示するための文字列が正しいかどうかの確認」であれば、この章で書かれている内容を使うことで確かにテストコードで確認ができる。
今回は文字列の確認だったが、例えば「画像を表示する」際は、「画像を表示するためのパスが正しいか(表示させるための画像ファイルのパスが正しいか)」をテストコードに起こせばいいという認識。
Kerberos認証について
はじめに
ネットワークの認証方式として様々なものがある。本投稿では、Kerberos認証について記載する。
Kerberos認証
ネットワーク認証方式の1つ。2種類の「チケット」を使って認証を行うことを特徴としている。
- 認証サーバが発行する「TGT(Ticket Grating Ticket)」
- ファイルサーバ等の何かしらのサービスにアクセスするためのチケット「ST(Service Ticket)」
Kerberos認証の構成
・KDC(Key Distribution Center)
- サービスとユーザの信頼関係を一括管理するデータベース(のようなもの)。
・Active Directory
- そのドメイン内のユーザ情報が格納されているデータベース(のようなもの)。
・Authentication Server
- 認証サーバ。認証処理を行い、その後TGTを発行する。TGTには有効期限やチケット送信時刻(タイムスタンプ)等が含まれる。
・Ticket Grating Service
- チケット発行サーバ。TGTを受け取り、タイムスタンプ等を確認後、STを発行する。
・Service
- ユーザがアクセスしたいサービス(ファイルサーバ等)。STを受け取り、認証する。
Kerberos認証の流れ
- 認証情報(ユーザ名/パスワード 等)を送信:Client
- 認証処理を実施:Authentication Server
- TGT(Ticket Grating Ticket)の生成・発行:Authentication Server
- TGS(Ticket Grating Service)の生成・発行:Ticket Grating Server
- TGSをもとに認証:Service(ファイルサーバ等)
なお、チケット取得時のパケットを見ると、以下のような内容のパケットがやりとりされている。
WireShark等でパケットキャプチャをとる際はこれらの内容が目印になる。
・AS-REQ : Client→Authentication Serverの通信
・AS-RES : Authentication Server→Clientの通信
・TGS-REQ: Client→TGSの通信
・TGS-RES : TGS→Clientの通信
Kerberos認証のメリット
シングルサインオンの仕組みの提供が容易になる。仮にClientが使いたいサービスが5つあったとして、Kerberos認証を適用しないと、5つのサービス全てに対してユーザ名やパスワードを入力して認証を行う必要がある。
Kerberos認証では、ユーザ名とパスワードを入力して認証するのは最初の1回だけで、以降はすべてチケットを使って認証が行われる。
また、チケット自体も暗号化されているため、セキュリティのリスクもおさえることができる。
参考文献
・Kerberos の概要とチケット取得の様子を目で確認した結果 - Web/DB プログラミング徹底解説・ケルベロス認証(Kerberos Authentication)とは
・Kerberos認証 | 日経 xTECH(クロステック)
・ケルベロス認証とは | セキュリティ用語解説 | 日立ソリューションズの情報セキュリティブログ
・Kerberos(ケルベロス)認証とは - @IT
・https://wa3.i-3-i.info/word15908.html
オープン・クローズドの原則
はじめに
Java等のオブジェクト指向言語の設計・実装をしていると、よく「実装に依存するな。インタフェースに依存せよ」と耳にすることがある。実装に依存するとはどういうことか、インタフェースになぜ依存するようにしないといけないのか、というについて本記事では述べる。
オープン・クローズドの原則
「インタフェースに依存する」という考えは「オープン・クローズドの原則」と呼ばれる。この原則は「オブジェクト指向設計の原則」のひとつである。
オブジェクト指向設計の原則は、過去のソフトウェア開発の知見から生まれた設計や開発における原則集で、オープン・クローズドの原則以外にも様々な原則が存在する。
話をオープン・クローズドの原則に戻す。
オープン・クローズドの原則は、「拡張に対して開いていて、修正に対して閉じていなければならない」という原則のことである。
これは、「拡張がしやすく、拡張しても修正箇所はできるだけ少なくなるような設計にするべき」と言い換えることができる。
もっと言うと、オープン・クローズドの原則を適用することで、設計者は以下のメリットを享受できるといえる。
オープン・クローズドの原則を適用するメリット
- 拡張性がある。
- ある処理を修正するときの修正範囲を少なくすることができる。
…とはいえ、言葉だけではよく分からないので、サンプルプログラムを交えて説明する。
なお、サンプルプログラムはJavaで記載する。
サンプルコード(オープン・クローズドの原則 適用前)
DBに接続する責務を担うオブジェクトを受け取り、DBへの接続を行うメソッドを有するControllerクラスがあるとする。package com.edu.my.app.controller; import com.edu.my.app.connector.WinDataBaseConnector; public class Controller { public void connectDataBase(WinDataBaseConnector connector) { connector.connectDataBase(); } }
DBに接続するクラスは以下のように定義されているとする。
package com.edu.my.app.connector; /** * WindodwsのDBへの接続を行うクラス. * */ public class WinDataBaseConnector { public void connectDataBase() { // 何かしらの処理 } }
このような実装をしていることによる問題点は以下の通り。
- 拡張性が低い
今の実装は、「WindowsのDBに接続する」という具象クラスに依存している。
これが冒頭で記載した「実装に依存する」ということである。
これでは、Windows以外のDBに接続する、という修正が今後発生したとき、その都度Connectorに手を加える必要が出る。
public class Controller { public void connectDataBase(WinDataBaseConnector connector) { connector.connectDataBase(); } public void connectDataBase(LinuxDataBaseConnector connector) { connector.connectDataBase(); } public void connectDataBase(OtherDataBaseConnector connector) { connector.connectDataBase(); } ・・・ }
修正が発生するたびに都度ソースコードに手を加えるのはあまり好ましいとは言えない。
どうしてこうなった??
- Controllerクラスが、具象(実装。今回の場合はWinDataBaseConnector)に依存しているから。
どうしたらいい??
- 具象に対してではなく、抽象(インタフェース)に対して依存するようにする。
サンプルコード(オープン・クローズドの原則 適用後)
まず、DB接続のI/Fを定義する。package com.edu.my.app.connector_if; public interface IDataBaseConnector { public void connectDataBase(); }
I/Fの実装クラスを用意する。
package com.edu.my.app.connector; import com.edu.my.app.connector_if.IDataBaseConnector; /** * WindodwsのDBへの接続を行うクラス. * */ public class WinDataBaseConnector implements IDataBaseConnector{ public void connectDataBase() { // 何かしらの処理 } }
Controller #connectDataBaseの引数の型をインタフェースにする。
package com.edu.my.app.controller; import com.edu.my.app.connector_if.IDataBaseConnector; public class Controller { public void connectDataBase(IDataBaseConnector connector) { connector.connectDataBase(); } }
結局何が変わった??
Controllerが抽象(インタフェース)に依存するようになった。
修正前: 「WindodwsのDBに接続する」オブジェクトを受け取る。
修正後: 「なんでもいいからとりあえずDBに接続する」オブジェクトを受け取る。
これが「抽象に依存する」ということで、このようにすることで、「なんでもいいのでとりあえずDBに接続する」IDataBaseConnectorを実装するオブジェクトをすべて引数として受け取ることができるようになる。
つまり、WindowsのDBに接続するWinDataBaseConnectorも受け取ることができるし、LinuxのDBに接続するLinuxDataBaseConnectorも受け取ることができる。その他のDBに接続するOtherDataBaseConnectorも受け取ることができる。
今後、他のDBに接続する処理を行うクラスが作られてconnectDataBaseメソッドを呼ぶことになったとしても、Controllerクラスには修正を加える必要が一切なくなる。
なぜなら、具象(実装)ではなく抽象(インタフェース)に依存しているから。
何かしらのDBに接続するクラスが作られるたびにConnectorに手を加えていたオープン・クローズドの原則適用前とは大きく異なる。
先述したオープン・クローズドの原則の定義「拡張に対して開いていて、修正に対して閉じていなければならない」「拡張がしやすく、拡張しても修正箇所はできるだけ少なくなるような設計にするべき」とは、こういうことである。
終わりに
本投稿では、オープン・クローズドの原則について述べた。オープン・クローズドの原則を適用するということは、具象(実装)ではなく抽象(インタフェース)に依存する、ということを意味する。
このようにすることで設計における拡張性が増し、修正時の修正量を最小限に抑えることができる。
本投稿では、そのことをサンプルプログラムを交えて記載した。
参考文献
・オブジェクト指向設計の原則 | Think IT(シンクイット)Pythonのススメ11
はじめに
Pythonでプログラムを書くにあたり、文法や言語仕様などの個人的なメモを記載する。今回のネタはライフゲーム。
Tkinterという、PythonのGUIツールを使ってライフゲームの実装を行った。
ライフゲーム
生命の誕生、進化、淘汰などのプロセスを簡易的なモデルで再現したシミュレーションゲームで、イギリスの数学者ジョン・ホートン・コンウェイによって考案された。セル・オートマトンの一種と言われているが、難しいことは置いといて、ライフゲームには以下のようなルールがある。
生物の誕生
死んでいるセルの周囲に生きているセルが3つあれば、次の世代に生物が誕生する。
生物が継続して生存
生きているセルの周囲に生きているセルが2つ以上3つ以下あれば、次の世代に生物は継続して生存する。
過疎状態
周囲に生きているセルが1つ以下しかなければ、次の世代には死滅する。
過密状態
生きているセルの周囲に生きているセルが4つ以上あれば、過密により死滅する。
上記4つの制約(いずれもシンプル)を守ったうえでゲームを実装する。
ライフゲームのソースコード
今回作成したソースコードは以下の通り。乱数を使って状態を初期化し、300ミリ秒ごとに上記制約から次の状態を判断し描画している。
(ループを多用しすぎている感があるので、とりあえず動くものは出来たが、リファクタリングの価値はまだあると思っている)
from tkinter import * from random import randint COLS, LOWS = [70, 50] CW = 10 data = [] CELL_NEXT_TURN_VAL_EXIST = 1 CELL_NEXT_TURN_VAL_NON_EXIST = 0 TIME_INTERVAL = 300 win = Tk() cv = Canvas(win, width=700, height=500) cv.pack() """ Canvasの初期化メソッド. 0か1の乱数を発生し、1の場合は青色の円を描写する. """ def initializeCanvas(): tmp = [] for col in range(0, COLS): for low in range(0, LOWS): tmp.append(randint(0, 1)) data.append(tmp) tmp = [] paintCanvas(data) """ 次ターンでセルが生存しているかどうか. """ def isExistNextTurn(col, low, data): targetPointList = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]] currentPositionValue = data[col][low] result = 0 for targetPoint in targetPointList: x = col + targetPoint[0] y = low + targetPoint[1] if (0 <= x < COLS) and (0 <= y < LOWS): if data[x][y] == 1: result += 1 # 次ターンで「誕生」 if currentPositionValue == 0 and result == 3: return True # 次ターンで「存続」 elif currentPositionValue == 1 and 2 <= result <= 3: return True # 次ターンで「消滅」 else: return False def getNextTurnCellData(data): result = [] tmp = [] for col in range(0, COLS): for low in range(0, LOWS): if isExistNextTurn(col, low, data): tmp.append(CELL_NEXT_TURN_VAL_EXIST) else: tmp.append(CELL_NEXT_TURN_VAL_NON_EXIST) result.append(tmp) tmp = [] return result def paintCanvas(data): cv.delete("all") for col in range(0, COLS): for low in range(0, LOWS): if data[col][low] == 1: cv.create_oval(col * CW, low * CW, col * CW + CW, low * CW + CW, fill="blue") else: cv.create_oval(col * CW, low * CW, col * CW + CW, low * CW + CW, fill="white") def game_loop(data): data = getNextTurnCellData(data) paintCanvas(data) win.after(TIME_INTERVAL, game_loop, data) # 指定時間後に再度描画 initializeCanvas() win.after(TIME_INTERVAL, game_loop, data) # 指定時間後に再度描画 win.mainloop()
終わりに
本投稿では、Pythonでライフゲームの実装をしたことを述べた。守るべき制約はあるものの、いずれもシンプルなものであり、工夫次第でいくらでもプログラムできる。
今回記載したソースコードでとりあえず動くが、ループを多用しすぎている感がある。
もっと良い書き方があると思っているので、気が向いたらリファクタリングをしてみようと思う。
参考文献
・ライフゲーム - Wikipedia・ゼロからはじめるPython(9) 生物集団の栄枯盛衰"ライフゲーム"を作ってみよう | マイナビニュース
・ゼロからはじめるPython(8) ゲームで覚えるPythonプログラミング - Tkinterで始めよう | マイナビニュース