Skip to content
Published at:

05. References & Borrowing — 引用与借用

在上一章中,我们学习了所有权和移动语义——值在赋值时转移所有权,源变量失效。但如果我们只是「看一下」某个值,并不想拿走所有权该怎么办?答案就是引用(reference):一种不拥有值的指针类型。创建引用称为借用(borrowing)——你借走使用,但最终必须归还给所有者。

引用是 Rust 在不牺牲性能的前提下实现「多处访问同一数据」的核心机制。没有引用,我们只能通过不断移动所有权来传递数据——代码会变得极其繁琐。

两种引用

Rust 提供了两种引用,对应两种访问模式:

引用类型语法访问权限允许多个是否 Copy
共享引用&T只读
可变引用&mut T读写否(独占)

共享引用 &T

共享引用允许多个地方同时「看」同一个值,但谁都不能改:

rust
let v = vec![1, 2, 3, 4, 5];

let r1 = &v;   // 共享引用
let r2 = &v;   // 可以同时存在多个共享引用
let r3 = &v;

println!("{:?} {:?} {:?}", r1, r2, r3);  // 全部 OK
println!("{:?}", v);  // v 仍可用,因为引用没有拿走所有权

创建共享引用使用 & 运算符。因为是 Copy 类型,赋值共享引用时执行按位复制:

rust
let r1 = &v;
let r2 = r1;   // r1 被拷贝(不是移动),r1 仍可用
println!("{:?}", r1);

可变引用 &mut T

可变引用提供对值的独占读写访问。同一时间只能存在一个可变引用:

rust
let mut v = vec![1, 2, 3];

let r = &mut v;  // 可变引用
r.push(4);       // 通过引用修改 v
// let r2 = &v;  // 错误:已有可变引用时不能创建共享引用
// let r3 = &mut v; // 错误:不能同时有两个可变引用
println!("{:?}", r);  // [1, 2, 3, 4]

可变引用不是 Copy——赋值时转移所有权(移动):

rust
let mut x = 42;
let r1 = &mut x;
let r2 = r1;     // r1 移动到 r2
// println!("{}", r1);  // 错误:r1 已被移动

借用规则

Rust 借用检查器的核心规则只有两条,但这两条规则消除了几乎所有内存安全问题:

  1. 共享访问是只读的访问。 在共享引用的生命周期内,它的引用对象(以及从引用对象可达的所有值)保持只读——不能赋值、不能移动。
  2. 可变访问是独占的访问。 在可变引用的生命周期内,它是访问引用对象的唯一路径——没有其他引用、没有所有者直接访问。

这两条规则等价于经典的「多读单写」约束:N 个读者或 1 个写者,不能同时存在

编译器在编译时静态检查这些规则:

rust
let mut v = vec![1, 2, 3];

// 规则 2 的例子:可变引用是独占的
let r = &mut v;
r.push(4);
// println!("{:?}", v);  // 错误:v 已被可变借用,不能通过所有者直接访问
// 规则要求:在 r 的生命周期内,v 不可用
println!("{:?}", r);  // OK:r 的生命周期在这里结束
println!("{:?}", v);  // OK:r 已不在作用域内

编译器的报错信息清晰而精确:

error[E0502]: cannot borrow `v` as immutable because it is also borrowed as mutable
 --> src/main.rs:5:22
  |
4 |     let r = &mut v;
  |             ------ mutable borrow occurs here
5 |     println!("{:?}", v);
  |                      ^ immutable borrow occurs here
6 |     println!("{:?}", r);
  |                      - mutable borrow later used here

借用冻结整个所有权树

共享引用不仅冻结被引用的值本身,还冻结从它可达的所有值:

rust
struct Book {
    title: String,
    pages: u32,
}

let mut library: Vec<Book> = vec![
    Book { title: String::from("Rust Book"), pages: 500 },
];

let first = &library[0];  // 共享引用 library[0]

