借用与引用
问题
什么是引用和借用?Rust 的借用规则是什么?
答案
引用(Reference)是一种不拥有数据所有权的指针,通过引用访问数据的行为称为借用(Borrowing)。借用让你在不转移所有权的情况下读取或修改数据。
两种引用
| 类型 | 语法 | 权限 | 别名 |
|---|---|---|---|
| 共享引用(不可变引用) | &T | 只读 | 可以有多个 |
| 可变引用 | &mut T | 读写 | 同一时间只能有一个 |
借用规则(核心)
Rust 编译器通过**借用检查器(Borrow Checker)**在编译期强制执行两条核心规则:
- 同一时间,可以有多个共享引用
&T,或者一个可变引用&mut T,二者不可共存 - 引用必须始终有效(不能悬垂引用,引用不能比被引用的数据活得更久)
这两条规则的本质目的:在编译期消除数据竞争。
数据竞争需要同时满足三个条件:
- 两个或多个指针同时访问同一块数据
- 至少一个在写入
- 没有同步机制
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 之前,上面的代码会编译失败,因为 r1、r2 要到花括号结束才"死亡"。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 有什么区别?函数参数应该用哪个?
答案:
&String:String类型的引用,指向堆上的完整String对象&str:字符串切片引用,可以引用String的一部分,也可以引用字符串字面量
函数参数优先使用 &str,因为:
&String可以自动转为&str(Deref Coercion),但反过来不行&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 阶段)工作,负责验证所有引用是否满足借用规则。
它检查的内容:
- 引用是否在有效范围内使用(不能悬垂)
- 可变引用是否独占(不能和其他引用共存)
- 被借用的数据是否在借用期间被修改/移动
借用检查器只在编译期运行,不产生任何运行时开销——这是 Rust "零成本抽象"的体现。
Q5: 如何解决"cannot borrow as mutable because it is also borrowed as immutable"?
答案:
这是最常见的借用错误之一。核心原因:同时存在共享引用和可变引用。
解决策略:
- 调整使用顺序(利用 NLL):让共享引用在可变引用创建前完成使用
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{}", first); // 共享引用最后一次使用
v.push(4); // ✅ 此时共享引用已结束
- 用作用域隔离:
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 共享引用在这里结束
v.push(4); // ✅
- 先 clone 再操作(性能换正确性):
let mut v = vec![1, 2, 3];
let first = v[0].clone(); // 复制值,而非借用
v.push(4); // ✅ 没有活跃的借用
println!("{}", first);
- 使用索引替代引用:
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);