LoginSignup
59
33

More than 3 years have passed since last update.

C++20 Contract

Last updated at Posted at 2019-02-24

この記事は、ブログ記事を色々した版です(提案文書読んでブログに書き終えてからドラフトに入ってることに気付いた)。

基本的に、以下の文献に基づきます。

この機能はC++23に延期になりました、ご注意ください

Contract programming 或は Design by Contract (DbC) とは

Wikipedia - 契約プログラミング

D4Cではない。

三行で

Contract Programming(契約プログラミング)、あるいは Design by Contract(契約による設計・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
    • postcondition(事後条件)。関数が戻る際に満たされているべき条件です。
    • 関数から戻る直前23に実行されます。
    • これのみ、戻り値4を表現するために追加のidentifierを書くことができます。
  • 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]];
    // ...
}

この関数は、呼び出されるときの事前条件として、引数xx > 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]];

この関数は、呼び出される際の事前条件として、xNaNではなく、かつ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++を楽しみましょう!


  1. immediately before starting evaluation of the function body. 

  2. immediately before returning control to the caller of the function. 

  3. 例外送出やlongjmpによる脱出時には実行されません。 

  4. glvalueまたはprvalueとして受け取れます。 

  5. https://cpprefjp.github.io/implementation-compliance.html 

59
33
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
59
33