Rust 防御性编程模式

我有这样一个爱好。

每当在代码中看到// 此情况绝不应发生的注释时,我总会尝试找出 可能 触发该情况的具体条件。而90%的情况下,我都能找到实现途径。大多数时候,开发者只是未考虑到所有边界情况或未来代码变更的可能性。

事实上,我如此钟爱这类注释,正是因为它们往往 精准标记着强保证机制崩溃的节点 。根源通常在于违反了编译器未强制执行的隐式不变量。

没错,编译器能防范内存安全问题,标准库也堪称业界标杆。但即便是标准库也存在缺陷,业务逻辑中的漏洞依然可能出现。

我们唯一能依靠的,是通过多年将Rust代码投入生产环境积累的经验,总结出的硬核模式来编写更具防御性的Rust代码。这里说的不是设计模式,而是那些鲜少被记录却能显著提升代码质量的小技巧。

元素周期表

代码异味:向量索引操作

以下这段代码看似无害:

match matching_users.len() {
    1 => {
        let existing_user = &matching_users[0];
        // ...
    }
    _ => Err(RepositoryError::DuplicateUsers)
}

当前代码虽能运行,但若重构时忘记保留长度检查呢?这便是首个未被编译器强制执行的隐式不变量。问题在于向量索引操作与长度检查是解耦的:这两项独立操作可被分别修改,而编译器不会发出警告。

若采用切片模式匹配,只有当match分支执行时才能访问元素。

match matching_users.as_slice() {
    [existing_user] => {  // Safe! Compiler guarantees exactly one element
        // ...
    }
    [] => Ok(Success::NotFound),
    _ => Err(RepositoryError::DuplicateUsers)
}

注意这种方式自动暴露了另一个边界情况:列表为空时会怎样?此前我们从未考虑过这种情形。编译器强制执行的模式匹配迫使我们思考所有可能状态!这正是健壮Rust代码中的常见模式——让编译器负责强制执行不变量。

代码异味:懒惰使用Default

初始化多字段对象时,人们常倾向于使用..Default::default()填充剩余字段。实践中这往往是漏洞的温床:你可能忘记在后续向结构体添加新字段时显式设置(导致使用默认值,这可能并非预期),也可能未能察觉所有被默认值覆盖的字段。

避免这样写:

let foo = Foo {
    field1: value1,
    field2: value2,
    ..Default::default()  // Implicitly sets all other fields
};

改用这种方式:

let foo = Foo {
    field1: value1,
    field2: value2,
    field3: value3, // Explicitly set all fields
    field4: value4,
    // ...
};

虽然代码稍显冗长,但好处在于编译器会强制你显式处理所有字段。当你向Foo新增字段时,编译器会提醒你在此处同步设置,并提示合理取值。

若仍想使用Default但不愿放弃编译器检查,也可采用解构默认实例的方式:

let Foo { field1, field2, field3, field4 } = Foo::default();

这样既能将所有默认值赋给局部变量,又能按需覆盖:

let foo = Foo {
    field1: value1,    // Override what you need
    field2: value2,    // Override what you need
    field3,            // Use default value
    field4,            // Use default value
};

此模式兼顾两全:

  • 获取默认值无需重复默认逻辑
  • 结构体新增字段时编译器会发出警告
  • 默认值变更时代码自动适配
  • 字段默认值与自定义值的区分清晰

代码异味:脆弱的特性实现

将结构体完全解构为组成部分,也可作为确保API合规的防御策略。例如构建披萨订购系统时,订单类型如下:

struct PizzaOrder {
    size: PizzaSize,
    toppings: Vec<Topping>,
    crust_type: CrustType,
    ordered_at: SystemTime,
}

订单追踪系统需根据披萨实际内容(sizetoppingscrust_type)进行订单比对。而ordered_at时间戳不应影响两份订单是否被视为相同。

显而易见的实现方式存在以下问题:

impl PartialEq for PizzaOrder {
    fn eq(&self, other: &Self) -> bool {
        self.size == other.size 
            && self.toppings == other.toppings 
            && self.crust_type == other.crust_type
            // Oops! What happens when we add extra_cheese or delivery_address later?
    }
}

现在假设团队新增了自定义选项字段:

struct PizzaOrder {
    size: PizzaSize,
    toppings: Vec<Topping>,
    crust_type: CrustType,
    ordered_at: SystemTime,
    extra_cheese: bool, // New field added
}

此时PartialEq实现虽能编译通过,但是否正确?extra_cheese 是否应纳入相等性检查?很可能需要——加奶酪的披萨就是新订单!但编译器不会提醒你思考这个问题。

