跳到主要内容

借用与引用

问题

什么是引用和借用?Rust 的借用规则是什么?

答案

引用(Reference)是一种不拥有数据所有权的指针,通过引用访问数据的行为称为借用(Borrowing)。借用让你在不转移所有权的情况下读取或修改数据。

两种引用

类型语法权限别名
共享引用(不可变引用)&T只读可以有多个
可变引用&mut T读写同一时间只能有一个

借用规则(核心)

Rust 编译器通过**借用检查器(Borrow Checker)**在编译期强制执行两条核心规则:

借用规则
  1. 同一时间,可以有多个共享引用 &T,或者一个可变引用 &mut T,二者不可共存
  2. 引用必须始终有效(不能悬垂引用,引用不能比被引用的数据活得更久)

这两条规则的本质目的:在编译期消除数据竞争

数据竞争需要同时满足三个条件:

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

Rust 的借用规则在编译期让条件 1+2 不可能同时成立。

共享引用 &T

共享引用允许读取但不允许修改,可以同时存在多个:

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

let r1 = &s; // ✅ 第一个共享引用
let r2 = &s; // ✅ 第二个共享引用
let r3 = &s; // ✅ 第三个共享引用

println!("{}, {}, {}", r1, r2, r3); // ✅ 同时使用多个共享引用
}

共享引用不允许修改:

fn main() {
let s = String::from("hello");
let r = &s;
// r.push_str(" world"); // ❌ 编译错误:不能通过 &T 修改数据
println!("{}", r);
}

可变引用 &mut T

可变引用允许读写数据,但同一时间只能有一个:

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

let r = &mut s; // ✅ 创建可变引用
r.push_str(" world"); // ✅ 通过可变引用修改数据
println!("{}", r); // "hello world"
}
注意

创建可变引用需要原始变量本身是 mut 的:let mut s = ...;

同一时间不能有两个可变引用:

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

let r1 = &mut s;
// let r2 = &mut s; // ❌ 编译错误:不能同时有两个可变引用
r1.push_str(" world");
println!("{}", r1);
}

共享引用和可变引用不能共存

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

let r1 = &s; // ✅ 共享引用
let r2 = &s; // ✅ 共享引用
// let r3 = &mut s; // ❌ 已有共享引用存在时,不能创建可变引用
println!("{}, {}", r1, r2);
}

原因:如果你持有一个共享引用(你期望值不变),此时另一个可变引用修改了数据,你读到的值就不一致了——这就是数据竞争

NLL(Non-Lexical Lifetimes)

Rust 2018 引入了 NLL——引用的生命周期不再以词法作用域(花括号)为边界,而是以最后一次使用为边界:

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

let r1 = &s;
let r2 = &s;
println!("{}, {}", r1, r2);
// r1 和 r2 在此之后不再被使用,它们的借用到此结束

let r3 = &mut s; // ✅ 现在可以创建可变引用了
r3.push_str(" world");
println!("{}", r3);
}

在 NLL 之前,上面的代码会编译失败,因为 r1r2 要到花括号结束才"死亡"。NLL 让编译器更加智能,只要引用不再被使用,借用就结束了。

函数中的借用

通过引用传参,避免所有权转移:

// 接受共享引用:只读访问
fn calculate_length(s: &String) -> usize {
s.len()
}

// 接受可变引用:可以修改
fn append_world(s: &mut String) {
s.push_str(" world");
}

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

let len = calculate_length(&s); // 传递共享引用
println!("长度: {}", len);

append_world(&mut s); // 传递可变引用
println!("{}", s); // "hello world"
}

悬垂引用(Dangling Reference)

Rust 编译器会阻止你创建悬垂引用——指向已被释放内存的引用:

fn dangle() -> &String {       // ❌ 编译错误
let s = String::from("hello");
&s // s 在函数结束时被释放,返回的引用将指向无效内存
}

fn no_dangle() -> String { // ✅ 正确做法:返回所有权
let s = String::from("hello");
s // 转移所有权给调用者
}

引用的自动解引用(Deref Coercion)

Rust 会在函数调用时自动进行引用的隐式转换(解引用强制转换):

fn greet(name: &str) {
println!("Hello, {}!", name);
}

fn main() {
let s = String::from("Alice");
greet(&s); // ✅ &String 自动转换为 &str(Deref Coercion)
}

常见的 Deref 链:

  • &String&str
  • &Vec<T>&[T]
  • &Box<T>&T

引用在结构体中

结构体包含引用时,必须标注生命周期:

// 结构体包含引用,需要生命周期标注
struct Excerpt<'a> {
text: &'a str, // text 引用必须至少和 Excerpt 实例一样长
}

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();

let excerpt = Excerpt { text: first_sentence };
println!("摘录: {}", excerpt.text);
}

详细内容请阅读 生命周期

可变引用的重借用(Reborrow)

可变引用可以被"重借用"为共享引用或另一个可变引用:

fn main() {
let mut data = vec![1, 2, 3];
let r = &mut data;

// 重借用:从 &mut Vec 隐式借出 &Vec
let len = r.len(); // 这里实际是 (&*r).len(),即重借用为共享引用
println!("长度: {}", len);

r.push(4); // ✅ 重借用结束后,r 依然可用
println!("{:?}", r);
}

常见面试问题

Q1: 为什么 Rust 的借用规则能消除数据竞争?

答案

数据竞争需要三个条件同时满足:多指针访问 + 至少一个写 + 无同步

Rust 的借用规则本质上是静态读写锁

  • 多个 &T(多读)→ 没有写,不构成竞争
  • 一个 &mut T(独占写)→ 没有其他读写,不构成竞争
  • &T&mut T 不可共存 → 编译器拒绝同时读写

