そういえば最近はC++関連のことに触れていないなと思ったので、今日はまだ日本語の説明があんまりないように見えるContractでも取り上げてみたい。サーベイ不足だったら申し訳ない。提案に関する情報は、
P0542R3: Support for contract based programming in C++
by G. Dos Reis, J. D. Garcia, J. Lakos, A. Meredith, N. Myers, B. Stroustrup
に基づく。ただし、この提案はまだ固まっていないため、今後も変更される恐れはある。
とか言ってたら最新のドラフトにもう入ってる上にちょっと変わってるじゃねーか!!
最後に変更点とか書いたので許して
(2/24追記):{QiitaでN4800に準拠したバージョンの記事を公開した。このブログでは歴史的経緯やどういうことが期待されるかに関する寄り道が多いが、Qiita版はドラフトで何が書かれているかに焦点を当てて書いているので、背景事情などどうでもいいという方はそちらを見ていただいた方がいいかもしれない。C++20 Contract - Qiita}
ちなみに私はContract programmingどっぷりなどということは一切なくて、昔ちょっと触って「ほーん便利だなー、でもBoostとはいえマクロでこんなゴリゴリやるのはつらいなー……やめとこ」となってしまった人間だ。なのでContract Programmingを崇めている人からしたらテキトー言ってるしれないことをdiscraimerしておく。
ちなみに、Boost.Contractに関しては日本語の情報もそれなりに出て来る。Contract Programmingそのものに関してもかなり情報があるので、「Contract programmingとは」の部分は読まずに普通にそっちを見てもらっても構わない。
Contract programmingとは
とはいえ最初から規格の説明をするのではわけがわからなくなるので、先に Contract Programming とはなんぞやという話を(私の主観込みで)やっておこう。だいたい知ってる、という人はスルーしていただきたい。
Contract Programming(契約プログラミング), あるいは Design by Contract (契約による設計)は、雑に言うと関数などを呼ぶ時に満たされているべき制約(事前条件)、返るときに満たされている制約(事後条件)、何回呼んでも変わらない条件(不変条件)などを明示的に書いていくスタイルだ。Contract Programming のための機能を言語側で提供することにより、簡単にチェックを入れることができたり、コンパイルオプションでチェックのレベルを変えたりできる。コンパイラが賢ければ、そのような関数が連なっている時に必ず満たされる制約はチェックを自動で外したり、果ては守りようのない制約をコンパイルエラーで落としたりできるだろう。
事前条件は、関数を呼び出す際に守られているべき条件のことだ。通常は引数について期待される条件になる。簡単な例は、
- 数値計算をする関数が、数値が inf や nan でないことを期待する
- 文字列処理をする関数が、UTF-8エンコーディングされていることを期待する
- 配列の要素を検索する関数が、配列がソートされていることを期待する
- コンテナの先頭を返す関数が、コンテナが空でないことを期待する
などだ。Contract programmingをサポートする言語では、これに専用の記法が導入され、コンパイルオプションや実行時フラグなどによってアサートがされたりされなかったりする。
事後条件は、関数が返る時に満たされるべき条件のことだ。通常は返り値について期待される条件になる。簡単な例は、
- 数値計算をする関数が、結果が必ずある範囲に収まることを保証する
- 要素を検索する関数が、見つかったなら返却値は元の配列に含まれていることを保証する
- コンテナの要素をフィルタする関数が、戻り値は全て条件を満たしていることを保証する
これも専用の記法によってアサートのレベルを色々といじることができるようになる。関数を実装する時は、条件が複雑すぎてめんどいということはあれど、戻り値が満たしている条件が全くないということはあまりないと思う(そういう関数は、一体何をするのか不明確すぎる気がする)。
不変条件は、プログラムを実行しても変化しない条件のことだ。その関数が変化させないもの、その実行を通して真であるような条件を指す。これをチェックする場所は難しいと思う。任意の瞬間に成り立っているはずの条件がある場合、完璧にチェックするためには1命令ごとにチェックするべきだが、そんなことをするとプログラムが圧倒的に遅くなるのでそういうことは普通しない。大抵は、怪しいところにassert
を入れるという形になる。
どれも、ドキュメントやコメントのような強制力のない媒体に書くよりもコードに入っていた方が圧倒的に良いことは疑いようもない。どの条件が満たされなかったかで、関数の内部にバグがあるか、関数を呼び出した側が下手を打ったが明らかになる(正しい条件を書けていれば)。通常のテストよりも少しだけ情報量が多くなり、問題のある箇所が絞りやすくなる。
さらに、これらの条件に特別な記法が導入されてコンパイラが理解できるようになったなら、十分賢いコンパイラはプログラムの妥当性をより適切に評価でき、またチェックをしつつも回数を最低限に留めることでパフォーマンスと両立することも可能になって来ると期待される。例えば、最初の関数の事後条件で「返り値が必ず偶数になる」と保証されていたとして、その返り値をconst
で受けとった後「引数が偶数であることを期待する」事前条件を持つ別の関数に渡すとする。通常のassert
でContract Programmingを真似ていたなら、2回チェックが挟まるだろう。最初の関数の最後と、2番目の関数の最初だ。だが専用構文が用意されていれば、十分賢いコンパイラは2番目の関数の最初にチェックは要らないことに気づくかもしれない。もしそうなれば、不要な2度目のチェックだけが省略されて、頑健で高速なバイナリが完成する。あるいは、その変数を「引数が偶数でないことを期待する」関数に渡した場合、コンパイラは絶対にそれが動かないことを指摘してくれるかもしれない。これは結構嬉しくないだろうか。
実際にコンパイラがそのレベルまで賢くなるかはわからないが。あるいは、十分賢いコンパイラは普通のassert
でも明らかに通るとわかる時は取り除くかもしれないが。
C++20 Contractの記法
というわけで提案文書を見ていこう。
概要
条件を書くのには、attributeの構文が用いられる。
int f(int x) [[contract-attribute (contract-level): conditional-expression]] { // function body ... }
Contractはfunction type
に対して適用されるため、ここで示されている位置に来なければならない。
ここで、contract-attribute
は以下のどれかだ。
expects
: precondition(事前条件)ensures
: postcondition(事後条件)- これのみ、
contract-level
の後にidentifier
を書くことができる。その名前で関数の返り値を参照できる。任意の名前をつけてよい。
- これのみ、
assert
: assertion(関数内に書かれる普通のassertと似たような感じ)
contract-level
は省略できる。書く場合は以下のどれかだ。
always
- どんなビルドモードでも検査される。その性質上、検査のコストは無視できることが期待される。
assert
に対してしか使用できない。
- どんなビルドモードでも検査される。その性質上、検査のコストは無視できることが期待される。
default
- 何も書かなかったらこれになる。
default
、またはaudit
モードで検査される。関数そのものの実行コストと比べると軽い(が本気の最適化の邪魔にはなる程度の)チェック。
- 何も書かなかったらこれになる。
audit
audit
モードでのみ検査される。関数そのものの実行コストと比べても見劣りしないかより重いチェック。デバッグの時にチェックしたいような条件。
axiom
- 検査するまでもないレベルの条件で、ほとんど「正式なコメント」扱い。いかなるモードでもチェックされない。これ要る? メモ用かな?
always
に関しては少し揉めたっぽくて、ミーティングで投票などが行われている。
conditional-expression
は、満たされるべき条件式だ。そこにどんな式が許されるかの詳細に入る前に、簡単な例をいくつか提案文書から引用しておこう。
int f(int x) noexcept [[expects audit: x>0]] [[ensures axiom res: res>1]];
関数f
は、引数x
が0より大きいことを期待しており(expects audit: x>0
)、返り値res
が1より大きいことを保証している(ensures axiom res: res>1
)。ただし、事前条件の検査はaudit
モードでないとなされないし、事後条件はaxiom
なのでいかなるモードでも検査されない。
void g() { int x = f(5); int y = f(12); //... [[assert: x+y>0]] //... }
関数g
は、x + y
が正であることを表明している。レベルはデフォルトなので、default
モードとaudit
モードで検査される。
ちなみに、これらの単語(audit
、axiom
など)を別の文脈で使うことは許される。[[expects audit: autit == 0]]
のようにも使える。この構文の中のどこに出現するかによって、パーサーはこのトークンが何を意味しているかわかるはずだからだ。
とまあ、こんな感じだ。ではもう少し深く潜ってみよう。
契約違反時の挙動
条件が破られると、violation handlerが呼ばれる。ただし、以下の関数の名前は定められておらず、ユーザーが直接呼んだり、関数ポインタを渡してそれに差し替えるというようなことはできない(のだが、なぜかユーザーが指定した場合の話もされている。意見が分かれているのだろうか)。差し替えることが簡単にできるならセキュリティ的な問題が発生しかねない、と筆者らは主張している。
void _Violation_handler(const std::contract_violation &);
ここで、このstd::contract_violation
は<contract>
ヘッダで定義されている構造体で、以下のようなものだ。
namespace std { class contract_violation { public: int 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; }; }
概ね自明な関数名だが、comment
だけ不明瞭かもしれない。ここには破られた条件を文字列にしたものが入っている。ソースコード内のコメントではない。
条件が破られた時、このクラスは、assert
が失敗した時はその[[assert]]
が書かれていた場所を、関数の事前条件([[expects]]
)が破られた時は条件を破って関数呼び出しを行った場所を、関数の事後条件([[ensures]]
)が破られた時はその関数の定義部分を、それぞれ指すようになる。
さらに、違反時の挙動はもう一段階制御可能で、violation continuation mode
と言うのをビルド時にon/offできる。デフォルトはoffで、その場合ハンドラが呼ばれたのちstd::terminate
によってプログラムは終了する。もしonにしたなら、ハンドラが呼ばれたのち、プログラムは継続して実行される。これは、主にログを取る目的で導入されたようだ。
さて、実際に使用するためにはもう少し情報が要るはずだ。もう一段深く潜ることにしよう。
関数のredeclarationと継承
関数の前方宣言をするとか、inline
関数が入ったヘッダを複数回読み込むとか、関数の宣言が複数回現れることは少なくない。このような場合、その関数の事前・事後条件は、「1. 完全に同じ条件である」か、「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]]; // 名前は違うが、構わない
だいたいわかってきた。だがもうちょっと知っておかないと不安になる。もう少し行こう。
条件式として書ける式
条件式には、ほとんどどんな式でも書ける。
int f(std::vector<int>& v) [[expects: v.size() >= 10]] [[ensures: !v.empty()]]; // OK.
ただし、変数を変更した場合、その動作は未定義になる。
int f(int x) [[expects: --x>0]]; // undefined! xを変更した
constexpr関数に対する条件式で、コンパイル時定数でないものを使ってはならない。どのタイミングで実行されるかを考えると当然だろう。
int min=-42; constexpr int g(int x) [[expects: min<=x]] // Error. minはコンパイル時定数ではない {/*...*/}
条件が複数並んでいたら、上から実行される。
void f(int * p) [[expects: p!=nullptr]] [[expects: *p == 0]] // p!=nullptr の後に実行されるのでセーフ(?) { *p = 1; }
これ、violation continuation mode
がon
だとめでたくUBな気がするが……? それとも、p!=nullptr && *p == 0
のような扱いになっていて前半で落ちた場合後半ではviolation_handler
呼び出しは発生しないのだろうか?
std::tuple
などを返す関数の返り値についての条件では、構造化束縛を使える。
std::tuple f() [[ensures [x,y]: x>0 && y.size()>0]]; // OK. std::tuple f() // 使わなくてもよい [[ensures r: get<0>(r)>0 && get<1>(r).size()>0]];
[[ensures]]
で使っている値を、関数の中で変更してはいけない。function body
とあるのでどうやら一切変更してはならないようだ。なぜだろう。処理系はreturn
するべき値をレジスタに載せ終わった直後にチェックを挟むのだと思っていたが違うのだろうか。
int f(int x) [[ensures r: r==x]] { return ++x; // Ill-formed: xが変更された }
クラス内の関数の契約条件は、呼ぶ側がアクセスできない変数や関数を使ってはいけない。「その関数が」アクセスできない変数や関数ではないので注意。これは少しわかりにくい。
class X { public: int v() const; void f() [[expects: x>0]]; // Ill-formed. // f()は外部から呼ばれ得る void g() [[expects: v()>0]]; // OK. v()はpublic protected: int w(); void h() [[expects: x>0]]; // Ill-formed. // h()は継承先から呼ばれ得る void i() [[ensures: y>0]]; // OK. yはprotected void j() [[ensures: w()>0]]; // OK. w()はprotected int y; private: void k() [[expects: x>0]]; // OK. // k()はprivateなので内部からしか呼ばれ得ない int x; }; class Y : public X { public: void a() [[expects: v()>0]]; // OK void b() [[ensures: w()>0]]; // Ill-formed void c() [[expects: x>0]]; // Ill formed protected: void d() [[expects: w()>0]]; // OK void e() [[ensures: x>0]]; // Ill-formed };
また、継承先で関数をオーバーライドする場合、継承元の事前・事後条件も引き継がなければならない。
関数ポインタには事前・事後条件を与えることはできない。ただし、事前・事後条件をもつ関数のアドレスを関数ポインタに代入することは可能で、その場合もチェックが行われる。
typedef int (*fpt)() [[ensures r: r!=0]]; // Ill-formed int g(int x) [[expects: x>=0]] [[ensures r: r>x]] { return x+1; } int (*pf)(int) = g; // OK.
まとめ
と言うわけで、C++20に入ることになったContractの提案文書の内容をまとめた。
だいたい書き終わって細かい表現とか表記揺れとか統一しようかな、というタイミングで、最新ドラフトN4800にすでにContractが入っており(§9.11.4)、その上少し変更されていることに気がついた。ざっと見た限り、always
は結局なくなっている。あと、violation handlerの設定方法は処理系定義に落ち着いたようだ。
あとは基本的には変わっていない。とはいえ規格なので、超細かい話が追加されている。例外送出やlongjmp
での関数からの脱出では事後条件はチェックされないことや、Contractの条件式が常に同じ値を返す場合は同一性のルールに違反していても警告する必要がない(x < y
がy < x
に入れ替わっているというような場合)、などが追加されている。
ちゃんと最新のドラフトを追っていないと、アホな目に逢うということを痛感した回だった。
ともあれ、すでにドラフトに入っている程度には前向きに検討されているようなので、強化版assert
として今後使っていくことを考えてみる時期ではないだろうか。
(2/24追記)
N4800をちゃんと読んでみたところ、§9.11.4には継承した際のルールが書かれていない。恐らく継承する際のルールはなくなったのだろう。
それと、std::contract_violation
のline_number
が返す値の型がint
ではなくstd::uint_least32_t
になっている。まあ、負の行番号という概念は不思議すぎるので、妥当な変更ではなかろうか。