以下是采用解构的防御性方案:

impl PartialEq for PizzaOrder {
    fn eq(&self, other: &Self) -> bool {
        let Self {
            size,
            toppings,
            crust_type,
            ordered_at: _,
        } = self;
        let Self {
            size: other_size,
            toppings: other_toppings,
            crust_type: other_crust,
            ordered_at: _,
        } = other;

        size == other_size && toppings == other_toppings && crust_type == other_crust
    }
}

此时若有人添加 extra_cheese 字段,代码将无法编译。编译器强制你做出选择:是否将 extra_cheese 纳入比较范围,还是通过 extra_cheese: _ 显式忽略?

此模式适用于所有需处理结构体字段的特质实现:HashDebugClone 等。在需求变化导致结构体频繁演进的代码库中,这种模式尤为重要。

代码异味:伪装成TryFromFrom实现

有时转换无法保证100%成功,这很正常。遇到这种情况时,请克制按惯例提供From实现的冲动,转而使用TryFrom

以下示例展示了伪装的TryFrom

impl From<&DetectorStartupErrorReport> for DetectorStartupErrorSubject {
    fn from(report: &DetectorStartupErrorReport) -> Self {
        let postfix = report
            .get_identifier()
            .or_else(get_binary_name)
            .unwrap_or_else(|| UNKNOWN_DETECTOR_SUBJECT.to_string());

        Self(StreamSubject::from(
            format!("apps.errors.detectors.startup.{postfix}").as_str(),
        ))
    }
}

unwrap_or_else暗示此转换可能失败。虽然我们设置了默认值,但这真的适合所有调用者吗?此处应改用TryFrom实现,明确标识其不可靠性。我们选择快速失败,而非继续执行可能存在缺陷的业务逻辑。

代码异味:非穷举匹配

使用 match 配合 _ => {} 之类的万能模式虽具诱惑力,但可能埋下隐患。问题在于你可能忘记处理后续新增的情况。

避免如下写法:

match self {
    Self::Variant1 => { /* ... */ }
    Self::Variant2 => { /* ... */ }
    _ => { /* catch-all */ }
}

改用:

match self {
    Self::Variant1 => { /* ... */ }
    Self::Variant2 => { /* ... */ }
    Self::Variant3 => { /* ... */ }
    Self::Variant4 => { /* ... */ }
}

通过显式列出所有分支,当新增分支时编译器会发出警告,迫使你进行处理。这再次体现了让编译器发挥作用的价值。

若两个变体的代码相同,可进行分组:

match self {
    Self::Variant1 => { /* ... */ }
    Self::Variant2 => { /* ... */ }
    Self::Variant3 | Self::Variant4 => { /* shared logic */ }
}

代码异味:_ 作为未用变量占位符

使用 _ 作为未使用变量的占位符可能引发混淆。例如,你可能无法确定哪些变量被跳过。布尔标志尤其如此:

match self {
    Self::Rocket { _, _, .. } => { /* ... */ }
}

在上例中,无法明确哪些变量被跳过及其原因。对于未使用变量,建议采用描述性命名:

match self {
    Self::Rocket { has_fuel: _, has_crew: _, .. } => { /* ... */ }
}

即使变量未被使用,其含义也清晰可辨,代码可读性与可审查性得以提升,且无需内联类型提示。

模式:临时可变性

若仅需临时修改数据,请明确标注。

let mut data = get_vec();
data.sort();
let data = data;  // Shadow to make immutable

// Here `data` is immutable.

此模式常被称为“临时可变性”,有助于防止初始化后的意外修改。更多细节请参阅Rust非官方模式手册

你还可以更进一步,将初始化部分放在作用域块中:

let data = {
    let mut data = get_vec();
    data.sort();
    data  // Return the final value
};
// Here `data` is immutable

此方式将可变变量限制在内部作用域,明确表明其仅用于初始化。若初始化过程中使用临时变量,它们不会泄漏到外部作用域。上例中虽未涉及,但假设存在临时向量用于存储中间结果:

let data = {
    let mut data = get_vec();
    let temp = compute_something();
    data.extend(temp);
    data.sort();
    data  // Return the final value
};

此时temp仅在内部作用域内可见,可避免后续意外调用。

作用域临时可变性

可进一步将初始化包裹在作用域块中,确保临时变量绝不泄漏:

let data = {
    let mut data = get_vec();
    let temp = compute_something();
    data.extend(temp);
    data.sort();
    data  // Return the final value
};

// Here `data` is immutable and `temp` is out of scope

