Rust 中的借用和生命周期示例

1. 概述

借用和生命周期是两个让 Rust 初学者最常与编译器“较劲”的概念。本文通过熟悉的 Order、Customer 等领域示例来解释借用(&T、&mut T)和生命周期标注('a)。读完后,你将能理解 borrow checker 的错误信息,知道何时需要手写生命周期标注,何时编译器可以自动推导。

2. 快速回顾所有权

在讨论借用之前,需要记住:Rust 中每个值只有一个所有者。当你按值传递值时,所有权被移动了。

struct Customer {
    name: String,
}

fn print_customer(customer: Customer) {
    println!("客户:{}", customer.name);
}

fn main() {
    let customer = Customer { name: String::from("Nguyễn Văn A") };
    print_customer(customer);
    // println!("{}", customer.name); // 错误:customer 已被移动
}

print_customer 按值接收 Customer,所以 customer 被移动到函数中。如果取消该注释行,将报错:

error[E0382]: borrow of moved value: `customer`

借用应运而生来解决这个问题:让其他函数“借用”数据而无需取得所有权。

3. 不可变借用:&T

当只需要读取数据而无需修改时,使用 &T。

struct Order {
    items: Vec<f64>,
}

fn calculate_total(order: &Order) -> f64 {
    order.items.iter().sum()
}

fn main() {
    let order = Order { items: vec![100.0, 250.0, 50.0] };
    let total = calculate_total(&order);

    println!("总计:{}", total);
    println!("商品数:{}", order.items.len()); // order 仍然可以使用
}
总计:400
商品数:3

calculate_total 接收 &Order —— 一个不可变引用。order 仍然属于 main,函数只是“临时借用”来读取。因此在调用函数后,main 仍然可以正常使用 order。你可以同时创建多个 &Order,只要没有 &mut Order 混在其中。

4. 可变借用:&mut T

当需要通过引用修改数据时,使用 &mut T。

struct Order {
    items: Vec<f64>,
    discount_percent: f64,
}

fn apply_discount(order: &mut Order, percent: f64) {
    order.discount_percent = percent;
}

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
    apply_discount(&mut order, 10.0);

    println!("折扣:{}%", order.discount_percent);
}
折扣:10%

注意两点:变量 order 必须声明为 mut,且函数调用时必须传递 &mut order。与 &T 不同,在任何给定时刻只能存在一个 &mut T,且不能有任何其他 &T 同时存在。

5. Borrow Checker:规则“一个主人,多个读者或一个写者”

核心规则:对于一个数据,在任何给定时刻,你要么有多个 &T,要么有一个 &mut T,两者不能混合。

fn main() {
    let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };

    for item in &order.items {
        order.items.push(*item); // 错误:order.items 在循环中作为不可变借用
    }
}

for item in &order.items 创建一个在整个循环期间存活的不可变引用。在循环中,order.items.push(...) 需要同一数据的可变引用 → 冲突。编译器报告:

error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable

常见的修复方法是:将需要添加的值收集到临时 Vec 中,然后在循环结束后 extend —— 即将两个借用分开到不同的时间点。

注意: borrow checker 根据“实际存活范围”(non-lexical lifetimes) 进行分析,而不是按 {} 块。如果两个借用的生命周期不重叠,即使在同一个函数中也是有效的。

6. 生命周期是什么

生命周期是 Rust 描述’引用活了多久’的方式,以确保编译器保证你永远不会在原始数据已被丢弃后使用引用。大多数情况下你无需手写生命周期—编译器会自动推导。但当一个函数接收多个引用并返回一个引用时,编译器需要你明确说明返回的引用“借用”自哪个参数。

6.1. 函数上的生命周期标注

fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let customer1 = String::from("Nguyễn Văn A");
    let customer2 = String::from("Trần Thị B dài hơn");

    let longest = longest_name(&customer1, &customer2);
    println!("最长的名字:{}", longest);
}
最长的名字:Trần Thị B dài hơn

'a 不会创建新的生命周期,它只是一个约束名称:a、b 和返回值都必须至少活得和 'a 一样长。编译器在运行时不知道函数返回的是 a 还是 b,所以它强制所有输入和输出共用一个生命周期以确保在任何情况下都是安全的。

如果你写错了——返回与声明的生命周期无关的引用:

fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
    let local = String::from("临时");
    &local // 错误:local 在函数末尾被丢弃
}
error[E0515]: cannot return value referencing local variable `local`

