短答は「できる」、ただし条件付きだ
Rustは開発中にランタイムcontractsを強制し、リリースビルドから完全に消し去ることができる。ただし、この言語はcontractsを第一級の概念として扱っていない。ビルディングブロックは手に入るが、自分で配線を引く必要がある。
debug_assert!は明白な出発点だ。デバッグビルドでは評価され、リリースでは何も生成しない。シンプルなinvariantsにはそれで十分だが、本物のcontractsにはpreconditions、postconditions、そして時にはカスタム失敗動作を伴うloop invariantsが必要だ。それらは標準では何も提供されていない。
この記事では、外部クレート不要で、リリースビルドで文字通りゼロオーバーヘッドとなる、実際のdesign-by-contractセマンティクスを実現するパターンを紹介する。
Rustでランタイムcontractsが重要な理由
Rustの型システムとborrow checkerは、コンパイル時に膨大なクラスのエラーを捕捉する。しかし、論理エラーやboundary violations、破損したビジネスinvariantsは捕捉できない。
NonZeroU32を引数に取る関数を書いても、間違った値で除算できる。型システムでslice boundsを強制しても、間違ったsliceを渡せる。contractsは「コンパイルが通る」と「正しい」の間の隙間を埋める。
異論は常にパフォーマンスだ。システムプログラミングにおいて、リリースビルドは約束だ。ランタイムチェックのための余分な分岐命令は、その約束を破るように感じられる。
目標は、それらのチェックを役立つ場所——開発とテスト中——に保ち、ユーザーが実行するバイナリーからは取り除くことだ。
Rustが標準で提供するもの
Rustには2つのアサーション群がある。assert!とassert_eq!はすべてのビルドで実行される。debug_assert!とdebug_assert_eq!はcfg(debug_assertions)が有効な場合のみ実行され、デフォルトではデバッグビルドで有効だ。
pub fn withdraw(balance: &mut i64, amount: i64) {
// 常に実行される。リリースでもコストを支払う。
assert!(amount > 0, "withdrawal must be positive");
assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
pub fn withdraw_debug_only(balance: &mut i64, amount: i64) {
// リリースビルドでは消える。
debug_assert!(amount > 0, "withdrawal must be positive");
debug_assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
これはアドホックなチェックには十分だ。しかし、体系的なcontractsには不十分だ。理由は以下の通り:
- preconditions、postconditions、invariantsのための組み込み構文がない
- ビルドプロファイルごとにcontractレベルを簡単に切り替えられない
- パニックする代わりにログを出力するようなカスタム失敗動作は、すべての呼び出しをラップする必要がある
マクロでゼロコストcontractsを構築する
うまくいくパターンは、デバッグではチェックに展開され、リリースでは何も生成しない小さなマクロ群だ。トグルはカスタムcfgフラグで制御すればリリーステストでもcontractチェックを有効にでき、debug_assertionsに直接紐付けてもよい。
以下は、完全に動作する実装だ:
// contracts.rs
/// Precondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! requires {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Precondition violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
/// Postcondition check. Compiles to nothing in release.
#[macro_export]
macro_rules! ensures {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Postcondition violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
/// Invariant check for data structures. Compiles to nothing in release.
#[macro_export]
macro_rules! invariant {
($cond:expr $(, $msg:tt)*) => {
#[cfg(debug_assertions)]
{
if !$cond {
panic!(
concat!("Invariant violated: ", stringify!($cond))
$(, ": ", $msg)*
);
}
}
};
}
使い方は単純だ:
use contracts::{requires, ensures, invariant};
pub struct Account {
balance: i64,
}
impl Account {
pub fn new(initial: i64) -> Self {
requires!(initial >= 0, "initial balance cannot be negative");
let acct = Self { balance: initial };
ensures!(acct.balance >= 0);
acct
}
pub fn withdraw(&mut self, amount: i64) {
requires!(amount > 0, "amount must be positive");
requires!(
amount <= self.balance,
"amount must not exceed balance"
);
self.balance -= amount;
ensures!(self.balance >= 0, "balance must stay non-negative");
invariant!(self.is_consistent());
}
fn is_consistent(&self) -> bool {
self.balance >= 0
}
}
デバッグビルドでは、すべてのチェックが実行され、明確なメッセージでパニックする。リリースビルドでは、マクロは空のブロックに展開される。オプティマイザは何も見ず、命令を生成しない。
リリースビルドが実際に無料であることを検証する
マクロを信じるだけでは不十分だ。アセンブリを確認すべきだ。
withdraw関数をリリースモードでビルドし、出力を確認する:
rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs
マクロがcfg(debug_assertions)を使っている場合、Account::withdrawのリリースアセンブリには、パニック分岐も文字列定数も、算術そのもの以外の余分な比較も含まれない。contractチェックは完全に存在しない。
出力にパニック関連のシンボルが見つかる場合、マクロにバグがあるか、リリースプロファイルでdebug-assertions = trueを有効にしてビルドしている。
ぶつかる「プロファイルの罠」
CargoはCargo.tomlを通じて、リリースビルドでdebug assertionsを有効にできる:
[profile.release]
debug-assertions = true
一部のチームは統合テストで問題を捕捉するために一時的にこれを有効にする。cfg(debug_assertions)をcontractのトグルとして使っている場合、これによりリリースバイナリーが突然遅くなる。テスト用ならそれでよいかもしれないが、ゼロコストを期待していたら驚きだ。
より細かな制御が必要な場合は、debug_assertionsの代わりにカスタムfeatureを使う:
#[cfg(any(feature = "contracts", debug_assertions))]
これで、contractsはデフォルトでデバッグビルドで実行され、特定のテスト実行やcanary deploymentでその追加の安全性が必要な場合に、--features contractsでリリースでも明示的に有効にできる。
なぜこれはEiffel流のdesign-by-contractではないのか
本物のdesign-by-contract言語は、contractsを継承し、モジュール境界を越えて合成し、時には静的に証明することすらできる。Rustのマクロはそれらのどれも提供しない。
得られるものは実用的だ。チェックは呼び出しサイトで実行される。失敗時にはパニックする。リリースでは消える。これは、開発中に膨大なクラスのバグを捕捉し、オーバーヘッドをproductionに運ばないのに十分だ。
意図を文書化するのにも十分だ。関数の先頭にあるrequires!ブロックは、次の読者に著者が何を仮定したかを、実行可能な形で正確に伝える。
どんなcontractをリリースに残すべきか
すべてのcontractが消えるべきではない。安全性が重要なinvariants、外部入力の検証、セキュリティチェックは、debug_assert!やカスタムcontractマクロではなくassert!に属する。
ルールはシンプルだ: 条件違反がメモリ安全性の損失、データ破損、またはセキュリティ脆弱性を意味するなら、リリースでもチェックを残す。テストで捕捉されるべき論理エラーには、ゼロコストパターンを使う。
// リリースに残す。メモリ安全性がこれに依存している。
assert!(!ptr.is_null());
// リリースで除去する。論理バグだがunsafeではない。
requires!(discount_rate <= 1.0);
1つのクレートにまとめたパターン
自分でマクロを管理したくない場合、crates.ioにあるcontractsクレートがほぼこのパターンを実装している。関数定義やstruct定義にアタッチする#[requires]、#[ensures]、#[invariant]というproc macroを提供する。
内部では、同じcfg展開を使ってリリースでチェックを除去する。主な利点はより良い構文だ。主なコストはproc-macroの依存関係だ。
すでにsynとquoteに依存しているチームには、このクレートは良い選択だ。ゼロ依存を望むチームには、上記のマクロパターンは20行で、Cargo.tomlの変更も不要だ。
1つの関数から始めよう
codebase全体をアノテーションする必要はない。trickyなinvariantを持つ1つの関数を選び、requires!とensures!を追加して、テストを実行し、リリースのアセンブリを確認する。
デバッグで実際のバグを捕捉しながら、チェックが消えるのを目の当たりにすれば、価値は明らかになる。contractsは導入すべきフレームワークではない。1つずつ、習慣として身につけるものだ。