当初始化过程中存在多个临时变量且需限制其在函数其余部分的访问时,此方法尤为有效。作用域明确标识这些变量仅用于初始化。

模式:防御性构造函数处理

假设你有一个如下所示的简单类型:

pub struct S {
    pub field1: String,
    pub field2: u32,
}

现在你需要添加验证逻辑以确保不会创建无效状态。一种模式是通过构造函数返回 Result

impl S {
    pub fn new(field1: String, field2: u32) -> Result<Self, String> {
        if field1.is_empty() {
            return Err("field1 cannot be empty".to_string());
        }
        if field2 == 0 {
            return Err("field2 cannot be zero".to_string());
        }
        Ok(Self { field1, field2 })
    }
}

但这无法阻止开发者通过直接创建实例绕过验证:

let s = S {
    field1: "".to_string(),
    field2: 0,
};

这绝不应该发生!这是编译器未强制执行的隐式不变量:验证逻辑与结构体构造解耦。这两项操作可独立变更,编译器不会报错。

为强制 外部代码 通过构造函数操作,添加私有字段:

pub struct S {
    pub field1: String,
    pub field2: u32,
    _private: (), // This prevents external construction 
}

impl S {
    pub fn new(field1: String, field2: u32) -> Result<Self, String> {
        if field1.is_empty() {
            return Err("field1 cannot be empty".to_string());
        }
        if field2 == 0 {
            return Err("field2 cannot be zero".to_string());
        }
        Ok(Self { field1, field2, _private: () })
    }
}

此时模块外部无法直接构造S,因其无法访问_private字段。编译器强制要求所有构造操作必须通过包含验证逻辑的new()方法!

为何_private要加下划线?

需注意下划线前缀仅是 命名约定 ,表明该字段被刻意闲置;真正使其成为私有字段并阻止外部构造的,是缺少pub修饰符。

对于需要长期演进的库,也可改用#[non_exhaustive]属性:

#[non_exhaustive]
pub struct S {
    pub field1: String,
    pub field2: u32,
}

这与禁止在外部构造器中创建结构体效果相同,但同时向用户表明未来可能添加更多字段。编译器将禁止用户使用结构体字面量语法,强制其使用构造函数。

应使用 #[non_exhaustive] 还是 _private`?

这两种方法存在显著差异:

  • #[non_exhaustive] 仅在仓库边界间生效。 它阻止仓库外部的构造。
  • _private 在模块边界生效。 它阻止模块外部的构造 ,但允许同一仓库内部构造。

此外,部分开发者认为 _private: () 能更明确表达意图:“此结构体包含阻止构造的私有字段”。

#[non_exhaustive] 的核心意图是标记未来可能新增的字段,禁止构造仅是附带效果。

防止内部构造

那么 同一模块 内的代码呢?使用上述模式时,同模块代码仍可绕过验证:

// Still compiles in the same module!
let s = S {
    field1: "".to_string(),
    field2: 0,
    _private: (),
};

Rust 的隐私机制作用于模块层级而非类型层级。同一模块内的任何内容均可访问私有项。

若需强制要求即使在自身模块内也必须使用构造函数,则需采用更防御性的嵌套私有模块方案:

mod inner {
    pub struct S {
        pub field1: String,
        pub field2: u32,
        _seal: Seal,
    }
    
    // This type is private to the inner module
    struct Seal;
    
    impl S {
        pub fn new(field1: String, field2: u32) -> Result<Self, String> {
            if field1.is_empty() {
                return Err("field1 cannot be empty".to_string());
            }
            if field2 == 0 {
                return Err("field2 cannot be zero".to_string());
            }
            Ok(Self { field1, field2, _seal: Seal })
        }
    }
}

// Re-export for public use
pub use inner::S;

现在即使外部模块的代码也无法直接构造S,因为Seal被封装在私有inner模块中。唯有与Seal位于同一模块的new()方法能进行构造。编译器确保所有构造操作(包括内部构造)都必须经过验证逻辑。

使用场景指南

通过构造函数强制验证:

  • 针对外部代码 :添加私有字段如_private: ()或使用#[non_exhaustive]
  • 针对内部代码 : 使用带私有“封装”类型的嵌套私有模块
  • 根据需求选择 :多数代码仅需阻止外部构造;强制内部构造更具防御性但复杂度更高

核心要义在于:通过使私有类型不可访问来阻止构造,可将验证逻辑从约定转化为编译器强制执行的保证。让我们让编译器发挥作用吧!