// library[0].title = String::from("New Title");  // 错误:不能修改被冻结的值
// library[0].pages = 600;                        // 错误:同样被冻结
// library.push(Book { ... });                    // 错误:library 本身也被冻结
// 因为 push 可能触发重新分配,使 first 悬垂

println!("{}", first.title);  // OK

这与 C 的 const 指针有本质区别:C 的 const int *p 只阻止通过 p 写入,但其他指针仍可修改同一内存。Rust 的 &T 保证在整个引用生命周期内,没有任何路径能修改该值。

与 C++ 引用的对比

特性Rust 引用C++ 引用
创建语法&x类型声明 int& r = x;
解引用显式 *r. 自动解引用隐式自动解引用
重新绑定r = &y 改变引用指向不能重新绑定,赋值写入目标
可为空否(可用 Option<&T>否(但实践中可为空)
编译期安全借用检查器保证无保证
默认可变性不可变(需 &mut可变的(需 const&

生命周期

生命周期(lifetime)是 Rust 编译器用来追踪引用有效范围的编译期概念——没有运行时表示,不影响生成的机器码。每个引用都有一个生命周期,编译器确保引用不会比它指向的值活得更久。

借用局部变量

最基本的生命周期约束:

rust
{
    let r;
    {
        let x = 1;
        r = &x;  // 错误:x 的生命周期不够长
    }
    // println!("{}", r);  // r 在这里使用,但 x 已经不存在了
}

编译错误:

error[E0597]: `x` does not live long enough
 --> src/main.rs:5:13
  |
4 |         let x = 1;
  |             - binding `x` declared here
5 |         r = &x;
  |             ^^ borrowed value does not live long enough
6 |     }
  |     - `x` dropped here while still borrowed
7 |     // println!("{}", r);
  |                - borrow later used here

编译器对每个引用分配了一个生命周期,并验证两个约束:

  1. 引用的生命周期不能超过引用对象的生命周期。
  2. 存储引用的变量的生命周期必须被引用的生命周期覆盖。

当这两个约束不能同时满足时,编译失败。

引用作为函数参数

当函数接受引用参数时,编译器需要推断参数的生命周期:

rust
// 这个函数编译不过
// fn f(p: &i32) {
//     static mut STASH: &i32 = &128;
//     unsafe { STASH = p; }  // p 的生命周期可能不够长
// }

函数签名揭示了其行为。上面的函数试图将参数存储到 static 变量中——这要求参数的生命周期至少和整个程序一样长。修复方式:

rust
static mut STASH: &i32 = &128;

fn f(p: &'static i32) {  // 签名表明:p 必须活到程序结束
    unsafe { STASH = p; }
}

'static 是特殊的生命周期名称,表示「整个程序执行期间」。&'static str 类型的字符串字面量(如 "hello")就属于此列。

生命周期参数

多个引用之间的生命周期关系通过生命周期参数表达:

rust
// 返回两个字符串切片中较短的那个
fn smallest<'a>(v: &'a [String]) -> &'a String {
    let mut s = &v[0];
    for r in &v[1..] {
        if r.len() < s.len() {
            s = r;
        }
    }
    s
}

<'a> 声明了一个生命周期参数。v: &'a [String] 表示 v 的生命周期为 'a-> &'a String 表示返回值的生命周期也为 'a——这意味着返回值从参数借用,不能比参数活得更久。

这个签名排除了悬垂引用:

rust
let s;
{
    let v = vec![String::from("hello"), String::from("world")];
    s = smallest(&v);
    // 错误:s 借用了 v,但 v 即将被释放
}
// s 在这里使用——但 v 已经不存在了

生命周期省略(Lifetime Elision)

Rust 允许在明确的情况下省略生命周期标注。省略规则只有三条:

  1. 每个引用参数获得一个独立的生命周期。
  2. 如果只有一个输入生命周期,则它被赋给所有输出生命周期。
  3. 如果有多个输入生命周期,但一个是 &self&mut self,则 self 的生命周期赋给所有输出生命周期。