6.2. 结构体中的生命周期

包含引用的结构体也需要生命周期标注,因为 Rust 必须确保结构体不会“活得比”它借用的数据更久。

struct Customer {
    name: String,
}

struct OrderSummary<'a> {
    customer: &'a Customer,
    total: f64,
}

fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
    OrderSummary { customer, total }
}

fn main() {
    let customer = Customer { name: String::from("Lê Văn C") };
    let summary = build_summary(&customer, 500.0);

    println!("{} - 总计:{}", summary.customer.name, summary.total);
}

OrderSummary<'a> 不拥有 Customer,它只是持有一个引用。生命周期 'a 强制 summary 不能活得比 customer 更久。如果你让 customer 在使用 summary 之前被丢弃,编译器在编译时就会阻止 —— 没有任何悬垂指针能逃到运行时。

6.3. 何时无需手写生命周期(省略)

Rust 有 3 条自动推导生命周期的规则(生命周期省略),适用于大多数函数:

  1. 每个引用参数都被分配一个独立的生命周期。
  2. 如果函数恰好只有一个输入生命周期,它会被分配给输出。
  3. 如果有 &self 或 &mut self,self 的生命周期会被分配给输出。
fn first_word(text: &str) -> &str {
    // 规则 2:只有 1 个输入生命周期 -> 自动分配给输出
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

这个函数无需写 'a,因为只有一个引用参数 —— 编译器自动推导输出借用自 text。你只需要手写生命周期,当有多于一个输入引用且输出不符合规则 2、3 时。

7. 实际例子:返回 slice 的解析器

fn first_word(text: &str) -> &str {
    match text.find(' ') {
        Some(index) => &text[..index],
        None => text,
    }
}

fn main() {
    let order_note = String::from("giao hàng nhanh trong ngày");
    let word = first_word(&order_note);

    println!("第一个单词:{}", word);
}
第一个单词:giao

first_word 不创建新的 String,它只是返回位于 order_note 内存中的一个 slice(&str)。这是 Rust 解析器中的常见模式:避免额外分配,只“指向”原始数据。生命周期确保 word 不能活得比 order_note 更久。

试试写错来看编译器如何保护你:

fn first_word_broken() -> &str {
    let text = String::from("giao hàng nhanh");
    first_word(&text) // text 在函数结束时被丢弃
}
error[E0106]: missing lifetime specifier

编译器立刻拒绝编译,因为无法为输出分配合法的生命周期 —— text 肯定会在返回值被使用前死亡。

first_word 的测试

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn 返回第一个单词() {
        let note = "giao hang nhanh trong ngay";
        assert_eq!(first_word(note), "giao");
    }

    #[test]
    fn 无空格时返回整个句子() {
        let note = "ship";
        assert_eq!(first_word(note), "ship");
    }
}

运行 cargo test:

running 2 tests
test tests::返回第一个单词 ... ok
test tests::无空格时返回整个句子 ... ok

8. 常见错误

  • 返回本地变量的引用:变量在函数末尾被丢弃,但引用仍然在调用处“活着” → 编译器用生命周期错误阻止,永远不会让其通过运行时。
  • 保持 &T 太久后才借用 &mut T:常见于将 .iter() 结果存储在变量中,然后在同一作用域内改变原始结构体。修复方法:通过 {} 块缩小不可变借用的作用域,或将读/写逻辑分成两个步骤。
  • 滥用 clone() 来躲避 borrow checker:clone() 总是能编译,但代价是复制数据。只在你真正需要独立副本时使用,不要用来“救火”借用错误。
  • 过度写生命周期标注:如果函数只有一个输入引用,不要手写 'a —— 省略规则已经处理了,多写只会让代码混乱。

提示: 如果你发现自己必须添加 'static 来让代码编译,停下来重新考虑设计。'static 强制数据在整个程序期间存活,通常表明你在回避真正的所有权问题而不是解决它。

9. 总结

借用让你在无需获得所有权的情况下使用数据,其中“多个读者或一个写者”的规则由 borrow checker 在编译时检查。生命周期是编译器和你都能理解的语言,用来明确引用活了多久,特别是在函数返回引用或结构体内部持有引用时。大多数情况下编译器通过省略规则自动推导生命周期;只有当有多个输入引用且输出来源不明确时,你才需要手写。

rustborrowinglifetimeownershipmemory-safety