Chapter 04

借用与生命周期

掌握 Rust 的借用规则,理解借用检查器如何在编译期防止悬垂引用

引用与借用

借用的动机

在上一章我们看到,将数据传入函数会转移所有权,函数返回后原变量失效。这非常不便——难道我们每次都要把数据传进函数,再返回回来吗?

// 不使用借用:必须返回所有权
fn calculate_length(s: String) -> (String, usize) {
    let len = s.len();
    (s, len)  // 必须把 s 还回去!
}

fn main() {
    let s1 = String::from("hello");
    let (s1, len) = calculate_length(s1);  // 传进去再拿回来,很笨拙
    println!("'{}' 的长度是 {}", s1, len);
}

Rust 的解决方案是借用(borrowing):使用 & 创建对值的引用,引用可以"借用"数据而不取得所有权。

// 使用借用:参数是引用,不转移所有权
fn calculate_length(s: &String) -> usize {
    s.len()
}  // s 是借用,离开作用域时不会 drop 它指向的 String

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // 借用 s1,不转移所有权
    println!("'{}' 的长度是 {}", s1, len);  // s1 仍然有效
}
借用的内存模型: let s1 = String::from("hello"); let len = calculate_length(&s1); ┌──────────────────────────────────────────────┐ │ main 函数栈帧 │ │ ┌──────────┐ │ │ │ s1 │ │ │ │ ptr ─────┼────────────────> 堆:"hello" │ │ │ len = 5 │ │ │ │ cap = 5 │ │ │ └──────────┘ │ └──────────────────────────────────────────────┘ │ &s1(引用传入函数) ↓ ┌──────────────────────┐ │ calculate_length 帧 │ │ ┌──────────┐ │ │ │ s │ │ │ │ ptr ─────┼────────┼──> 指向 s1(不拥有) │ └──────────┘ │ └──────────────────────┘ 函数结束时,s 被销毁,但 s1 的堆数据完好无损

借用规则

两条核心借用规则

借用规则(与所有权规则同等重要)

规则一:在任意时刻,你可以拥有任意数量的不可变引用(&T),或者恰好一个可变引用(&mut T),但两者不能同时存在。
规则二:引用必须始终有效——引用不能比它所指向的数据活得更长。

这两条规则从根本上防止了所有的数据竞争和悬垂引用问题。让我们逐一理解:

不可变借用:可以有多个

fn main() {
    let s = String::from("hello");

    // 多个不可变借用同时存在:合法!
    let r1 = &s;
    let r2 = &s;
    let r3 = &s;
    println!("{} {} {}", r1, r2, r3);  // 正常
    // 多个读者同时读取同一数据:安全,不会产生竞争
}

可变借用:同一时刻只能有一个

fn main() {
    let mut s = String::from("hello");

    // 一个可变借用
    let r1 = &mut s;
    r1.push_str(" world");
    println!("{}", r1);

    // 编译错误!不能同时有两个可变借用
    // let r2 = &mut s;  // error: cannot borrow `s` as mutable more than once

    // 编译错误!不可变借用和可变借用不能同时存在
    // let r2 = &s;      // error: cannot borrow `s` as immutable because it is also borrowed as mutable
}

为什么这样设计?防止数据竞争

数据竞争(Data Race)是并发编程中最难追踪的 bug 类型,发生条件是:

  1. 两个或更多指针同时访问同一块数据
  2. 至少有一个指针在写入数据
  3. 没有同步机制

Rust 的借用规则在编译期就防止了这三个条件同时满足:任意时刻,要么多个读者(不可变引用),要么恰好一个写者(可变引用),永远不会同时存在读者和写者。

非词法生命周期(NLL)

从 Rust 2018 Edition 开始,Rust 引入了非词法生命周期(Non-Lexical Lifetimes, NLL):引用的生命周期结束于它最后一次使用的地方,而不是词法上的作用域结束处。这让借用检查器更智能,减少了不必要的编译错误。

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;  // 不可变借用开始
    let r2 = &s;  // 另一个不可变借用开始
    println!("{} {}", r1, r2);
    // ↑ NLL:r1 和 r2 在这里最后使用,它们的生命周期在此结束

    // 所以这里可以安全地创建可变借用!
    let r3 = &mut s;
    r3.push_str(" world");
    println!("{}", r3);
}