这等价于在编译期强制执行了 RwLock 的语义——要么多个读者,要么一个写者,不可能同时出现读者和写者。

Q2: NLL 是什么?解决了什么问题?

答案

NLL(Non-Lexical Lifetimes,非词法生命周期)是 Rust 2018 Edition 引入的改进。

之前:引用的生命周期由词法作用域(花括号)决定,即使引用不再被使用,它的借用在整个作用域内都有效。

之后:编译器基于控制流图分析引用的实际使用范围,引用在最后一次使用后就"死亡"。

// NLL 之前:这段代码无法编译,因为 r1 的借用要到函数结束才结束
// NLL 之后:r1 在 println! 后不再使用,借用提前结束,r2 可以创建
let mut x = 5;
let r1 = &x;
println!("{}", r1); // r1 最后一次使用
let r2 = &mut x; // ✅ NLL 允许,r1 的借用已结束
*r2 += 1;

Q3: &str&String 有什么区别?函数参数应该用哪个?

答案

  • &StringString 类型的引用,指向堆上的完整 String 对象
  • &str:字符串切片引用,可以引用 String 的一部分,也可以引用字符串字面量

函数参数优先使用 &str,因为:

  1. &String 可以自动转为 &str(Deref Coercion),但反过来不行
  2. &str 更通用,能接受 &String、字符串字面量 "hello"&str 切片
fn greet(name: &str) {}    // ✅ 推荐:更通用

fn greet2(name: &String) {} // ❌ 不推荐:限制了调用方

fn main() {
let s = String::from("Alice");
greet(&s); // ✅ &String → &str(Deref)
greet("Bob"); // ✅ &str 直接传

greet2(&s); // ✅
// greet2("Bob"); // ❌ &str 无法转为 &String
}

详细内容请阅读 String 与 &str

Q4: 什么是"借用检查器"(Borrow Checker)?它在什么阶段工作?

答案

借用检查器是 Rust 编译器中的一个组件,在编译期(MIR 阶段)工作,负责验证所有引用是否满足借用规则。

它检查的内容:

  1. 引用是否在有效范围内使用(不能悬垂)
  2. 可变引用是否独占(不能和其他引用共存)
  3. 被借用的数据是否在借用期间被修改/移动

借用检查器只在编译期运行,不产生任何运行时开销——这是 Rust "零成本抽象"的体现。

Q5: 如何解决"cannot borrow as mutable because it is also borrowed as immutable"?

答案

这是最常见的借用错误之一。核心原因:同时存在共享引用和可变引用。

解决策略

  1. 调整使用顺序(利用 NLL):让共享引用在可变引用创建前完成使用
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{}", first); // 共享引用最后一次使用
v.push(4); // ✅ 此时共享引用已结束
  1. 用作用域隔离
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 共享引用在这里结束
v.push(4); // ✅
  1. 先 clone 再操作(性能换正确性):
let mut v = vec![1, 2, 3];
let first = v[0].clone(); // 复制值,而非借用
v.push(4); // ✅ 没有活跃的借用
println!("{}", first);
  1. 使用索引替代引用
let mut v = vec![1, 2, 3];
let idx = 0; // 保存索引,而非引用
v.push(4);
println!("{}", v[idx]); // 通过索引访问

Q6: 什么是重借用(Reborrow)?

答案

重借用是 Rust 编译器的一个隐式行为:当你将 &mut T 传给期望 &T&mut T 的地方时,编译器会隐式地从可变引用中"借出"一个新引用,而不是 Move 原引用。

fn read(data: &Vec<i32>) {
println!("{:?}", data);
}

fn write(data: &mut Vec<i32>) {
data.push(42);
}

fn main() {
let mut v = vec![1, 2, 3];
let r = &mut v;

// 重借用:r 暂时被降级为 &Vec,read 结束后 r 恢复可用
read(r); // 隐式 read(&*r)
write(r); // 隐式 write(&mut *r)

r.push(100); // ✅ r 仍然可用
}

如果没有重借用机制,&mut T 传给函数后会被 Move(因为 &mut T 不是 Copy),导致原引用失效。

Q7: 引用和裸指针有什么区别?

答案

特性引用 &T / &mut T裸指针 *const T / *mut T
安全性编译器保证安全解引用需要 unsafe
非空保证不为 null可能为 null
对齐保证对齐正确不保证
有效性保证指向有效内存不保证
借用规则受借用检查器约束不受约束
主要用途日常代码FFI、数据结构底层实现

引用可以安全地转为裸指针(不需要 unsafe),但裸指针解引用需要 unsafe:

let x = 42;
let r: &i32 = &x;
let p: *const i32 = r; // ✅ 引用 → 裸指针(安全)
unsafe {
println!("{}", *p); // ❌ 裸指针解引用需要 unsafe
}

Q8: 为什么 Vec::push 会导致已有引用失效?

答案

Vec::push 可能触发重新分配(reallocation):当 Vec 的容量不足时,它会申请一块更大的内存,把数据复制过去,释放旧内存。

如果你持有指向旧内存的引用,push 后引用指向的内存已被释放——这就是悬垂引用。借用检查器识别到 push 需要 &mut self,与已有的共享引用冲突,因此拒绝编译:

let mut v = vec![1, 2, 3];
let first = &v[0]; // 共享引用,指向 v 的堆内存
// v.push(4); // ❌ push 需要 &mut v,与 &v[0] 冲突
// // 实际原因:push 可能重新分配内存,使 first 成为悬垂指针
println!("{}", first);

相关链接