簡短答案是肯定的,但有個但書
Rust 可以在開發階段強制執行 runtime contracts,並在 release build 中將它們完全抹除。但書在於,這門語言並未將 contracts 視為 first-class concept。你拿到了積木,但得自己動手組裝。
debug_assert! 是最顯而易見的起點。它在 debug build 中執行,在 release 中編譯成什麼都沒有。這對簡單的 invariants 夠用,但真正的 contracts 需要 preconditions、postconditions,有時還要有 loop invariants 以及自訂的失敗行為。這些統統不是現成的。
這篇文章展示一種模式,讓你獲得真正的 design-by-contract 語義,在 release 中 literally 零成本,而且不需要任何 external crates。
為什麼 Runtime Contracts 在 Rust 中很重要
Rust 的 type system 和 borrow checker 能在編譯時攔截大量錯誤。但它們抓不到 logic errors、boundary violations,或是 business invariants 被破壞的情況。
你可以寫一個接受 NonZeroU32 的函數,卻還是拿錯的值去除。你可以用 type system 強制 slice bounds,卻還是傳入錯誤的 slice。Contracts 填補了「能編譯」與「正確」之間的縫隙。
反對的理由永遠是效能。在 systems programming 中,release build 是一種承諾。為了 runtime checks 而額外插入 branch instructions,感覺就像違背了這個承諾。
目標是讓這些檢查留在它們有用的地方——開發與測試階段——並從使用者實際執行的 binary 中移除。
Rust 出廠即附贈了什麼
Rust 有兩大 assertion 家族。assert! 和 assert_eq! 在所有 build 中執行。debug_assert! 和 debug_assert_eq! 只在 cfg(debug_assertions) 啟用時執行,而預設情況下 debug builds 會啟用。
pub fn withdraw(balance: &mut i64, amount: i64) {
// Always runs. You pay for this in release.
assert!(amount > 0, "withdrawal must be positive");
assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
pub fn withdraw_debug_only(balance: &mut i64, amount: i64) {
// Vanishes in release builds.
debug_assert!(amount > 0, "withdrawal must be positive");
debug_assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
這對 ad-hoc checks 沒問題。但對系統性的 contracts 來說不行,因為:
- 沒有內建語法來表達 preconditions、postconditions 或 invariants
- 無法輕易針對不同 build profile 切換 contract levels
- 自訂失敗行為——例如用 logging 取代 panicking——需要把每次呼叫都包起來
用 Macros 建立零成本 Contracts
可行的模式是一套小型 macro suite,在 debug 中展開為檢查,在 release 中展開為什麼都沒有。如果你想在 release tests 中也執行 contract checks,可以用自訂的 cfg flag 來控制開關;不然也可以直接綁定 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
}
}
在 debug build 中,每個檢查都會執行,並在失敗時 panic 並附上清楚的訊息。在 release build 中,macros 展開為空區塊。Optimizer 什麼都看不到,也不會產生任何 instructions。
驗證 Release Build 是否真的零成本
光相信 macros 是不夠的。你應該檢查 assembly。
用 release mode 編譯 withdraw 函數並檢查輸出:
rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs
當 macros 使用 cfg(debug_assertions) 時,Account::withdraw 的 release assembly 不會包含 panic branches、string constants,也不會有除了算術本身之外的額外 comparisons。Contract checks 完全不存在。
如果你在輸出中看到與 panic 相關的 symbols,要不是 macro 有 bug,就是你在 release profile 中設定了 debug-assertions = true。
你一定會踩到的 Profile 陷阱
Cargo 允許透過 Cargo.toml 在 release builds 中啟用 debug assertions:
[profile.release]
debug-assertions = true
有些團隊會暫時這樣做,以便在 integration tests 中抓到問題。如果你用 cfg(debug_assertions) 作為 contract 的開關,這會突然讓你的 release binary 變慢。測試時這可能是你想要的,但如果你預期的是零成本,那就會出乎意料。
如果你需要更細緻的控制,請使用自訂 feature 而非 debug_assertions:
#[cfg(any(feature = "contracts", debug_assertions))]
現在 contracts 預設會在 debug builds 中執行,而當你需要在特定 test run 或 canary deployment 中獲得額外安全性時,可以用 --features contracts 明確在 release 中啟用它們。
為什麼這不是 Eiffel 風格的 Design-by-Contract
真正的 design-by-contract 語言可以繼承 contracts、跨 module boundaries 組合它們,有時甚至能靜態證明。Rust macros 統統做不到。
你得到的是務實的東西。檢查在 call site 執行。失敗時 panic。在 release 中消失。這已經足以在開發階段攔截大量 bug,同時不會把 overhead 帶到 production。
這也足以表達意圖。函數開頭的 requires! 區塊會以可執行的形式,明確告訴下一位讀者作者假設了什麼。
什麼時候應該把 Contracts 留在 Release 中
並非所有 contracts 都該消失。Safety-critical invariants、external input validation 和 security checks 通常應該放在 assert! 裡,而不是 debug_assert! 或自訂的 contract macros。
規則很簡單:如果違反條件會導致 memory unsafety、data corruption 或 security vulnerability,就讓檢查留在 release 中。把零成本模式留給那些應該被 tests 抓到的 logic errors。
// Keep this in release. Memory safety depends on it.
assert!(!ptr.is_null());
// Strip this in release. A logic bug, but not unsafe.
requires!(discount_rate <= 1.0);
一個 Crate 裡的模式
如果你不想維護自己的 macros,crates.io 上的 contracts crate 幾乎實作了完全相同的模式。它提供 #[requires]、#[ensures] 和 #[invariant] 這些 proc macros,可以附加在函數與 struct definitions 上。
底層上,它使用相同的 cfg expansion 來在 release 中剝除檢查。主要好處是語法更漂亮。主要代價是多了一個 proc-macro dependency。
對於已經依賴 syn 和 quote 的團隊,這個 crate 是不錯的選擇。對於想要 zero dependencies 的團隊,上面的 macro pattern 只要二十行,而且不用改 Cargo.toml。
從一個函數開始
你不需要為整個 codebase 加上註解。挑一個有棘手 invariant 的函數,加上 requires! 和 ensures!,執行你的 tests,然後檢查 release assembly。
一旦你看到檢查在 debug 中抓到真正的 bug,同時在 release 中消失無蹤,價值就顯而易見了。Contracts 不是一個要導入的 framework。它們是一個要養成的習慣,一次一個函數。