悬垂引用:Rust 编译器防止的 bug

什么是悬垂引用?

悬垂引用(Dangling Reference)是指引用指向的内存已经被释放,但引用本身仍然存在。这是 C/C++ 中极其危险的 bug——读取悬垂指针会得到垃圾数据或导致崩溃,而且很难调试。

// Rust 不允许你写出这样的代码
fn dangle() -> &String {  // 试图返回对局部变量的引用
    let s = String::from("hello");
    &s
    // ↑ 编译错误!s 在函数结束时被 drop,
    //   返回的引用将指向已释放的内存——悬垂引用!
    // error[E0106]: missing lifetime specifier
}
// 正确做法:直接返回 String(转移所有权)
fn no_dangle() -> String {
    let s = String::from("hello");
    s  // 所有权移出,不会被 drop
}

生命周期(Lifetimes)

生命周期是什么?

生命周期是 Rust 类型系统的一部分,用来追踪引用的有效时间范围。大多数情况下生命周期是隐式推断的,但有时编译器无法独立推断,需要你明确标注。

生命周期的作用是:让编译器验证"没有任何引用的有效期会超过它指向的数据的有效期"。

生命周期标注语法

// 生命周期参数:用 'a、'b 等表示(以单引号开头)

// 这个函数返回两个字符串中较长的那个
// 编译器无法单独推断:返回值的生命周期取决于哪个参数更长
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    // 生命周期 'a 表示:返回值的有效期不超过 x 和 y 中较短的那个
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("xy");
        result = longest(s1.as_str(), s2.as_str());
        println!("较长的是: {}", result);
    }
    // 如果在这里使用 result,编译器会报错
    // 因为 s2 在内部块结束时被 drop,result 可能指向它
}

生命周期标注不改变运行行为

重要:生命周期标注不会改变引用的实际有效时间,它只是告诉编译器多个引用之间的关系,让编译器能够验证你的代码是否安全。生命周期是"给编译器的注释"。

结构体中的生命周期

// 结构体包含引用时,必须标注生命周期
struct Important<'a> {
    part: &'a str,  // 这个引用的有效期至少和结构体实例一样长
}

impl<'a> Important<'a> {
    fn announce(&self, announcement: &str) -> &str {
        println!("注意!{}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("这是一个故事。很久很久以前...");
    let first_sentence: &str;
    {
        let i = Important {
            part: novel.split('。').next().expect("找不到句号"),
        };
        first_sentence = i.announce("重要通知");
        println!("{}", first_sentence);
    }
}

生命周期省略规则(Elision Rules)

在很多常见模式中,Rust 编译器能自动推断生命周期,你不需要显式标注。这被称为"生命周期省略规则":

规则1(输入)
每个引用参数都获得独立的生命周期参数。
fn foo(x: &i32, y: &i32)fn foo<'a,'b>(x: &'a i32, y: &'b i32)
规则2(输出)
如果只有一个输入生命周期参数,它被赋予所有输出引用。
fn foo(x: &i32) -> &i32fn foo<'a>(x: &'a i32) -> &'a i32
规则3(方法)
如果方法有 &self&mut self,self 的生命周期被赋予所有输出引用。这是最常见的情况。

'static 生命周期

// 'static 表示整个程序生命周期内有效
// 字符串字面量自动拥有 'static 生命周期
let s: &'static str = "我永远有效";

// 函数签名中的 'static 约束
fn need_static(s: &'static str) {
    println!({}, s);
}
不要滥用 'static

看到编译错误时,不要无脑地给所有生命周期标注 'static——这通常是逃避问题,而不是解决问题。'static 意味着引用必须整个程序生命周期内有效,这在很多场景下过于严格。真正的解决方案是理清数据的所有权关系。