这三条规则覆盖了绝大多数常见模式:

rust
fn first(s: &str) -> &str { ... }           // 规则 2:输出 = 输入
fn find(s: &str, c: char) -> Option<usize>; // 规则 1:无输出引用
impl Foo { fn get(&self) -> &Bar { ... } }  // 规则 3:输出 = self

包含引用的结构体

如果结构体包含引用字段,必须显式声明生命周期参数:

rust
// 编译错误:缺少生命周期标注
// struct S {
//     r: &i32
// }

// 正确:显式声明生命周期
struct S<'a> {
    r: &'a i32,
}

// 使用
{
    let x = 42;
    let s = S { r: &x };  // s 的生命周期不能超过 x
    println!("{}", s.r);
}

生命周期参数使结构体的借用关系对读者和编译器都显式可见。

不同的生命周期参数

当结构体包含多个引用时,应该用不同的生命周期参数,而不是强制它们相同:

rust
// 过于严格:强制 x 和 y 生命周期相同
// struct S<'a> { x: &'a i32, y: &'a i32 }

// 正确:x 和 y 可以有独立的生命周期
struct S<'a, 'b> {
    x: &'a i32,
    y: &'b i32,
}

let x = 10;
{
    let y = 20;
    let s = S { x: &x, y: &y };
    println!("{} {}", s.x, s.y);
}  // y 在这里释放
// x 仍然活着,s 在这里不能再使用了(因为 y 已被释放)

这条原则同样适用于函数签名。

使用引用

引用的引用

Rust 允许引用链——. 运算符会自动「穿透」多层引用:

rust
struct Point { x: i32, y: i32 }
let p = Point { x: 10, y: 20 };
let r = &p;
let rr = &r;
let rrr = &rr;

// . 运算符自动解引用多层
assert_eq!(rrr.x, 10);  // 等价于 (***rrr).x
assert_eq!(rrr.y, 20);

在 C++ 中,你需要 rrr->x 或者多次 * 解引用。Rust 的 . 运算符帮你「看穿」任意深度的引用。

比较引用

比较运算符同样「看穿」引用。你比较的是值,而非地址:

rust
let x = 10;
let y = 10;
let rx = &x;
let ry = &y;

assert!(rx == ry);    // 比较值:true(都是 10)
assert!(rx < &20);    // 比较值:true
assert!(rx == &&10);  // 多层引用穿透比较

// 如果要比较地址,使用 std::ptr::eq
assert!(!std::ptr::eq(rx, ry));  // 不同地址

引用永不为空

Rust 引用不能为 null。如果需要「可能没有值」的语义,使用 Option<&T>

rust
fn find_item(items: &[String], target: &str) -> Option<&String> {
    for item in items {
        if item == target {
            return Some(item);
        }
    }
    None
}

// 编译器强制你处理 Option
match find_item(&items, "apple") {
    Some(item) => println!("Found: {}", item),
    None => println!("Not found"),
}

在底层,Option<&T> 被优化为和裸指针相同的大小:None 就是全零的 null 指针,Some(r) 就是非零地址。Rust 保证引用永远不会为空,这个优化是安全的。

借用任意表达式的引用

Rust 允许对任意左值表达式取引用,编译器会创建匿名变量来存储中间结果:

rust
let v = vec![1, 2, 3];
let r = &v[1];         // 借用 v 的第二个元素
// 编译器为 v[1] 创建了一个匿名变量,其生命周期与 r 相同

// 也可以借用临时值
let num = &(1 + 2);    // 创建匿名变量存储 3,取它的引用
assert_eq!(*num, 3);

胖指针:切片和 Trait 对象的引用

对切片的引用是胖指针(fat pointer)——包含起始地址和长度:

rust
let v = vec![1, 2, 3, 4, 5];
let slice: &[i32] = &v[1..4];  // 胖指针:(地址指向 v[1], 长度 3)

