Design by Contract - 現代プログラミング勉強会
この記事は、「現代プログラミング勉強会」と称して僕が社内向けの勉強会で話した内容を一般公開できるように再編集したものです。
現代プログラミング勉強会シリーズは全4回に分かれています。
勉強会のターゲットとしては、以下のようなエンジニアを想定しています。
- 普段の業務ではレガシーなコードしか見る機会がない若手エンジニア
- 一定のプログラミング経験があるものの、主にレガシーシステムの保守を担当しているエンジニア
たとえどんな姿をしていようとも、本番環境で稼働しているコードは価値のあるものです。 一方で、コーディングスタイルや設計思想に関して言えば、いまあるものを将来に渡って盲信しつづけるのは非常に危険です。 この勉強会は、現代のアプリケーションに要求される特性を学ぶことと、これまで培ってきたプログラミングの常識について学習棄却の機会となることを目的としています。
Design by Contract
Design by Contract(契約による設計)とは、関数の仕様を一種の契約と捉え、事前条件・事後条件・不変条件の3つにより関数とその関数を呼び出すクライアントの責任範囲を明確にする設計技法です。 契約プログラミングとも呼ばれ、言語仕様としてこの思想を取り込んだプログラミング言語もありますが、ここではあくまで一般のプログラミングにおける設計手法・概念という扱いで紹介します。
事前条件・事後条件・不変条件
まずはDesign by Contractの考え方の基礎となる3つの要素について紹介します。
事前条件
事前条件はクライアントに課せられる義務です。 クライアントは、関数が要求する状態・性質を満たした上でその関数を呼び出さなければなりません。
わかりやすい事前条件の例としては、関数に与えるパラメータ(引数)の型や値域があります。 (望ましいことではありませんが)もし関数がアプリケーションのグローバルな状態を参照するのであれば、それも事前条件となります。 内部でデータベースにアクセスするような関数であれば、「○○なスキーマのテーブルが存在すること」みたいなものを事前条件として数えてもよいでしょう。
事前条件としては、クライアントが努力することで満たすことのできる項目だけを設定すべきです。 プログラム、つまりソフトウェアの側でコントロールできないハードウェアやインフラに関連する制約を事前条件に盛り込むのはナンセンスです。
事後条件
事後条件は関数に課せられる義務です。 関数は、事前条件が満たされた状態で呼び出されたならば必ず事後条件を満たさなければなりません。
一般的には関数の返り値や関数が行う処理の結果など、関数を呼び出すことによってクライアントが得られる利益が事後条件に該当します。 また、関数は(このあと説明する不変条件を満たす限り)事後条件を満たすためにどのような処理を用いても構いません。
不変条件
不変条件も関数に課せられる義務です。 関数は、事前条件が満たされた状態で呼び出されたならば必ず不変条件を満たさなければなりません。
不変条件は関数が発生させる副作用を合理的な範囲に制限するためのものです。 不変条件がなかった場合、関数は事後条件を満たすためにどんな手段でも選択してよいということになってしまいます。 例えば、グローバル変数を書き換えたり、メモリリークを発生させたり、異常なまでに長い処理時間を要したりといったような振る舞いが許されてしまうわけです。
このようなやんちゃを防ぐために、不変条件として関数が余計な副作用を発生させないことや、 必要であればパフォーマンス条項を不変条件の一部として設定します。
厳密に全ての不変条件を記述することは現実的ではないので、あまり頑張りすぎるのは得策ではありません。 ビジネス界の契約に照らして言えば、誠実協議条項みたいなやつがDesign by Contractの世界にも必要となります。
また、不変条件と事後条件はどちらも関数が満たすべき性質であることには変わりないので、きっちりと線引きすることにはあまり意味はないと思います。
事前条件が満たされない場合の関数の動作
事前条件が満たされなかった場合の関数の動作は、未定義としてください。 理由は単純で、動作を定義してしまったらそれはもはや関数の仕様、つまり契約の一部となってしまうからです。 クライアントは自らが守るべき契約に従っていないわけですから、関数がどのような振る舞いをしても文句を言う権利はありません。
事前条件が満たされなかった場合、関数は正常に動作しているように振る舞っても良いですし、めちゃくちゃな返り値を返しても良いですし、 親切に呼び出し側に対して事前条件が満たされなかったことを通知してあげても構いません。 関数としては、事前条件が満たされなかった場合の挙動にはなんの保証もせず、将来の改修で予告なく変更してもよい自由を確保しなければなりません。
もちろん、「正常系で期待する事前条件が満たされなかった場合に、関数はクライアントにそれを通知する」というような契約を作ること自体は問題ありません。 しかし、「正常系で期待する事前条件が満たされなかった場合」をすべて網羅するのは非常に困難です。 関数を開発する側の負荷が非常に大きくなるだけでなく、契約が無駄に複雑になることで利用する側の心理的な負担も増してしまうため、僕はおすすめしません。
例外
Design by Contractを用いて関数の仕様を定義することによって、例外を簡単に定義できるようになります。 例外とは、クライアントが事前条件を満たして関数を呼び出したにもかかわらず、関数が事後条件と不変条件を満たせない、かつ関数内で復旧ができない状況のことを指します。
ここでいう例外はいわゆるシステムエラーを指します。 特定の条件を満たした場合必ず発生する業務エラーに関しては、関数の事後条件として扱うべきです。
ファイル読み書きやネットワーク通信がを行う関数では、事前条件が満たされたとしても事後条件を満たせないケースがしばしば発生します。 そういったケースでは、関数はシステムエラーを発生させます。 一方で、呼び出し側が事前条件を満たさなかった場合はシステムの不具合です。 この場合は先述の通り、例外の送出を約束してはいけません1。
インタフェースと契約
また、Design by Contractの考え方は、特に実装とインタフェース(抽象)を積極的に切り離していく世界においても重要になります。 インタフェースとは、それを実装するクラスが満たすべき契約そのものです。
次のような労働者(Worker)インタフェースを例に考えてみましょう。 関数 work() は(本題と関係ないため詳細には言及しませんが、)工数(ManHours)を与えることを事前条件とし、成果物(Deliverables)を返すことを事後条件とする関数です。
interface Worker {
work(manHours: ManHours): Deliverables
}
そしてWorkerインタフェースを実装する社畜(Employee)クラスとエリート社畜(EliteEmployee)クラスがあるとします。
class Employee implements Worker {
work(manHours: ManHours) {
// 中身は何でもいいですが
return deliverables
}
}
class EliteEmployee implements Worker {
work(manHours: ManHours) {
// 一般的なEmployeeにくらべるといい仕事をするかもしれません
return deliverables
}
}
インタフェースには常に契約がついてまわることに注意を払ってください。 上記の例におけるEmployeeクラスとEliteEmployeeクラスは、Workerとして捉えた場合には必ず同じ契約に従わなければなりません。
エリート社畜には長時間労働する能力があったり、サービス残業でも文句を言わなかったりと、Workerインタフェースの契約外では一般的な社畜とは異なる実装をされているかもしれません。 しかし、 work() を呼び出すためにクライアント2が満たすべき事前条件と、 work() が呼び出された際に満たされる事後条件・不変条件は同じでなければいけません。 このルールを守らない実装はリスコフの置換原則に違反することになります。
抽象に課せられた契約はその派生全てに及ぶため、安易なインタフェースの複雑化は再利用性や拡張性を損ないます。 さらに、契約を満たすためにクライアント側では余計な依存・結合が必要になる可能性が高くなります。 原則としてインタフェースは可能な限りシンプルに保ち、異なる契約が必要になった場合は新しいインタフェースを作るべきです。
単体テストとの関連
Design by Contractを採用することで、単体テストの意義も明らかになり、テストの作成に明確な指針を設けることができます。
単体テストは、それ自体が関数とクライアントの間の契約を表現したものになります。 単体テストが保証すべき内容は大まかにわけて次の2つです。
- 事前条件を満たした場合に、事後条件・不変条件が満たされること
- 事前条件を満たした場合に、事後条件・不変条件が満たせない場合は例外を送出すること
契約の内容を保証するものなので、事前条件が満たされないケースをテストしてはいけません。 テストして動作を保証するのであれば、それは関数の仕様であり、契約の一部となります。
事前条件を満たさなかった場合をテストしないことに関して、不安に感じる方がいるかも知れません。 しかし、それはテストしないことが問題なのではなく、契約のほうが要求する品質に対して不十分であることが原因と言えます。 不安なのであれば契約を再設計し、関数の仕様を見直した上でテストをかけばOKです。 仕様にないものをテストしたところで得られるものは偽りの安心感だけです。
まとめ: その契約は言語化できるか
Design by Contractの優れているポイントは、関数の振る舞いを事前条件・事後条件・不変条件の3つに整理して言語化できるようにするところです。 事前条件・事後条件・不変条件はなにかを意識しながら関数を作る癖をつけると、契約を自分の言葉で説明できない関数が生まれなくなったり、契約が過剰に複雑になってしまうことを回避できるようになったりします。 コードレビューの際にも、契約が妥当かどうかを観点として用いることでコード品質の向上が期待できます。
Design by Contractを取り入れる際にコストはほとんどかかりません。 変える必要があるのはチームメンバーの考え方だけです。 あなたのチームでシステム設計をエンジニアの良識に頼っているのであれば、Design by Contractは設計の概念を統一するための良い指針になるのではないかと思います。