简短回答是”可以”,但有个前提
Rust 可以在开发阶段强制执行 runtime contracts,并在 release 构建中将其完全抹除。前提是这门语言并没有把 contracts 当作一等公民。你会拿到所有积木,但得自己把它们拼起来。
debug_assert! 是最显而易见的起点。它在 debug 构建中执行,在 release 构建中编译为空。这对简单的 invariants 够用,但真正的 contracts 需要 preconditions、postconditions,有时还需要带自定义失败行为的 loop invariants。这些都不是开箱即用的。
这篇文章展示一种模式,让你在不依赖任何外部 crate 的情况下,获得真正的 design-by-contract 语义,并在 release 构建中实现字面意义上的零开销。
为什么在 Rust 中 Runtime Contracts 仍然重要
Rust 的类型系统和 borrow checker 能在编译期捕获大量错误。但它们抓不住逻辑错误、boundary violations,或者被破坏的业务 invariants。
你可以写一个接收 NonZeroU32 参数的函数,却仍然用错误的值做除法。你可以用类型系统约束 slice bounds,却仍然传错 slice。Contracts 填补的就是从”能编译”到”逻辑正确”之间的鸿沟。
反对意见永远是性能。在系统编程里,release 构建是一种承诺。为 runtime checks 额外插入的分支指令,感觉就像在违背这个承诺。
目标是把检查留在有用的地方——开发和测试阶段——然后把它从用户实际运行的二进制文件中移除。
Rust 开箱即给了什么
Rust 有两类 assertion 家族。assert! 和 assert_eq! 在所有构建中运行。debug_assert! 和 debug_assert_eq! 只在 cfg(debug_assertions) 激活时运行,而默认情况下 debug 构建就是激活的。
pub fn withdraw(balance: &mut i64, amount: i64) {
// 始终运行。你在 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) {
// 在 release 构建中消失。
debug_assert!(amount > 0, "withdrawal must be positive");
debug_assert!(amount <= *balance, "insufficient funds");
*balance -= amount;
}
这对临时检查还行。但对系统性的 contracts 来说不行,因为:
- 没有内置语法支持 preconditions、postconditions 或 invariants
- 无法按构建配置轻松切换 contract 的级别
- 自定义失败行为,比如用 logging 代替 panicking,需要给每个调用都包一层
用宏构建零成本 Contracts
可行的模式是一套小型宏,在 debug 构建中展开为检查,在 release 构建中展开为空。如果你希望在 release 测试中也运行 contract checks,可以用自定义的 cfg 标志控制开关;也可以直接绑定到 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 构建中,每次检查都会运行,失败时 panic 并给出清晰的信息。在 release 构建中,宏展开为空块。优化器什么也看不到,不会生成任何指令。
验证 Release 构建真的零成本
光相信宏不够。你应该去检查汇编。
在 release 模式下编译 withdraw 函数并检查输出:
rustc --edition 2021 -C opt-level=3 --emit asm contracts.rs
当宏使用 cfg(debug_assertions) 时,Account::withdraw 的 release 汇编中没有 panic 分支、没有字符串常量,除了算术运算本身之外没有任何额外的比较。Contract checks 完全不存在。
如果你在输出中看到了与 panic 相关的符号,那说明宏里有 bug,或者你在 release profile 中设置了 debug-assertions = true。
你会踩到的 Profile 陷阱
Cargo 允许通过 Cargo.toml 在 release 构建中启用 debug assertions:
[profile.release]
debug-assertions = true
有些团队会临时开启这个设置,以便在集成测试中捕获问题。如果你用 cfg(debug_assertions) 作为 contract 的开关,这会让你的 release 二进制文件突然变慢。对于测试来说这可能是你想要的,但如果你期望的是零成本,这就成了意外。
如果你需要更精细的控制,用自定义 feature 代替 debug_assertions:
#[cfg(any(feature = "contracts", debug_assertions))]
这样 contracts 默认在 debug 构建中运行,你也可以通过 --features contracts 在 release 构建中显式启用它们,在特定的测试运行或 canary deployment 中获得额外的安全性。
为什么这不是 Eiffel 风格的 Design-by-Contract
真正的 design-by-contract 语言可以继承 contracts、跨模块边界组合它们,有时甚至能静态证明它们。Rust 的宏这些一个都给不了。
你得到的是务实的方案。检查在调用点运行。失败时 panic。在 release 构建中消失。这足以在开发阶段捕获大量 bug,而不会把开销带到生产环境。
它也足以表达意图。函数开头的一个 requires! 块告诉下一位读者,作者到底假设了什么——而且是可执行的形式。
什么时候应该把 Contracts 留在 Release 中
不是所有 contracts 都应该消失。安全关键的 invariants、外部输入验证和安全检查通常应该放在 assert! 里,而不是 debug_assert! 或自定义的 contract 宏中。
规则很简单:如果违反条件会导致内存不安全、数据损坏或安全漏洞,就把它留在 release 构建中。零成本模式只适用于那些本该被测试捕获的逻辑错误。
// 留在 release 中。内存安全依赖它。
assert!(!ptr.is_null());
// 在 release 中剥离。这是逻辑 bug,但不是 unsafe。
requires!(discount_rate <= 1.0);
把模式打包成一个 Crate
如果你不想维护自己的宏,crates.io 上的 contracts crate 几乎就是完全相同的模式。它提供了可以附加到函数和 struct 定义上的 #[requires]、#[ensures] 和 #[invariant] proc macros。
底层实现用的也是相同的 cfg 展开,在 release 构建中剥离检查。主要好处是语法更优雅。主要代价是多了一个 proc-macro 依赖。
对于已经依赖 syn 和 quote 的团队来说,这个 crate 是个好选择。对于想要零依赖的团队来说,上面的宏模式只有二十行代码,而且不需要修改 Cargo.toml。
从一个函数开始
你不需要给整个 codebase 加注解。选一个带有棘手 invariant 的函数,加上 requires! 和 ensures!,运行测试,然后检查 release 汇编。
一旦你看到检查在 debug 构建中捕获了真实的 bug,同时在 release 构建中彻底消失,价值就不言自明了。Contracts 不是需要整套引入的框架,而是一个需要逐步培养的习惯,一次一个函数。