この記事は、ブログ記事を色々した版です(提案文書読んでブログに書き終えてからドラフトに入ってることに気付いた)。
基本的に、以下の文献に基づきます。
- N4800 - Working Draft, Standard for Programming Language C++
-
P0542R3: Support for contract based programming in C++
- by G. Dos Reis, J. D. Garcia, J. Lakos, A. Meredith, N. Myers, B. Stroustrup
この機能はC++23に延期になりました、ご注意ください
Contract programming 或は Design by Contract (DbC) とは
D4Cではない。
三行で
Contract Programming(契約プログラミング)、あるいは __D__esign __b__y __C__ontract(契約による設計・DbC)とは、関数などを呼び出す前に満たすべき条件(事前条件)、関数から戻る際に保証される条件(事後条件)、常に成り立っているべき条件などを明示し、それらが満たされているかどうか検査できるようにするスタイルのことです。
何が嬉しいか
関数が呼び出し前に要求する条件や、関数が戻り値に対して保証する条件は、コメントやドキュメントのような強制力のない媒体よりもコードそのものに埋め込まれていたほうがよいものでしょう。ある種のテストと考えることもできますが、コンパイル時のオプション一つでプロダクションコードの中にも埋め込めるというメリットがあります。リリース前のチェックとして便利になるでしょう。
さらに、条件に種類を設けることで、誰が悪いのかがわかりやすくなります。例えば、事前条件が破られていたならば、その関数を呼ぶ前にするべきことがあったのを呼ぶ側が忘れている・認識していないということがわかります。事後条件が破られていたならば、関数の実装が間違っていることがわかります――正しい条件が書けているならば。
加えて、このような条件に特別な記法を与え、コンパイラに教えてやることで、確実に満たされている条件式はスキップしたり、満たしようのない条件が現れるとコンパイルエラーにしたり、と静的検査をより強力にする効果も期待されています。
文法
色々提案されましたが、Contractは関数に対するattribute
の一種になることになりました。形式的には、その文法は以下のようになります(§9.11.4)。
contract-attribute-specifier:
[[ expects contract-level_opt: conditional-expression ]]
[[ ensures contract-level_opt identifier_opt: conditional-expression ]]
[[ assert contract-level_opt: conditional-expression ]]
contract-level:
default
audit
axiom
contractには以下の3つの種類があります。
-
expects
- precondition(事前条件)。関数が呼ばれた際に満たされているべき条件です。
- 関数の本体が実行される直前1に実行されます。
-
ensures
-
assert
-
assertion
(アサーション)。書かれた場所で満たされているべき条件です。
-
事前条件と事後条件をまとめて、契約条件(contract condition)と呼びます。
また、それぞれの種類のcontractには、検査するべきタイミングを制御するためのレベル(contract-level
)があります。ログレベルみたいな感じです。このcontract-level
は省略可能ですが、指定する場合以下の3つのどれかになります。
-
default
- レベルが省略された場合はこれになります。
default
モードとaudit
モードの両方で検査されます。 - これに含まれる条件式の評価は、関数そのものの実行コストに比べて軽い、または重くはないと想定されます。
- レベルが省略された場合はこれになります。
-
audit
-
audit
モードでのみ検査されます。default
モードでは検査されません。 - これに含まれる条件式の評価は、関数そのものの実行コストに比べて重い、または遜色ないと想定されます。
-
-
axiom
- これは特殊なレベルで、いかなるモードでも検査されません。
- 「検査するまでもなく成り立つ条件」という立ち位置で、「正式なコメント」扱いになります。
これらを制御するため、ビルドレベルという概念があります。ビルドレベルは、処理系によって定められる方法でビルド時に設定します。何も設定しなかった場合、レベルはdefault
になります。
以下の表に、それぞれの組み合わせで検査される場合(○)とされない場合(×)が示されています。
contract-level \ build-level | off | default | audit |
---|---|---|---|
default | × | ○ | ○ |
audit | × | × | ○ |
axiom | × | × | × |
具体例
ここらで一度、具体例を出してみましょう。
int take_positive_int(int x) [[expects: x > 0]]
{
// function body...
[[assert: x > 0]];
// ...
}
この関数は、呼び出されるときの事前条件として、引数x
がx > 0
を満たすことを要求しています。負の値を渡すと契約違反になります。また、関数のなかばでx > 0
が再度表明されています。ここでx
が負の値になっていた場合、これも契約違反となります。
int pop_front(std::vector<int>& v) [[expects: !v.empty()]];
この関数は、呼び出されるときの事前条件として、!v.empty()
を要求しています。空のベクタを渡すと契約違反になります。
bool is_prime(std::uint64_t k);
std::uint64_t nth_prime(std::uint64_t n, std::vector<std::uint64_t>& prime_table)
[[ensures audit retval: is_prime(retval)]];
[[ensures : !prime_table.empty()]]
この関数は、戻る際の事後条件として、その戻り値(retval
)が素数であること(厳密に言うと、is_prime
に渡すとtrue
が返ってくること)と、prime_table
が空でなくなることを保証します。このとき、戻り値が素数であるかどうかをチェックするのは少し時間がかかると想定できるため、レベルをaudit
にしています。そのため、この条件はdefault
モードでは検査されません。
ついでに、実行後にprime_table
が空でなくなることから、この関数はn
番目の素数がprime_table
に入っていなければn
番目までの素数をテーブルに追加するのだろうな、ということが推測できます。
double clamp(double x, double low, double high)
[[expects: !std::isnan(x)]]
[[expects: low <= high]]
[[ensures: low <= x && x <= high]];
この関数は、呼び出される際の事前条件として、x
がNaN
ではなく、かつlow <= high
であることを期待します。また、戻る際の事後条件として、 $x \in [low, high]$ を保証します。
ちなみにこのように複数個の条件を書いた場合、上から順に実行されます。この際、規格では以下のような例が挙げられているので(N4800 §9.11.6.2/6)、同じ種類の契約条件のうち最初の一つが落ちたら残りは評価されないと考えるべきでしょう。
void f(int* p)
[[expects: p != nullptr]] // 関数呼び出しの際に最初に実行される
[[ensures: *p == 1]] // 関数から戻る際に実行される
[[expects: *p == 0]]; // 関数呼び出しの際に2番めに実行される
具体例を見ると、便利そうな気がしてきませんか?
関数の前方宣言に関する注意点
関数の前方宣言、あるいはインライン関数のヘッダを何度も読み込む場合など、同じ関数が何度か出てくる場合があります。そのような場合、2回め以降の登場時には、その関数は、「契約条件が完全に省略されている」か、「完全に同一の契約条件のリストを持つ」必要があります。
具体例を出しましょう。
int f(int x)
[[expects: x>0]]
[[ensures r: r>0]]; // 前方宣言
int f(int x); // OK. 全て省略されている
int f(int x)
[[expects: x>0]]; // error. 条件が足りない
int f(int x)
[[expects: x>0]]
[[ensures r: r>0]]; //OK. 同じ条件
ここで、「契約条件のリストが完全に同一である」とは、「同一の契約条件が」「同じ順番で記載されている」ことを指します。順序が保たれる必要があるのは、複数の事前・事後条件があった場合、その実行順序は書いた順になるからです。
さらに、「同一の契約条件」とは、同じレベルの、同じ条件式を持つものを指します。雑に言うと、変数名が変わっても構わないが、条件式は同じである必要があるという感じです。
int f(int x)
[[expects: x>0]]
[[ensures r: r>0]];
int f(int y)
[[expects: y>0]] // OK. 引数の名前は違うが、同じ意味
[[ensures z: z>0]]; // OK. 戻り値の名前は違うが、同じ意味
ちなみに、複数の条件式が常に同じ値になる限り、この条件に反していても診断不要(no diagnostic required)5です(N4800 §9.11.4.2/1)。
テンプレート関数の扱い
もちろん、テンプレート関数にも契約条件を与えることができます。そして、テンプレート関数の明示的特殊化を行った場合、それらの特殊化された関数の間では契約条件が同一である必要はありません(N4800 §12.8.3/14)。
template<typename T>
T calc_stuff(T x);
template<>
int calc_stuff(int x)
[[expects: x > 0]]
[[ensures r: r > 0]]
{/*...*/}
template<>
double calc_stuff(double x)
[[expects: !std::isnan(x) && x > 0]]
[[ensures r: !std::isnan(r) && r > 0]] // OK. calc_stuff<int>と同じである必要はない
{/*...*/}
関数ポインタの扱い
関数ポインタには契約条件を付けることはできません。
typedef int (*f)(int) [[ensures r: r != 0]]; // error. 関数ポインタは契約条件を持てない
ただし、契約条件を持つ関数を普通の関数ポインタに代入することは可能です。関数ポインタ経由で契約条件を持つ関数を呼び出した場合、契約条件は検査されます(検査が行われるモードでビルドしている場合)。
int g(int x)
[[expects: x >= 0]]
[[ensures r: r > x]]
{
return x + 1;
}
int (*pf)(int) = g; // OK. pfにgを代入可能
int x = pf(5); // 契約条件が検査される
禁止事項
契約条件には、いくつかの禁止事項があります。
まず、外にある値を変更してはいけません。
int min = -42;
int f(int x)
[[expects: ++x > 1]] // undefined behavior! x を変更した
{
[[assert: ++min > 0]]; // undefined behavior! min を変更した
}
constexpr関数に対する契約条件では、外にある非constexprな値を参照できません。
int min = -42;
constexpr int f(int x)
[[expects: x >= min]] // error. min は constexpr でない
{/*...*/}
事後条件が関数の引数を使う場合、その引数を関数内部で変更すると未定義動作となります。
int f(int x)
[[ensures r: r == x]]
{
++x; // undefined behavior. 事後条件で参照されている x が変更されている
return x;
}
int g(int* p)
[[ensures: p != nullptr]]
{
*p = 42; // OK. pの値そのもの(pが表すアドレス値)が変更されたわけではない
return x;
}
契約違反時の挙動
契約条件が破られた場合、violation handler
が呼ばれます。これは、以下のようなシグネチャを持つ関数で、名前は定まっていません。
void __Violation_handler(const std::contract_violation&);
この関数を変更する方法は標準では提供されません。提供する場合は、その方法は処理系定義です。提案時点から、著者らはセキュリティの観点からこれを変更できるべきではないと主張していました。
この関数は処理系によって与えられるので、何をするかも処理系定義でしょう。とはいえ、流石に無意味な関数を指定するわけにもいかないので、contract_violation
に入っている情報を綺麗にフォーマットするような関数になっていることが期待されます。そして、contract_violation
には以下のような情報が入っています。
contract_violation の中身
std::contract_violation
は<contract>
で定義されるクラスで、以下のインターフェースを持っています。
namespace std {
class contract_violation {
public:
uint_least32_t line_number() const noexcept;
string_view file_name() const noexcept;
string_view function_name() const noexcept;
string_view comment() const noexcept;
string_view assertion_level() const noexcept;
};
} // std
大体何が格納されているか名前からわかりますが、comment
だけはひっかかるかも知れません。これには、違反された契約条件を表す処理系定義の文字列が入っています。これも実際には、[[assert: x > 0]]
なら"x > 0"
とかが入っていると期待していいと思います。
より重要なのは、このクラスが指す位置でしょう。
事前条件が破られた際は、このクラスが指す位置は処理系定義です(!)。事前条件を守らずに呼び出した箇所を報告することが奨励されていますが、義務ではありません。コンパイラ開発者が無理だと判断した場合は、適当な場所を指してかまいません。現実的には、あまりにも呼び出し箇所を特定するのが難しい場合は事前条件を破られた関数の場所が報告されるでしょう。そういうケースがあるかはわかりませんが。
事後条件が破られた際は、関数が定義された箇所を指します。アサーションが破られた場合は、そのアサーションがあった場所を指します。これは普通ですね。
契約違反時の挙動の制御
契約違反を起こした場合、violation handler
が呼ばれることは確実なのですが、その後どうするかは制御することができます。この制御のためviolation continuation modes
というものがあり、これはoff
またはon
をビルド時に選択できます。デフォルトのモードはoff
です。
off
を設定した、あるいは何も設定しなかった場合、違反時にプログラムの実行を継続しないことになり、violation handler
の呼出しの後にstd::terminate
が呼び出されます。
これをon
にすると、std::terminate
は呼び出されません。そのまま実行が継続します。失敗した場合もしばらくログを取りたい場合などが考えられるため、このようなモードが導入されました。
ちなみに、violation handler
が例外を送出して終了した場合、関数の内部から例外が送出されたかのように見えます。なので、noexcept
な関数の契約条件が破られてviolation handler
が例外を送出すると、noexcept
関数が例外送出したことになるのでstd::terminate
が呼ばれます。
細かいこと
ところで、N4800 §9.11.4.3/4に「定数式評価時は現在のビルドレベルで検査される条件式のみが評価されるが、それ以外の場合は、その時点でのビルドレベルでは実行されない検査の条件式が評価されるかどうかは未規定で、そのような条件式がfalse
と評価された場合の挙動は未定義である」というようなことが書いてあります。
ややこしいですが、default
ビルドレベルだとdefault
の契約条件は必ず検査され、audit
な契約条件は検査されないものの、検査されないだけでaudit
な契約条件に対応する条件式が絶対に評価されないとは言っていない、ということでしょうか。
検査は行わないが、条件式の評価はする可能性があり、その条件式がfalse
と評価された場合、未定義動作になる。実質はチェックが走っているのと同義ですね。ビルドモードによらず常に条件式を評価して、未定義動作の一種としてviolation handler
を呼ぶような超厳格な堅物コンパイラを作るのは規格準拠っぽいですが、ユーザーにボコボコにされるでしょうから、実際にはチェックは走らないでしょう。多分。デバッグモードだと全てを走らせる、みたいなのは登場するかもしれません。
そう考えると、この一節はaxiom
な契約条件を検査するコンパイルオプションを許すためにあると考えることもできそうです。axiom
な契約条件はいかなるモードでも検査はされませんが、条件式の評価はしてもよい上に、条件式がfalse
なら何が起きてもよいので、violation handler
が呼ばれても構いません。これは実質的には検査が走っているのと変わりません。
しかし、これは要するに、axiom
な条件を書いておくと実行時のコストなしで条件に違反するコードを未定義動作の魔界に叩き落とすことが可能ということを意味しているわけです。もしかすると異様に賢いコンパイラが、axiom
な契約条件が必ず破られることに気付いて、あなたの鼻から悪魔を出す準備に勤しむかもしれません……。
まとめ
C++20に入るであろうContractですが、結構便利そうですね。もっとがっつり馬鹿デカいのが入ってきてひえーなんじゃこりゃ、いつ使えるようになるんだよ、となるかと思いきや、必要な機能に絞った仕様というように感じました。
C++20はこの他にも多くの便利機能が入る予定なので、これからもどんどん便利になっていくC++を楽しみましょう!