textlint で文書の校正を自動化する
遅ればせながら、 textlint
を導入してみたのでメモ。
textlint とは
文書の校正を自動化するツール。
npm で動作しており、指定したファイルのおかしな文章をエラー警告してくれる。
チェック事項はプラグイン追加や設定ファイルなどで柔軟に指定できる。
インストール
npm からインストールする。必要なバージョンは以下。
- Node.js 6.0.0以降
- npm 2.0.0+
textlint
本体には校正ルールはバンドルされていない。
チェック事項を追加するにはルールプラグインが必要となる。ルールプラグインは、公式のルール集やtextlintの日本語コミュニティなどで公開されている。
ルール集: Collection of textlint rule · textlint/textlint Wiki · GitHub
textlintの日本語コミュニティ:textlint-ja · GitHub
今回は、JTF日本語標準スタイルガイドに従ってチェックしてくれる textlint-rule-preset-jtf-style
を使用する。
$ npm install textlint --save-dev $ npm install textlint-rule-preset-ja-spacing --save-dev
設定ファイルの作成
インストールが終わったら、textlint の設定ファイルを作成する。
$ npx textlint --init
上記コマンドを実行後、.textlintrc
ファイルが作成される。
ファイルを開き、rules
のなかにルールプラグイン名を追加する。
{ "filters": {}, "rules": { "preset-jtf-style": true } }
チェックを実行する。
最低限の準備はできたので実際にチェックしてみる。
$ npm textlint <チェックするファイル名>
ファイル拡張子は、デフォルトでは .txt
.md
に対応している。プラグインを使用すれば HTML もチェックできるようだ。
例として、前回の記事を使用して実際に実行してみた。
$ npx textlint 20190425.md /textlint_sample/20190425.md 1:4 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 1:14 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 12:10 ✓ error 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() 12:14 ✓ error 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() 13:37 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 16:7 ✓ error 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() 16:10 ✓ error 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() 17:18 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 18:32 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 18:38 ✓ error 原則として、全角文字どうしの間にスペースを入れません。 jtf-style/3.1.2.全角文字どうし 19:37 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 20:29 ✓ error 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() 20:31 ✓ error 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() 21:18 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 21:31 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 21:33 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 21:37 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 21:39 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 21:46 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 26:1 error 箇条書きの文末に句点(。)を付けて下さい。 箇条書きの文末に句点(。)を付けるかを統一します。 jtf-style/1.1.3.箇条書き 28:1 error 箇条書きの文末に句点(。)を付けて下さい。 箇条書きの文末に句点(。)を付けるかを統一します。 jtf-style/1.1.3.箇条書き 29:5 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 34:15 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 35:31 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 36:1 error 箇条書きの文末から句点(。)を外して下さい。 箇条書きの文末に句点(。)を付けるかを統一します。 jtf-style/1.1.3.箇条書き 36:18 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 41:1 error 箇条書きの文末から句点(。)を外して下さい。 箇条書きの文末に句点(。)を付けるかを統一します。 jtf-style/1.1.3.箇条書き 41:13 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 41:26 ✓ error 算用数字は「半角」で表記します。 jtf-style/2.1.8.算用数字 42:3 ✓ error 算用数字は「半角」で表記します。 jtf-style/2.1.8.算用数字 42:5 ✓ error 算用数字は「半角」で表記します。 jtf-style/2.1.8.算用数字 46:22 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 47:1 error 箇条書きの文末に句点(。)を付けて下さい。 箇条書きの文末に句点(。)を付けるかを統一します。 jtf-style/1.1.3.箇条書き 47:9 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 52:9 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 65:14 ✓ error 算用数字は「半角」で表記します。 jtf-style/2.1.8.算用数字 70:4 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 70:13 ✓ error 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 ✖ 38 problems (38 errors, 0 warnings) ✓ 33 fixable problems. Try to run: $ textlint --fix [file]
ヒィィ!
こんな感じで警告してくれます。
なお、自動で修正できる警告 fixable problems
については fix
オプションを付与して実行すれば勝手に修正してくれる。
$ npx textlint --fix 20190425.md /textlint_sample/20190425.md 41:26 ✔ 算用数字は「半角」で表記します。 jtf-style/2.1.8.算用数字 42:3 ✔ 算用数字は「半角」で表記します。 jtf-style/2.1.8.算用数字 <略> 1:4 ✔ 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 1:14 ✔ 原則として、全角文字と半角文字の間にスペースを入れません。 jtf-style/3.1.1.全角文字と半角文字の間 <略> 18:37 ✔ 原則として、全角文字どうしの間にスペースを入れません。 jtf-style/3.1.2.全角文字どうし 12:10 ✔ 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() 12:14 ✔ 半角のかっこ()が使用されています。全角のかっこ()を使用してください。 jtf-style/4.3.1.丸かっこ() <略> ✔ Fixed 33 problems
参考文献
第137回PHP勉強会 in GMO Yours に初参加した!!!
渋谷の GMO Yours にて開催されたPHP勉強会に参加してきました!
オープニング
受付が終わるや否や、颯爽と振舞われるホワイトベルグ。うまし。泡を吹くホワイトベルグ、問題無いです! #phpstudy pic.twitter.com/4eokZsJCTX
— さっぴー川原 🍶 ㍻31/4/30 平成最後のLT大会&パーティ開催 (@sapi_kawahara) 2019年4月24日
参加者で簡単な自己紹介を済ませ、本番へ。
発表内容
メイン発表枠(20分)
- ユーザーアカウントについて語る。20分で話せるだけ版 @tadsan うさみけんたさん
- PHPで学ぶ空間計算量の話 @hanhan1978 Ryo Tomidokoroさん
LT枠(5分)
- PHPerKaigi 2019 を楽しんだら2位になった話 @chatii0079 Taichi Inabaさん
- ランキング実装に失敗するとこうなるという例 @tomzoh 長谷川智希 さん / とむぞうさん
- フリーランス向けサービスフリーナンス!の説明 @y__hayashi はやしさん
- composer-bin-pluginを使ってみたよ(仮) @o0h_ hideki kinjyoさん
- ハムスターを監視するシステムを Raspberry Pi と PHP の Swoole で作った話 @m3m0r7 memory
以下気になったメモ
- ユーザーアカウントについて語る。
- ユーザーの識別子に何を使うか問題
- 電話番号やメールアドレスは本人が入れ替わることがありうる。
自前の認証フォームは持たないことに越したことはない(外部連携を利用する) NIST ガイドラインは押さえておこう support.trustlogin.com
空間計算量は、基本的に時間計算量と同じ考え方で計算できる
- memory_limit は根本解決ではない(当たり前)
- そもそもwebアプリでmemory allocation を起こしている時点で真っ当な書き方をされてないのでデータ分割なりなんなりをしっかりやる(ごもっとも)
- unbufferd query で結果セットをDB側のメモリに積める。
実行中は他のクエリを受け付けてくれないので事故注意 www.php.net
phperkaigi のトークン探しランキング2位の参加者の発表後に、ランキング実装者の裏話発表があり面白かった。
- 1位2位の人、最後は手動ブルートフォース攻撃やっててワロタ
ランキング実装にバグがあって逆転されてるのワロタ
composer-bin-plugin は利用ライブラリ間でのバージョンの衝突を回避できる。
vender ディレクトリを複数使い分けられるようになるプラグイン github.com
ハムスターかわいい
- Swoole よく聞くのでちゃんと調べたい github.com
使用されたスライド(発見次第順次追加)
まとめ
初参加で緊張しましたが、終始良い意味でゆるい雰囲気があり、とても楽しかったです。 懇親会もあり、新卒の方から1x年のベテラン勢まで幅広く参加されていて、非常に刺激になりました。 主催者・登壇者の皆さま、ありがとうございました!
今回のハイライト
突然の KENT WEB スライドに思わず涙を流す古参兵達すごい懐かしい!みんなのうめき声すごいwww #phpstudy pic.twitter.com/NUaJ6GZtPj
— ほしさきひとみ (@HitomiHoshisaki) 2019年4月24日
おわり。
PHPUnit で例外・トレイトのテスト
PHPUnit の例外・トレイトのテスト方法についてまとめたのでメモ。
環境情報
例外をテストする
例外をスローするメソッドをテストしたい。
<?php class Sample { const ERROR_CODE_1 = 10; public function foo($int) { if ($bool !== 1) { throw new ErrorException('例外のテスト', self::ERROR_CODE_1); } return $int; } }
例外がスローされたかをテストする方法には以下の2つがある。
@expectedException
アノテーションexpectedException()
メソッド
@expectedException
アノテーションは後ろに検査したい例外クラスを指定する。
expectedException()
メソッドは引数に検査したい例外クラスを指定する。
<?php class SampleTest extends TestCase { /** * @expectedException ErrorException */ public function testSample1() { $sample = new Sample(); $sample->foo(true); } public function testSample2() { $this->expectException(ErrorException::class); $sample = new Sample(); $sample->foo(true); } }
どちらも例外クラスを検査するものであるが、エラーメッセージやエラーコードまでは検証できない。
エラーメッセージを検証するには
@expectedExceptionMessage
expectedExceptionMessage()
エラーコードを検証するには
@expectedExceptionCode
expectedExceptionCode()
を使う。
<?php class SampleTest extends TestCase { /** * @expectedException ErrorException * @expectedExceptionMessage 例外のテスト * @expectedExceptionCode Sample::ERROR_CODE_1 */ public function testSample1() { $sample = new Sample(); $sample->foo(true); } public function testSample2() { $this->expectException(ErrorException::class); $this->expectExceptionMessage('例外のテスト'); $this->expectExceptionCode(Sample::ERROR_CODE_1); $sample = new Sample(); $sample->foo(true); } }
trait をテストする
trait をテストしたい。
<?php trait SampleTrait { public function foo () { return 'foo and ' . $this->bar(); } abstract function bar(); }
trait は単独でインスタンス化することができないため、通常のクラスとは異なる手順が必要になる。 テストするには以下の方法がある。
- テストクラスに直接 use する
- 無名クラスを使う
getMockForTrait()
を使う
<?php class SampleTest extends TestCase { // テストクラスに直接 use use SampleTrait; public function testSample1() { $actual = $this->foo(); $this->assertEquals('foo and bar', $actual); } // テストクラスに直接 use する場合、抽象クラスも直接定義しなければならない。 public function bar() { return 'bar'; } // 無名クラスを使う public function testSample2() { $trait = new class{ use SampleTrait; public function bar(){ return 'bar'; } }; $actual = $trait->foo(); $this->assertEquals('foo and bar', $actual); } // getMockForTrait() public function testSample3() { $mockTrait = $this->getMockForTrait(SampleTrait::class); $mockTrait->expects($this->any()) ->method('bar') ->will($this->returnValue('bar')); $actual = $mockTrait->foo(); $this->assertEquals('foo and bar', $actual); } }
テストクラスに直接 use する方法はシンプルではあるが、trait がプロパティや抽象メソッドを定義している場合には余計なメソッドをテストクラスに生やさなければならなくなる。 具象メソッドについても テストクラス のメソッドやプロパティの衝突もあり得るのが怖い。
PHP7 であれば無名クラスが使える。テストクラス に依存しなくてよくなるため、PHP7であればこちらの方がよい。
getMockForTrait()メソッドは引数に渡した trait のモックオブジェクトを返却する。抽象メソッドをモックすることで具象メソッドをテストできるようになる。
参考文献
PHPUnit のプラクティス
PHPUnit で単体テストを書く上でのプラクティスについて調べたのでメモ。
環境情報
Arrange-Act-Assertを意識する
<?php // Arrange-Act-Assertを意識しないコード public function testDivision() { $this->assertEquals([1,2,3,4,5,6], division([2,4,6,8,10,12], 2)); }
テストコードは、AAA(Arrange-Act-Assert) の区分を意識する。
- Arrenge : 準備。テスト対象の処理を実行するための準備を行う区分。
- Act : 実行。テスト対象を実際に実行する区分。
- Assert : 検証。期待通りの結果を得られたかを検証する区分。
これにより、どの処理がテスト対象なのかがはっきりさせることができる。
また、利用するにあたってどのような準備が必要になるか明確になる。
<?php public function testDivision() { // Arrange $left = [2,4,6,8,10,12]; $right = 2; $expected = [1,2,3,4,5,6]; // Act $actual = division($left, $right); // Assert $this->assertEquals($expected, $actual); }
1テスト1アサーション
<?php // ひとつのテストで複数の内容をテストしている。 public function testDivision() { $left = [2,4,6,8]; $right = 2; $expected = [1,2,3,4]; $actual = division($left, $right); $this->assertEquals($expected, $actual); $left = [2,4,6,8]; $right = 0; $expected = [2,4,6,8]; $actual = division($left, $right); $this->assertEquals($expected, $actual); $left = 4; $right = 2; $expected = 2; $actual = division($left, $right); $this->assertEquals($expected, $actual); }
ひとつのテストで複数の事柄をテストすると可読性が悪くなりがちだ。「何をテストしているのか」の意図も不明瞭になる。 また途中のアサーションでテストが落ちた場合、後続のアサーションは実行されないため成功/失敗の判断がつかなくなってしまう。
AAAに従ってテストを分割し、内容に応じた名前をつける。
<?php // 分割後のテスト public function testDivisionWithArray() { $left = [2,4,6,8]; $right = 2; $expected = [1,2,3,4]; $actual = division($left, $right); $this->assertEquals($expected, $actual); } public function testDivisionWithZero() { $left = [2,4,6,8]; $right = 0; $expected = [2,4,6,8]; $actual = division($left, $right); $this->assertEquals($expected, $actual); } public function testDivisionWithInt() { $left = 4; $right = 2; $expected = 2; $actual = division($left, $right); $this->assertEquals($expected, $actual); }
dataProvider, testWith を利用する
さきほどの testDivision
のようにテスト内容が同じ引数・結果の繰り返しであれば @dataProvider
アノテーションを使うことを検討する。
<?php /** * @dataProvider divisionDataProvider */ public function testDivision($left, $right, $expected) { $actual = division($left, $right); $this->assertEquals($expected, $actual); } public function divisionDataProvider() { return [ '左辺に配列を渡した場合' => [ [2,4,6,8], 2, [1,2,3,4], ], '右辺に0を渡した場合' => [ [2,4,6,8], 2, [1,2,4,8], ], '左辺に整数を渡した場合' => [ 4, 2, 2, ], ]; }
連想配列のキーの部分には任意のコメントがつけられる(日本語OK)。また途中のデータでテストが失敗しても後続のデータは引き続きテストされる。 デメリットとしては、テストの実施場所(testDivision)と準備するデータ(divisionProvider)が離れて配置されるため、場合によってはコードを追うのが辛くなるときがある。
準備するデータがシンプルであれば、コメント上に直接書ける @testWith
も便利。
<?php /** * @testWith ['2019-04-30', false] * ['2019-05-01', true] */ public function testIsReiwa($date, $expected) { $era = new Era($date); $actual = $era->isReiwa(); $this->assertEquals($expected, $actual); }
public メソッドのみテストする
public
メソッドがクラスのインターフェースであるならば、private
は実装の詳細だ。
private メソッドはその性質上、必ず 同じクラス内のpublic メソッドからのみ(直接・間接はあれど)利用される。
一方で、private メソッドがテストに成功したとしてもそれを利用する public メソッドのテストが成功することは保証されない。
テストは private メソッドをそれを利用する public メソッドに対し行うようにする。
独立してテストできるようにする。
<?php public function testFirst() { $hoge = new Hoge(0); $actual = $hoge->countup(); $this->assertEquals(1, $actual); return $hoge; } /** * @depends testFirst */ public function testSeccond($hoge) { $actual = $hoge->countdown(); $this->assertEquals(0, $actual); }
@depends
は便利なアノテーションであるが、各テストが依存関係を持ってしまうと以下のようなデメリットもある。
- 依存先の処理は単体でテストできない。
- 依存元のテストコードに誤りがあった場合、依存先のテストが(例え正しく動作するとしても)影響を受ける。
不必要な依存は避け、独立してテストできるようにする。
<?php public function testCountup() { $hoge = new Hoge(0); $actual = $hoge->countup(); $this->assertEquals(1, $actual); } public function testCoundDown() { $hoge = new Hoge(1); $actual = $hoge->countdown(); $this->assertEquals(0, $actual); }
実装途中のテストを明示する
テストが未完成であるときは、markTestIncomplete
を使ってその旨を明示する。
markTestIncomplete メソッドが実行されたテストの結果にはI
(= Incomplete)と表示される。
<?php public function testSample() { $this->assertTrue(true); $this->markTestIncomplete('このテストは実装途中です!!'); }
【VSCode】Better PHPUnit で テストコードから直接テストを実行する
(若干見づらいけど)こんな感じでテストコード内のカーソルが置かれた任意のメソッドのテストをVSCode上で直接実行できる。クラス名にカーソルを合わせればそのクラスのテストをすべて実行する。 また、スイート全体の実行や前回実行したテストの再実行もできるのもとてもよい。
GitHub - calebporzio/better-phpunit: A better PHPUnit test runner for VS Code
isset() とはなんぞや?
Twitterで isset() の挙動が話題になっていたので isset() について今一度きちんと理解しておこうと思い調べた。 バージョンは 7.2 で確認。
Q. isset() とは
A. 変数がセットされていること、そして NULL でないことを検査する言語構造
ようはちゃんと意味のある変数が定義されているかを調べるための構文。
$x; isset($x); // false $x = 1; isset($x); // true $arr = []; isset($arr['o']['mo']['te']['na']['shi']); // false // typo してもエラーを吐かないので注意 $hoge = 1; isset($hogee); // false echo $hogee; // PHP Notice: Undefined variable: hogee in ...
変数が定義されてようが NULL でも false
文字通り、変数が定義されていてもNULLであれば false が返る。 未定義もしくはNULL値以外の変数であればすべて true が返る。
$x = null; isset($x); // false $x = false; isset($x); // true // 変数が定義済か否かのみ調べたいときは compact を使う手もある $x = null; !!compact($x); // true
複数同時に調べられる
isset は同時に複数のパラメータを渡せる。ひとつでも false の値があれば結果は false となる。
isset($a, $b, $c); // false $a = 1; $b = 2; $c = 3; isset($a, $b, $c); // true unset($c); isset($a, $b, $c); // false
変数以外は受けつけない。
isset() は変数以外の値を受け付けてくれない。リテラルや定数や関数の戻り値を渡すと深刻めなエラーを吐く。
// リテラルは× isset(true); // PHP Fatal error: Cannot use isset() on the result of an expression (you can use "null !== expression" instead) in ... // 定数も× const HOGE = "hoge"; isset(HOGE); // PHP Fatal error: Cannot use isset() on the result of an expression (you can use "null !== expression" instead) in ... // 定数は defined を使う defined('HOGE'); // true // 戻り値だって× $a = [1,2,3,4, null]; isset(array_pop($a)); // PHP Fatal error: Cannot use isset() on the result of an expression (you can use "null !== expression" instead) in ... // 戻り値は代入する。 $a_last = array_pop($a); isset($a_last); // false
isset() ≠ 関数
isset() は言語構造( if とか for とかと同じ扱い)、つまり関数ではない。
is_callable('isset'); // false
なお、PHPマニュアルにも isset()は 言語構造であるという記載はあるものの、説明ページは変数操作 関数の一覧に配置されており、ファイル名も function.isset.php
になっている。ちゃっかりしている。
www.php.net
なのでコールバック関数としては使用できない。 可変関数や、array_map
でまとめて検査、みたいな使い方はNG。
// 可変関数には使えない $a = 1; $method = 'isset'; $method($a); // PHP Fatal error: Uncaught Error: Call to undefined function isset() in ... // コールバック関数にも使えない $arr[0] = 1; $arr[1] = null; $arr[2] = 3; array_map('isset', $arr); // PHP Warning: array_map() expects parameter 1 to be a valid callback, function 'isset' not found or invalid function name in ..
まとめ
- isset() はちゃんと意味のある変数が定義されているかを調べるための構文(NULLはダメ)
- typo してもすり抜けるので気をつける
- 複数同時に検査できるけど可読性には気をつける
- 変数以外に使うとめちゃ怒られるので気をつける
- 関数じゃないので可変変数やコールバック関数には使えない・使わない
用法用法を守って正しく isset() しよう!
PHPで考える開放閉鎖原則
開放/閉鎖原則(OCP:The Open-Closed Principle)とは、オープン・クローズドの原則とも呼ばれる、オブジェクト指向設計の原則のひとつです。
単一責任の原則と同じく、SOLID原則にまとめられています。
ソフトウェアの構成要素は、拡張に対して開いていて、修正に対し閉じていなければならない。
本稿では、PHPのソースコードを用いながら、この原則(以下OCP)が意味するところについて考えます。
「開いている」「閉じている」とは
- 拡張に対して開かれている(Open)
あるモジュールが拡張可能である場合、そのモジュールは拡張に対して開かれている(Open)と言います。 - 修正に対して閉じている(Closed)
あるモジュールが修正に対してソースコードに影響を受けない場合、そのモジュールは修正に対して閉じている(Closed)と言います。
まとめると 「ソースコードを修正せずにモジュールを拡張可能にすべき」という意味になります。
つまるところソフトウェアの機能の拡張というのは、既存のソースコードの「変更」ではなく「追加」によってなされるべきだということです。
OCP違反の例
クラス Mario
を例に考えてみます。
<?php class Mario { private $status = 'normal'; public function __construct($status) { $this->status = $status; } public function jump () { echo 'jump!'; } public function attack() { switch ($this->status) { case 'fire': echo 'fireeeee!!!'; break; case 'hammer': echo 'hammerrr!!!'; break; default: echo '...'; break; } } public function miss() { if($this->status !== 'normal'){ echo 'mamma mia...'; } echo 'game over'; } }
Mario はプロパティ status
に応じて、火やハンマーで攻撃したりあるいは何もしなかったりします。また、missしたときにゲームオーバーか通常 Mario になるかを判定します。
この Mario に対し新たな種類の status を追加する場合、attack
メソッドや miss
メソッドに変更を加える必要があります。修正に対して閉じていないためOCP違反です。
status の種類が少ないうちは良いですが、その数が膨大(Wikipedia に載っているだけでも40種類以上あるそうです)になると、attack や miss の処理は複雑になりそうです。また、改修にともない既存の他の status の振る舞いに影響を与えかねず、極めて不安定なソースコードとなります。
interface を使った解決案
この Mario
を、変更でなく、追加で機能拡張できるようにします。
interface
を使い、各status に共通する振る舞いを抽象化してみます。
<?php interface MarioStatus { public function attack(); public function miss(); }
続いて、上記の interface に対応するよう Mario クラスを変更します。
<?php class Mario { private $status; public function __construct(MarioStatus $status) { $this->status = $status; } public function jump () { echo 'jump!'; } public function attack() { $this->status->attack(); } public function miss() { $this->status->miss(); } }
attack
miss
内部に書かれていた実装を MarioStatus に移譲しました。 これにより、Mario クラスは修正を行うことなく(=閉じたまま)、status による振る舞いを追加できる(=開いている)ようになります。
あとは MarioStatus
インターフェースを実装すれば、随時 Mario クラスの振る舞いを拡張できます。
<?php // 通常マリオ class NormalMario implements MarioStatus { public function attack() { echo '...'; } public function miss() { echo 'game over'; } } // ファイアマリオ class FireMario implements MarioStatus { public function attack() { echo 'fireee!!!'; } public function miss() { echo 'mamma mia..'; } } // ハンマーマリオ class HammerMario implements MarioStatus { public function attack() { echo 'hammerrrr!!!'; } public function miss() { echo 'mamma mia..'; } } // 無敵マリオを追加 class InvincibleMario implements MarioStatus { public function attack() { echo 'I am invincible.'; } public function miss() { echo 'I am invincible.'; } }
戦略的に閉じる
上記のソースコードでは、attack
と miss
に対し変更を加えることなく機能を拡張できるようになりました。
一方で、 jump
の振る舞いを変更しなければならなくなった場合(尻尾のついた Mario など)には、既存のソースコードに変更を加える必要があります。この場合、やはりOCP違反となります。
MarioInterface に あらかじめ jump
を加えておけばよさそうにも思えますが、変更が発生しないうちはクラスにすべて同じ jump
を実装するという不必要な複雑さを招きます。また、そもそも 変更が発生しなかった場合、この対策はまったく無意味です。
変更が確実に発生しない不確かなものである以上、あらゆるケースに完璧に閉じることはできません。
この問題について、Robert C.Martin は「最初の鉛玉は甘んじて食らってみる」という面白い例えをしています。あらゆる要求に対応しようとするのではなく、予測不可能な部分については実際に変更が発生してからOCPを適用するという方針です。早まった設計を行わず、戦略的に閉じていくのが重要といえそうです。