契約による設計・契約プログラミングが少しワカッタ
「契約による設計 Design By Contract」とか「契約プログラミング Programming By Contract」とか、単語は聞いたことあったけど何するもんなのかよく分かんねーなーと思ってた。
Wikipedia の記事を抜粋するとこんな感じ。
プログラムコードの中にプログラムが満たすべき仕様についての記述を盛り込む事で設計の安全性を高める技法。
契約は、コードの利用条件が満たされることによって成立する。 それら条件は、満たすべきタイミングと主体によって、以下の3種類に分けられる。
- 事前条件 (precondition)
- サブルーチンの開始時に、これを呼ぶ側で保証すべき性質。
- 事後条件 (postcondition)
- サブルーチンが、終了時に保証すべき性質。
- 不変条件 (invariant)
- クラスなどのオブジェクトがその外部に公開しているすべての操作の開始時と終了時に保証されるべき、オブジェクト毎に共通した性質。
何かをコードに入れることでどうにかすんだろーなー、とは分かるが、何を書くことを指しているのかがよく分かっていなかった。
そしたらコチラの記事を見つけた。
とても分かりやすい。
つまりこういうことだ。
- 事前条件 (Precondition) : 引数チェックしろってこと。
IllegalArgumentException
を投げる実装でも良いが、ひととおり実装が終わった後に「想定外の値」が飛んでこない確証があるのであれば、アサーション (assert()
) で書いおいても良いと思った。- アサーションの良いところは、開発中のビルドではコードが残るのでチェックできるが、プロダクション・ビルドの時はアサーションのコードが除去されるので、実行速度が落ちないという点。ただし、もしもその上で例外が発生した時は、スタックトレースが追いづらくなる恐れはある。
- 関数を呼ばれた側が、関数のド頭で引数チェックをする実装が基本。事後条件と並べて「入力」と「出力」を保証できる。入力仕様はその関数の作りによって決まるだろうから、呼ばれる側に実装しておけば間違いない。
- 関数を呼ぶ側が、呼ぶ直前に設定する引数をチェックするような実装もアリみたい。このやり方の注意点は、呼び出す関数の仕様が変わった時に、許容しているはずの引数のパターンをアサーションエラーにしてしまうような「修正漏れ」が起きそうなところ。
- 事後条件 (Postcondition) :
return
する戻り値の仕様チェック。- 「この関数では計算結果が負数になることはない」とか「データがない場合は空の配列を返す、
null
は返さない」みたいなことが決まっていれば、それをreturn
の直前でassert()
しておく。 - アサーションで実装し、単体テストと結合テストが済んでいれば十分だとは思うが、別に例外をスローするような実装にしても問題はなさそう。
- 関数を呼んだ側が、受け取った戻り値を検証するような実装は見かけなかった。
- 「この関数では計算結果が負数になることはない」とか「データがない場合は空の配列を返す、
- 不変条件 (Invariant) : クラスのプロパティの値を変更する時にチェックする。
- クラスの関数を呼ぶ前、呼んだ後で変わらない条件のこと。なので、全ての関数の最初と最後で、同じ条件をチェックする、というのが最も律儀なやり方。
- そのような不変な条件って、クラスのプロパティの仕様ぐらいなので、プロパティに値を代入する直前にチェックすれば良い、と考えられる。
this.num = num;
の直前にassert(num > 0)
とチェックしたりthis.itemArray.push(newItem);
の直前にassert(newItem != null)
とチェックしたりsetText(text)
のような Setter 関数のド頭でチェックしたり- という登場位置が妥当。
なーんだ、コレってほとんど普段から俺がやってることじゃん。と思った。アサーションは言語によっては使わないけど、関数の頭で引数チェックしてるし、関数の最後で戻り値のチェックしてるし、Setter 系の処理の所では引数チェックと同じノリでチェックしてるし。コレが契約プログラミングだったのか。
防衛的プログラミングとの違い
似たような言葉で、防衛的プログラミングという考え方もある。コチラは以下の記事で要領を掴めた。
起こりそうな例外を予めチェックしておき、例外を発生させないというプログラミング手法だ。
関数の引数チェックでは、null
などの異常値を空文字に変換して処理を続行させたり。処理中の例外は catch
して、何らかの初期値に差し替えて処理を終了させたり。
このようなコーディングもよくやってる。JavaScript の場合は null
や undefined
のような Falsy な値のチェックと変換が必要になることが多いので、そういう癖がついたと思う。
擬似コードで例示
それぞれを擬似コードで例示してみる。TypeScript 風に書くが、素の JavaScript や Java っぽい感じでも汲み取ってもらえればと。
TypeScript の場合、「その型宣言なら null
はチェックせずとも弾けるのでは?」といった指摘はありうるが、実行時に TypeScript での型チェックが働くワケではないので、API から取得したデータを元に動的に処理したりしているような場面で意図した型で動作しない場合もある。あくまで擬似コードとして、言語別の個別の指摘は無視する。ココでは「何をすることが事前条件と言われるのか」といったことを押さえるために見て欲しい。
// ↓ 「不変条件」に焦点を当てた例
/** 人物を表すクラス */
class Person {
/** 氏名 : 空文字は許容しない */
private name: string;
/** 年齢 : 0 以上の整数のみとする */
private age: number;
/** 氏名を設定する */
public setName(name: string): void {
this.name = name; // 契約的プログラミングにおける「不変条件」(空文字を許容しない) のチェックをしていない
}
/**
* 年齢を設定する
*
* @param age 年齢。0 以上の整数のみとする
*/
public setAge(age: number): void {
// 契約的プログラミングにおける「事前条件」のチェック : 引数チェック
if(age == null) throw new IllegalPreConditionException('引数 age が null か undefined です');
if(!Number.isInteger(age)) throw new IllegalPreConditionException('引数 age が小数です');
if(age < 0) throw new IllegalPreConditionException('引数 age が負数です');
// 契約的プログラミングにおける「不変条件」の事前チェック : フィールドの変更前の値をチェックする・ココでは assert ではなく例外スローとしている
if(this.age == null) throw new IllegalInvariantConditionException('処理前のフィールド age が null か undefined です');
if(!Number.isInteger(this.age)) throw new IllegalInvariantConditionException('処理前のフィールド age が小数です');
if(this.age < 0) throw new IllegalInvariantConditionException('処理前のフィールド age が負数です');
// 実処理 : フィールドに値を設定する
this.age = age;
// 契約的プログラミングにおける「不変条件」の事後チェック : フィールドの変更後の値をチェックする・ココでは assert ではなく例外スローとしている
if(this.age == null) throw new IllegalInvariantConditionException('処理後のフィールド age が null か undefined です');
if(!Number.isInteger(this.age)) throw new IllegalInvariantConditionException('処理後のフィールド age が小数です');
if(this.age < 0) throw new IllegalInvariantConditionException('処理後のフィールド age が負数です');
}
}
// ↓ 「事前条件」「事後条件」に焦点を当てた例
/**
* 引数の2つの値を加算する関数
*
* @param numberA 数値1。整数のみとし、負数は受け取らないものとする
* @param numberB 数値2。整数のみとし、負数は受け取らないものとする
* @return 引数の2つの値を加算した合計値。合計値は整数のみとし、負数にはならないものとする
* @throws 引数もしくは合計値が異常値 (小数、負数、null、undefined のいずれか) である場合
*/
function add(numberA: number, numberB: number): number {
// 契約的プログラミングにおける「事前条件」のチェック : 引数チェック
if(numberA == null) throw new IllegalPreConditionException('引数 numberA が null か undefined です');
if(numberB == null) throw new IllegalPreConditionException('引数 numberB が null か undefined です');
if(!Number.isInteger(numberA)) throw new IllegalPreConditionException('引数 numberA が小数です');
if(!Number.isInteger(numberB)) throw new IllegalPreConditionException('引数 numberB が小数です');
if(numberA < 0) throw new IllegalPreConditionException('引数 numberA が負数です');
if(numberB < 0) throw new IllegalPreConditionException('引数 numberB が負数です');
// 契約的プログラミングにおける「不変条件」がある場合は、その事前チェックをここで行う
// 実処理 : 引数の2つの値を加算する
const result = numberA + numberB;
// 契約的プログラミングにおける「事後条件」のチェック : 戻り値のチェック・ココでは assert ではなく例外スローとしている
if(result == null) throw new IllegalPostConditionException('戻り値 result が null か undefined です');
if(!Number.isInteger(result)) throw new IllegalPostConditionException('戻り値 result が小数です');
if(result < 0) throw new IllegalPostConditionException('戻り値 result が負数です');
// 契約的プログラミングにおける「不変条件」がある場合は、その事後チェックをここで行う
// 終了する
return result;
}
// ↓ 呼び出し方の違いによる「防衛的プログラミング」の例
// メイン処理
function main() {
// 「契約的プログラミング」では呼び出し元の呼び出し方については触れていない
// なので、引数に与える値の妥当性を呼び出し元ではチェックしないでいる
const resultA = add(1, 2);
const resultB = add(3, 4.5); // ← 関数内の「事前条件」違反で例外がスローされることになる
// 一方「防衛的プログラミング」は、原則的に想定される例外は絶対に発生させないようにする
// 例えば「計算に失敗した場合は -1 を出力する」といった例外ハンドリング処理を設計に組み込んでおく
try {
// 実際は API 等から値を取得するテイで…
const numberA = 6;
const numberB = 7.8;
// 関数を呼び出す前に、呼び出し元で例外が発生しないように処理する : ココでは catch 句に移動させるため例外をスローしている
if(numberA == null || !Number.isInteger(numberA) || numberA < 0) throw new IllegalParameterException('変数 numberA が不正値のため計算を開始できません');
if(numberB == null || !Number.isInteger(numberB) || numberB < 0) throw new IllegalParameterException('変数 numberB が不正値のため計算を開始できません');
// 安全に add() 関数を呼び出せることが分かったら実行する
const result = add(numberA, numberB);
// コンソール出力して終了する
console.log(result);
}
catch(error) {
// 事前に設計した例外ハンドリング時の処理どおり、計算に失敗した場合は -1 を出力する
if(error instanceof IllegalParameterException ||
error instanceof IllegalPreConditionException ||
error instanceof IllegalPostConditionException)
console.log(-1);
return; // コンソール出力して終了する
}
// 万が一、設計時に想定できていないエラーが発生した場合のためのスロー文
throw error;
}
}
愚直に実装しようとすると、まぁメンドクサイなと思う気持ちは分からんでもない。ただ、
- 別ファイルに分かれたテストコードではなく、実際に動くコードの中に条件や仕様が明記されるので分かりやすい
- コンパイル時までの型チェックではなく、実行時に実際の値でチェックされるので、より安全に処理を実行できる
- 異常値によるエラーが発生した場合にログ出力処理などを組み込みやすく、本番環境で発生したバグの調査がしやすくなったりする
…といったメリットが考えられるので、僕は割と自然にこういう感じのコードは書いてきた。
言われなくてもやってたわ
こんなの「契約プログラミング」だの「防衛プログラミング」だの、大層な名前を付けずとも当たり前にやってることだろ、と思ったんだけど、違うのかしら。「概念を知らないと意識できない」とは思うが、僕はこれらの手法の概念を理解していなかったのに、「こうやれば色々防げるでしょ」って思い付いていた。ということは誰でも思い付く程度のことだろう、と思うんだけど、違うのかしらねぇ。