1. 猜疑心か相互信頼か、防御的か契約に基づくか
防御的プログラミングと契約プログラミングについて、後述する勉強会で疑問を持ち、勉強会内で説明されていること深堀りしてみました。
すべてが勉強になる話だったのですが、こちらの記事でフォーカスするのは「クラス設計スタイル」におけるふたつの選択肢
- トランザクションスクリプト方式
- ドメインモデル方式
に登場する「防御的プログラミングと契約プログラミング」になります。
- トランザクションスクリプト方式が「防御的プログラミング」
- ドメインモデル方式が「契約プログラミング」
増田さんのお話ではクラス設計において変更容易性を実現するには「ドメインモデル方式」選択すべきというお話でした。
本記事では、実装フェーズにおいて、各クラスがどのレイヤー以降なのか?によって、防御的・契約どちらのプログラミングを行うべきか異なる。という話をしていきます。
どのレイヤー以降なのか?これは外部と内部で区切ります。外部とは、3層アーキテクチャにおけるプレゼンテーション層が該当するかと思います。Web APIの出入り口 、ユーザのフォームなどのリクエストの受け取りや入出力などに面しているレイヤーであり防御的なレイヤーです。内部とは防御的レイヤーより内側のレイヤーとしてます。
三層アーキテクチャについては以下の資料を用いてざっくりとLTを行ったことがあります。サーバの三層構造とサーバ内の三層構造について説明しています。ご参考に
❐結論
- 外部に面してるレイヤーは防御的プログラムで有るべき
- それ以降のレイヤーでは契約プログラミングでもいい(設計やドメインによるがDDDでは契約プログラミング)
防御的プログラミングと契約プログラミングの根本的な違いとしては
それぞれ「猜疑心」と「相互信頼」に基づく思想だという点かと思います。
実装フェーズにおいては、どっちが良い悪いでなくメンタルモデルの違いであり、上記メンタルモデルが適用されるべきシステム内でのレイヤーがそれぞれ異なるのかなと。その他、堅牢性や正確性やパフォーマンス、長期的な保守性など鑑みる必要があるかと思いますがこの記事では触れません。
なぜそうなのか?
assertion(表明)・Exception(例外)の話を交えて解説していきたいと思います。
2. 増田 亨さん勉強会「設計の考え方とやり方」
勉強会の様子は以下のブログにて紹介されています。
この勉強会では、大まかに分けて以下の3点についてお話がありました。
- 良い設計を目指す
- 設計スタイルの選択
- 設計スキルを身につける
①良い設計を目指す
- ETC(Easy To Change) 原則、良い設計は悪い設計より変更が楽である
- 変更を楽で安全にする設計、それが開発者がやるべき仕事
②設計スタイルの選択(どうやってやるか)
- クラス設計スタイル
→トランザクションスクリプト方式ではなくドメインモデル方式を採用
- テーブル設計スタイル
→イミュータブルなデータモデルを構築
- 開発のやり方
→まずビジネスロジックを記述したクラスを作る
→そのクラスを利用したアプリケーションクラスを作る
→とっとと作り、定期的にリファクタリングをかける
③設計スキルを身につける
- 経験則の脳内データベース
→経験則を増やす(データベースを拡充する)
- 超高速の脳内検索
→検索速度を上げる(脳内のキャッシュ・パーティショニング)
- 目の前の課題と経験則との高度なマッチング(文脈の違いの吸収)
→文脈の差異に適応する(パターンマッチ能力を上げる)
3. クラス設計の分かれ道
増田さんのお話の中では、クラス設計のスタイルとして以下の2つの選択が分かれ道になるとのこと
- トランザクションスクリプト方式(昔の潮流)
- ドメインモデル方式(今の潮流)
「変更が楽で安全にするというようなことを考えるんであればドメインモデル方式っていうのは相当有力な選択肢だと思ってるし…… 」
4. トランザクションスクリプト方式(昔の潮流)
- データクラスと機能クラスを分ける
- 中核の関心事は入出力処理(画面・DB・Web API)
- プリミティブな型でプログラミング
- 防御的プログラミング
構造がシンプルでわかりやすい反面、目的の主体はあくまでトランザクションの処理。ドメインの文脈上どのような意味、目的を持っているかについてはあまり重視しておらず、アプリケーションのユースケースを達成することが目的。
画面・DB・Web APIなどの外部仕様を決めてそれが動くように作る。
プリミティブ型は、プログラミング言語が提供する基本的なデータ型のことで、ドメインモデル方式で採用している独自の型に対する対比。
❐防御的プログラミング
入力データや引数で渡されるデータは信用してはいけない。
受け取った側が徹底的にチェックし、不正なデータは受け付けない。
❐トランザクションスクリプト方式の問題
計算判断ロジックの複雑さをどう扱うか?が、トランザクションスクリプト方式とドメインモデル方式の大きな違い
- あちこちの入出力処理に計算判断ロジックが断片化し重複する。
- ビジネスルールが暗号化される
- どこにどんな計算判断ロジックが書いてあるか探しにくい
- ちょっとした計算判断ロジックの変更が厄介で危険になる
→修正や変更時に断片化したもの、重複したものが何処にあるのか?探しきれているのか?テストを徹底的に行わないと怖い。
かといって何処までテストすべきかも不明瞭。リリース後しばらくしてからバグが発見される。
5. ドメインモデル方式(今の潮流)
- ロジックとデータを一つのクラスにカプセル化する
- 中心の関心事は計算判断ロジック(ビジネスルール)
- アプリケーションで扱う値を独自の型として定義
- 契約プログラミング
核心は計算判断ロジックだけを入出力から切り離したこと。
論理的な計算ロジック・ビジネスルールだけを特定して、プログラムとして完成させ、関連するデータを一つのクラスのカプセル化する。
それにより、同じデータを使った計算判断ロジックがあちこちに断片化しない/重複しない。
❐ ロジックとデータを一つのクラスにカプセル化する
データとデータを操作したロジックは一つのクラスの中に、独立したモジュールとしてカプセル化される。
クラス名(型名)とメソッド名で業務の約束事を表現
- ビジネスで扱うデータの種類ごとにクラスを用意する
- データの種類ごとに必要な計算判断を洗い出して名前をつける(クラス名・メソッド
名) - 契約プログラミング:型を使って事前条件(引数の型)と事後条件(返す型)を表明
❐ 中心の関心事は計算判断ロジック(ビジネスルール)
ロジックをカプセル化する意味合いは、中心の関心事は計算判断ロジックだから。計算判断ロジックはプログラマからの見方であり、業務・ビジネスをする側からするとそれはビジネスルールである。業務をすすめる上での約束事。
ソフトウェアを複雑にする根源であり、中核の関心事である計算判断ロジックを、データとロジックをカプセル化するというアプローチ
❐ アプリケーションで扱う値を独自の型として定義
金額・数量・期限、顧客の区分・出荷製品のカテゴリ、そういったものを独自の型として定義。
❐ 契約プログラミング
防御的プログラミングと真逆のアプローチ
事前条件として引数の型を厳密に定義しておき、その引数の型を持ってオブジェクトを渡す限りはある価値を返すことを事前条件で約束する。
何を約束するのか?定められた型のオブジェクトを返しますよ、や基本的には例外も返しません・nullも返しません、などといった約束。
呼び出し側が事前条件を満たしてくれるなら、呼び出された側は事後条件を守るといった約束が大前提。
防御的プログラミングを無くそうというのが契約プログラミング。
6. クラス設計により複雑さを分離する
❐ビジネスルールクラスの設計(ドメイン層)
- 事業活動の決め事(ビジネスルール)を
- 値の種類/区分定義に注目して
- 宣言的に記述
if文・switch文などの条件分岐の複雑さをドメイン層のビジネスルールを記述したクラスに閉じ込めて、宣言的に記述することによって、他のプログラムが単純化される。こういった複雑な部分をドメイン層が、契約プログラミングで結果を保証してくれると
❐ビジネスアクションクラスの設計(アプリケーション層)
上記の前提の場合、アプリケーション層いわゆる、ユースケースのような機能を実現するクラスは
- 計算判断の実行(ドメインオブジェクトを使った計算判断の実行)
- 記録・参照の実行
- 通知・依頼の実行
7. ここまでが勉強会での話
ここまでで、防御プログラミングと契約プログラミングのある程度の違いが分かってきたかと思います。以降からが、
防御プログラミングと契約プログラミング
の違いについての深堀りになります。
8. 契約による設計とは?- 表明と例外 -
ここまで、契約プログラミングについてある程度お話しましたがこの話を最初に提唱した方の紹介をします。
契約による設計はBertrand Meyer氏によるオブジェクト指向入門という書籍で紹介されている考え方です。
契約による設計では、関数やメソッドと、それらを利用するコードとの間に以下のような契約を考えて分析します。
もしそちらが事前条件を満たした状態で私を呼ぶと約束して下さるならば、お返しに事後条件を満たす状態を最終的に実現することをお約束します。
PHP7 で堅牢なコードを書く - 例外処理、表明プログラミング、契約による設計 / PHP Conference 2016
関数の仕様を決める時に、関数を実行するのに何が必要か(事前条件)、そしてどういった結果を返してくれるのか(事後条件) をはっきりさせて分析することで、関数の責任や他の部分との境界を明確にして整理することができます。さらに、契約に基づいて関数の事前条件や事後条件をコード上に表明(assertion)として表現することで、コードのまちがいに気付きやすくなり、信頼性の高いソフトウェア開発につながります。
契約による設計において表明と例外はどっちも不正な状態を起こさないために開発者が記述・発生させるもの
- 事前条件違反は呼び出し側に不正な状態がある証拠
- 事後条件違反は供給者側に不正な状態がある証拠
不正な状態とは基本的にはバグを示します。バグはキャッチしてはいけない。即落とすべきですね。
引用元:例外入門以前
❐表明(assertion)
呼び出し側が呼び出し先に対して、事前条件としての引数の型や値が条件を守っていれば、事後条件としての結果を返してもらうことが出来る。
表明は、「ありえない」と思う場合に使用する。
「ありえない」と思っていることがあれば、それをコードで表現する。ありえないことは暗黙の前提であり、それをコードで明示することで読み手がコードの前提を理解しやすくなる。
呼び出し元から渡される値が事前条件を満たしていないことが「ありえない」という意味。あり得た場合は、プログラマのミス。事前条件は呼び出し側が守るべき条件。
なので、ありえない入力・不正な入力が「ありえる」と思える場合は使ってはいけない。
本来のエラー処理に使用してはいけない。
PGの間違い、記述ミスを早い段階で捕まえるためのデバッグツールであり、プログラミングエラー・コミュニケーションエラーを洗い出す道具。ありえないことが起きた場合は、呼び出し元に責任があり、呼び出し元が間違う原因は共有認識の不備やコミュニケーション不足、ドキュメント不備などがある。
仲間内のプログラム・自分が記述した別のプログラム・自分の組織内のプログラムのやり取りの場合、呼び出される側・呼び出す側は互いのことを信頼できるはず。よって契約が成立できるはず。
契約・事前条件は呼び出し側が守るべきもの。
正の整数を渡して下さいといった年齢の欄に、負の数は与えないください・受け入れることはできない、という事前条件・契約があれば、この状態に負の条件を渡すのはプログラミングのミス。そういうのをあぶり出すのが表明。
assertionはTruethyな式が記述される。つまり、入力とはこうでなくてならない・あるべきという意味の式になる。認知的負荷がかなり低く、読みてはそこから呼び出し側が守るべき事前条件を理解することが出来る。
こういった観点からassertionは仕様の記述の様なものでもある。メソッドの事前条件や呼び出しの条件がassertionに書いてあるロジックなので、ドキュメントにメソッドの事前条件・呼び出し条件が記述されていなくてもassertionの記述でそれがわかる。
構文
※別途、有効化の設定が必要です。
assert expression
assert expression : message
expression
boolean値を返す条件式を指定する。ここに指定した条件式の評価がfalseだった場合、
java.lang.AssertionError 例外がスローされる。
message
アサーションの詳細メッセージを指定する。ここに指定したメッセージが
java.lang.AssertionError クラスのコンストラクタに引数として渡される。
例
public class Test {
public static void main(String[] args) {
Person p = new Person(-30, "Yoshi Taro");
int age = p.getAge();
String name = p.getName();
assert name != null : "Nameがnullです)";
assert age >= 0 : "Ageがマイナスの値です";
}
}
表明違反例外
Exception in thread "main" java.lang.AssertionError: Ageがマイナスの値です
at Test.main(Test.java:8)
assertionの記述によって、開発者が以下の前提を持って以降のプログラムを作っていることが解ります。
- 「 age は 0 以上しかありえない。」
- 「name が null はありえない。」
❐例外(Exception)
事前条件・事後条件が守られないことが、ありえると思う場合に使用。
たとえ呼び出し元が事前条件を満たしていても、呼び出された側からのレスポンスやリクエストがそれを満たしているとは限らない場合
もしくは、外部のAPIを自分が公開してるとしたら、呼び出し元がルールを守ってくれるかどうか解らない。不正な値・nullが入ってくることは十分予想できる。
この状態で、表明を使用してしまうと事前条件を満たす契約という関係ではなくなってしまう。その契約があるから事後条件が保証されるのに。表明とはルールでもあり、ルールを守らなかった場合エラーが発生するがエラーを返すだけでその後の処理に責任がない。これが、組み込み系やアプリのような堅牢性が重要視されるもので起きてしまったらUXはかなり低いことになりそう。
例外は表明とは違い、あり得るという前提のため備える必要がある。例外の条件・それに相応しい例外の型・メッセージを定義、コールスタックを繋げて、呼び出し側に送出する。
falsyな扱いの場合に例外が投げられる。表明と比較して認知的負荷が高い。そのため、例外が投げられる場合のロジックからどの様な仕様なのかを読み解くのは表明と比較すると難しい。
表明は雑で荒っぽいが、例外は高級なエラーハンドリング
9. 防御的プログラミングは前提を置かない
かもしれない運転。
どんなに、事前条件を説明しても無視して入力する様な同僚がいるかも知れない。いるかも知れないからそれを想定し、チェックし例外を投げたりする。どうあっても文字列を突っ込むやつがいるかも知れないという猜疑心でプログラミングを行うのが防御的プログラミング。
全体的な目線でみると何が起こっているのか?
防御的プログラミングは局所最適化になってる。だれもルールを守ってないかもしれないが、すくなくとも自分の記述した部分は防御できている。
システム全体で何が起こってるのか見ると、すべての人がすべてのレイヤで同じ様な防御をしている。誰も他の人を信じてないから。
10. 契約プログラミングは相互信頼が前提
これに対して、全体最適を目指そうというのが契約による設計である。
やり取りのルールをきめて、お互いが守っている前提があれば防御しないという考え方。
結果、防御的プログラミングが減り、コードが減り、保守性が上がり、レイヤー間・実装の重複が減り、システム全体の保守性や内部品質的に望ましい状態を作ることが出来るようになる。
1番信頼できない入力が来るレイヤーである、ユーザーからの入力を受け入れるところはで防御的プログラミングで書き、それ以降のレイヤーは防御的プログラミングにする必要がない。
外部に面してるところは何が来るかわからないから防御するしかない。
しかし、その防御線を超えた後は、身内の同僚達で開発している世界だから同僚を信じる。ルールを守ってるという前提を信じず、防御的なプログラミングを行えばコードの重複率は増え、コード総量も増え、複雑性もあがる。ルールで防御し、ルールが遵守されていることを信じ、全体の保守性を上げるのが、契約による設計の考え方。
契約による設計や表明は相互信頼に根ざす
防御や例外は猜疑心に根ざす
実装フェーズにおいては、どっちが良い悪いでなくメンタルモデルの違いであり、上記メンタルモデルが適用されるべきシステム内でのレイヤーがそれぞれ異なる
という理解に落ち着きました。
一年目のヒヨッコなので、ご意見・ご指摘あればコメント頂けると嬉しいです。
泣いて喜びます。
最後に
こちらの記事は私のブログ記事のまんま転載です。
https://yoshitaro-yoyo.hatenablog.com/entry/2022/08/07/On_Defensive_and_Contract_Programming
こちらの記事は、ツイッター上で増田さんにご紹介頂きました。
折角なら他の方にも読んで頂いて色んなご意見を頂戴できたら嬉しいと思いこちらにも投稿した次第です。