简短回答是”可以”,但有个前提

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 依赖。

对于已经依赖 synquote 的团队来说,这个 crate 是个好选择。对于想要零依赖的团队来说,上面的宏模式只有二十行代码,而且不需要修改 Cargo.toml

从一个函数开始

你不需要给整个 codebase 加注解。选一个带有棘手 invariant 的函数,加上 requires!ensures!,运行测试,然后检查 release 汇编。

一旦你看到检查在 debug 构建中捕获了真实的 bug,同时在 release 构建中彻底消失,价值就不言自明了。Contracts 不是需要整套引入的框架,而是一个需要逐步培养的习惯,一次一个函数。