[模式:在关键类型上使用#[must_use]

#[must_use]属性常被忽视。这令人遗憾,因为它能以简单而强大的机制防止调用者意外忽略重要返回值。

#[must_use = "Configuration must be applied to take effect"]
pub struct Config {
    // ...
}

impl Config {
    pub fn new() -> Self {
        // ...
    }

    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }
}

现在若有人创建了Config却忘记使用它,编译器会发出警告:

let config = Config::new();
config.with_timeout(Duration::from_secs(30)); // Warning: unused `Config` that must be used

// Correct usage:
let config = Config::new()
    .with_timeout(Duration::from_secs(30));
apply_config(config);

该特性对需全生命周期持有的守护类型(guard types)及必须检查的运算结果尤为有效。标准库广泛采用此机制,例如Result类型即标记为#[must_use],因此未处理错误时会触发警告。

代码异味:布尔参数

布尔参数不仅使调用点难以阅读,更容易引发错误。我们都经历过这样的场景:坚信这是函数最后一次添加布尔参数。

// Too many boolean parameters
fn process_data(data: &[u8], compress: bool, encrypt: bool, validate: bool) {
    // ...
}

// At the call site, what do these booleans mean?
process_data(&data, true, false, true);  // What does this do?

若不查看函数签名,根本无法理解这段代码的逻辑。更糟的是,布尔值极易被误置。

建议改用枚举明确表达意图:

enum Compression {
    Strong,
    Medium,
    None,
}

enum Encryption {
    AES,
    ChaCha20,
    None,
}

enum Validation {
    Enabled,
    Disabled,
}

fn process_data(
    data: &[u8],
    compression: Compression,
    encryption: Encryption,
    validation: Validation,
) {
    // ...
}

// Now the call site is self-documenting
process_data(
    &data,
    Compression::Strong,
    Encryption::None,
    Validation::Enabled
);

这种写法清晰得多,且当传递错误枚举类型时编译器会自动捕错。你会发现枚举变体比简单的truefalse更具描述性。实际上,具有实际意义的选项往往不止两种——尤其对于随时间演进的程序而言。

对于选项众多的函数,可通过参数结构体进行配置:

struct ProcessDataParams {
    compression: Compression,
    encryption: Encryption,
    validation: Validation,
}

impl ProcessDataParams {
    // Common configurations as constructor methods
    pub fn production() -> Self {
        Self {
            compression: Compression::Strong,
            encryption: Encryption::AES,
            validation: Validation::Enabled,
        }
    }

    pub fn development() -> Self {
        Self {
            compression: Compression::None,
            encryption: Encryption::None,
            validation: Validation::Enabled,
        }
    }
}

fn process_data(data: &[u8], params: ProcessDataParams) {
    // ...
}

// Usage with preset configurations
process_data(&data, ProcessDataParams::production());

// Or customize for specific needs
process_data(&data, ProcessDataParams {
    compression: Compression::Medium,
    encryption: Encryption::ChaCha20,
    validation: Validation::Enabled, 
});

这种方法在函数演进时更具可扩展性。新增参数不会破坏现有调用场景,且可轻松添加默认值或使特定字段可选。预设方法还能记录常见用例,便于根据不同场景选择正确配置。

Rust常因缺乏命名参数而受批评,但对于选项繁多的复杂函数,使用参数结构体或许更为优越。

防御性编程的Clippy代码检查规则

这些模式大多可通过Clippy代码检查自动强制执行,以下是最相关的规则:

代码检查项 描述
clippy::indexing_slicing 禁止直接对切片和向量进行索引操作
clippy::fallible_impl_from 警告可能引发恐慌的From实现,应改用TryFrom
clippy::wildcard_enum_match_arm 禁止使用通配符 _ 模式。
clippy::unneeded_field_pattern 识别你是否不必要地用 .. 忽略了过多结构体字段。
clippy::fn_params_excessive_bools 当函数布尔参数过多(默认4个及以上)时发出警告。

可在项目中通过在 Cargo.toml 或仓库顶部添加配置启用这些检查,例如:

#![deny(clippy::indexing_slicing)]

结论

Rust 中的防御性编程旨在利用类型系统和编译器在错误发生前进行捕获。遵循这些模式可实现:

  • 将隐式不变量显式化并交由编译器验证
  • 使代码具备抗重构错误的未来适应性
  • 缩小漏洞暴露面

这项技能并非与生俱来,多数Rust书籍也未涵盖,但掌握这些模式能让代码从“勉强可用但脆弱不堪”跃升为“经年累月仍坚固可维护”。

谨记:当你写下// 此情况绝不应发生时,请退后一步思考:编译器能否代你强制执行该不变量?最好的错误,是根本不会编译通过的错误。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号