// 占用两个 word:指针 + 长度
println!("size of &[i32]: {}", std::mem::size_of::<&[i32]>());  // 16(64位系统上)
println!("size of &i32:   {}", std::mem::size_of::<&i32>());    // 8

对 Trait 对象的引用也是胖指针——包含数据指针和虚表(vtable)指针。这些内容将在后续章节详细介绍。

引用与函数式编程

借用让 Rust 的函数式风格代码变得自然。以处理表格数据为例:

rust
use std::collections::HashMap;

type Table = HashMap<String, Vec<String>>;

// 接受共享引用——不获取所有权,只是「看一下」
fn show(table: &Table) {
    for (artist, works) in table {
        // works 自动成为 &Vec<String>
        println!("works by {}:", artist);
        for work in works {
            // work 自动成为 &String
            println!("  {}", work);
        }
    }
}

// 接受可变引用——需要修改数据
fn sort_works(table: &mut Table) {
    for (_artist, works) in table.iter_mut() {
        works.sort();
    }
}

fn main() {
    let mut table = Table::from([
        ("Chopin".to_string(),
         vec!["Nocturne No.2".to_string(), "Ballade No.1".to_string()]),
        ("Liszt".to_string(),
         vec!["La Campanella".to_string(), "Hungarian Rhapsody".to_string()]),
    ]);

    show(&table);        // 共享借用
    sort_works(&mut table); // 可变借用
    show(&table);        // 可变借用结束后,可以再次共享借用
}

注意 show() 接收 &Table 而非 Table——调用者保留所有权,可以在 show() 之后继续使用 table。如果 show() 签名为 fn show(table: Table),调用后 table 就会被消耗。

与所有权系统的协同

借用和所有权共同构成了一套完整的资源管理方案。考虑以下场景:

rust
// 无法从共享借用的 Vec 中移出元素
let v = vec![String::from("a"), String::from("b")];
let r = &v;
// let s = r[0];        // 错误:不能通过共享引用移出值
// let t = v[0];        // 错误:v 已被共享借用,不能直接访问
println!("{}", r[0]);   // OK:只读访问

共享引用阻止了所有对引用对象的写操作——包括移动。这保证了在共享引用存在期间,数据不会被意外修改或销毁。

Rust 让你远离的对象之海

很多 GC 语言鼓励「对象之海」架构——对象互相引用形成复杂的网状图结构。这在 Rust 中几乎不可能(或极其困难):

// GC 语言常见模式(在 Rust 中很难实现)
// 对象 A → 对象 B → 对象 C
//    ↑         ↓         |
//    └─────────┘ ←───────┘
//       (循环引用!)

Rust 强制你选择更清晰的所有权结构——通常是

// Rust 推荐的所有权树
//   根对象
//   ├── 子对象 1
//   │   ├── 叶子 A
//   │   └── 叶子 B
//   └── 子对象 2
//       └── 叶子 C

数据流按单方向从根到叶。如果需要循环引用,必须使用 Rc<RefCell<T>>(引入运行时检查),成本明显。这不是 Rust 的缺陷——它迫使你在设计阶段就理清数据关系,而非在未来调试悬垂指针和循环泄漏。

小结

  • 两种引用&T 共享只读(Copy),&mut T 可变独占(非 Copy)。借用规则等价于「N 个读者或 1 个写者」。
  • 借用检查器在编译时静态验证所有引用。共享引用冻结整个所有权树,可变引用独占访问路径。
  • 生命周期是编译期概念,无运行时开销。编译器确保引用的生命周期不超过引用对象的生命周期。
  • 生命周期省略三条规则覆盖了绝大多数场景,只在结构体持有引用或函数在不同输入间选择输出时需要显式标注。
  • 引用永不为空,用 Option<&T> 表示可空语义,存储上与裸指针等大。
  • 胖指针:切片引用携带地址+长度,Trait 对象引用携带地址+虚表指针。
  • Rust 用借用+所有权引导你走向清晰的树状所有权结构,而非难以追踪的对象网状图。