借用与引用
用
&和&mut在不转移所有权的情况下使用数据,以及生命周期初步。
引用与借用
借用的动机
如果每次「只是读一下」都要 move,函数签名会变成 fn f(s: String) -> String 那样的往返接力。 引用(reference)解决了这个问题:看一眼,但不拿走。
rust
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // 把 &s 借出去,s 仍然是所有者
println!("'{s}' 的长度是 {len}。"); // s 依然可用
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s 是一个引用,离开作用域时不会 drop 任何堆内存输出:
'hello' 的长度是 5。
借用(borrow)的内存图
main 栈帧 calculate_length 栈帧
┌────────────────────────┐ ┌────────────────────────────┐
│ s ──[ptr|5|5]──────────┼──┐ │ s ──[ptr 指向 main 的 s]───┼──┐
└────────────────────────┘ │ └────────────────────────────┘ │
│ 引用本身也是栈上的一个指针 │
│ │
└────────────────────────────────────────┘
两个 s 名字,一个所有者,一个借用者💡 对照:借用 ≈ C++ 的
const T&,但更强:C++ 的const&只保证不走这个引用改, 不阻止别人(包括原对象自己)改。Rust 的&T在借用存活期间真的锁住了可变访问 —— 由编译器强制。
&T 与 &mut T
rust
fn main() {
let mut s = String::from("hello");
let r = &s; // 不可变引用(shared reference),可以同时有多个
let r2 = &s;
println!("{r} {r2}"); // 输出:hello hello
let m = &mut s; // 可变引用(mutable reference),必须独占
m.push_str(", world");
println!("{m}"); // 输出:hello, world
}改内容需要 &mut T,而 &mut T 要求变量本身先声明为 mut:
rust
fn main() {
let s = String::from("hello");
change(&s); // 编译失败
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
// error[E0596]: cannot borrow `s` as mutable, as it is not declared as mutable
// help: consider changing this to be mutable
// 3 | let mut s = String::from("hello");💡 对照:Java 的方法参数引用与 Python 的对象引用都是永远可变的, 想表达「只读」只能靠约定(
final只锁引用本身,不锁内容)。 Rust 把「只读」和「可写」编码进类型&T/&mut T,调用方一眼看得出。
借用规则:读-写互斥
在任意给定时刻,对同一个值:
要么 ┌────────────────────────────────────────────┐
│ 任意多个不可变引用 &T (读者可以很多) │
└────────────────────────────────────────────┘
要么 ┌────────────────────────────────────────────┐
│ 恰好一个可变引用 &mut T (写者必须独占) │
└────────────────────────────────────────────┘
两者不可同时存在。为什么会这样? 三条直觉,任选一条都能自洽:
- 数据竞争(data race)的最小定义就是「读与写并发 + 无同步」。 禁止
&T与&mut T共存,等于静态消灭了单线程里的数据竞争。 (多线程里的数据竞争还需要Send/Sync,见〈并发编程〉,但那部分建立在同一套规则上。) - 迭代器失效(iterator invalidation):
Vec扩容会重新分配缓冲区, 旧指针变悬垂。C++ 里这是经典 UB,Rust 用「push 时不能再有迭代中的引用」直接拦下。 &mut T隐含了「这一段内存只被我一个人碰」的承诺, 优化器可以据此把值缓存在寄存器里(noalias)。多个&mut会打破这个承诺。
rust
fn main() {
let mut v = vec![1, 2, 3];
let a = &mut v;
let b = &mut v; // 编译失败
a.push(4);
b.push(5);
}
// error[E0499]: cannot borrow `v` as mutable more than once at a timeE0502:不可变与可变不能共存
rust
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 不可变借用开始
let r2 = &s;
println!("{} and {}", r1, r2);
let r3 = &mut s; // 编译失败:r1/r2 之后还要用(下一行 println!)
r3.push_str(" world");
println!("{}", r1);
}rustc 原始输出(1.98.1 实测):
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src\main.rs:8:14
|
3 | let r1 = &s;
| -- immutable borrow occurs here
...
8 | let r3 = &mut s;
| ^^^^^^ mutable borrow occurs here
9 | r3.push_str(" world");
10| println!("{}", r1);
| -- immutable borrow later used here解读:编译器不是在抱怨第 8 行,而是在抱怨「第 3 行的借用活到了第 10 行」。 immutable borrow later used here 这一行是全文最重要的线索 —— 它告诉你这个借用的实际结束点在哪儿。
修法:把这个不可变借用用完就收。
rust
fn main() {
let mut s = String::from("hello");
{
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // 输出:hello and hello
} // r1、r2 在这里结束
let r3 = &mut s;
r3.push_str(" world");
println!("{}", r3); // 输出:hello world
}引用的作用域:NLL 与临时值生命周期
这是初学者最大的困惑源头:「作用域」不等于「花括号范围」。
下面这段代码里,r1/r2 的借用在第 4 行就结束了,尽管它们的花括号要到第 7 行才闭合:
rust
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2); // r1、r2 的最后一次使用
// 从这里起 r1、r2 已「死」,借用到此为止 —— 这就是 NLL
let r3 = &mut s;
println!("{}", r3); // 输出:hello
}NLL(Non-Lexical Lifetimes,非词法生命周期) 是新的借用检查器(MIR borrowck)的核心, 自 Rust 1.31 / 2018 edition 起稳定,并在 Rust 1.36 起成为所有 edition 的默认行为。 它按控制流图上的活跃区间(liveness)判定借用结束,结束点是最后一次使用, 而不是包含它的那个 }。
⚠️ 陷阱:在
rustc 1.98.1上,NLL 对所有 edition 都生效 —— 你无法用--edition 2015复现「花括号式」的旧报错。 换句话说,「升级 edition 就能通过借用检查」在今天的 NLL 场景里已经不成立, edition 影响的是下面这类临时值作用域规则。
Rust 2024 edition 改小了临时值(temporary)在一种具体位置上的存活范围: if let $pat = $expr { ... } else { ... } 中,由 $expr 产生的临时值 在进入 else 分支之前就被 drop,而不是等到整个 if let 语句结束。 这消除了「读锁一直持有到 else 分支里再去拿写锁」的经典诡异死锁(或 RefCell panic)。
rust
use std::cell::RefCell;
fn f(value: &RefCell<Option<bool>>) {
if let Some(x) = *value.borrow() { // 这里产生一个临时的 Ref 守卫
println!("value is {x}");
} else {
let mut v = value.borrow_mut(); // 2021:Ref 还活着 ⇒ panic
if v.is_none() { *v = Some(true); }
}
}
fn main() {
let c = RefCell::new(None);
f(&c);
f(&c);
println!("{:?}", c.borrow()); // 2024 输出:Some(true)
}| edition | else 分支里的 borrow_mut() | 结果 |
|---|---|---|
| 2021 | 临时 Ref 借用的作用域被延长到整个 if let 语句之后 | thread 'main' panicked at ...: RefCell already borrowed |
| 2024 | 临时 Ref 在进入 else 前已 drop | 正常输出 Some(true) |
🧠 原理:edition 只改作用域推断规则,不改语法。同一份
.rs文件用--edition 2021与--edition 2024编译都成功(这不是编译错误差异, 而是运行期行为差异),但运行结果可能完全不同。cargo fix --edition会通过if_let_rescopelint 把有风险的if let改写成match(match的临时值作用域仍是「到语句结束」,即 2021 的行为)。
⚠️ 陷阱:不要用花括号去「推理」借用何时结束,要看最后一次使用在哪里。 唯一的例外是临时值生命周期延长(temporary lifetime extension)——
let r = &String::from("x");这类「引用直接作为let初始化式」的写法会被延长到变量作用域。
悬垂引用与生命周期入门
悬垂引用(dangling reference)
Rust 永远不允许产生悬垂引用。下面这段代码的失败信息很有教学价值:
rust
fn main() {
let s = no_dangle();
println!("{}", s);
}
fn no_dangle() -> &String { // 编译失败:这个 &String 该借谁?
let s = String::from("hello");
&s // s 在函数末尾被 drop,返回的引用会悬垂
}rustc 原始输出(1.98.1 实测):
error[E0106]: missing lifetime specifier
--> src\main.rs:6:19
|
6 | fn no_dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value,
but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're
returning a borrowed value from a `const` or a `static`
|
6 | fn no_dangle() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
6 - fn no_dangle() -> &String {
6 + fn no_dangle() -> String {为什么是 E0106(缺少生命周期标注)而不是「悬垂引用」? 因为对编译器来说,这个签名信息不足:返回值的引用可能来自参数(合法), 也可能来自函数内部(非法)。它先要求你把来源写清楚。 如果来源写清楚了但只可能是函数内部,才会报 E0515:
rust
struct Foo {
name: String,
}
fn make<'a>() -> &'a Foo { // 编译失败示例
let f = Foo { name: String::from("a") };
&f
}
// error[E0515]: cannot return reference to local variable `f`
// | &f
// | ^^ returns a reference to data owned by the current function正确做法:返回拥有所有权的值,让所有权正常转移出去。
rust
fn no_dangle() -> String {
let s = String::from("hello");
s // 移动出去,不是借用
}
fn main() {
let s = no_dangle();
println!("{s}"); // 输出:hello
}🧠 原理:
E0106和E0515是同一件事的两个阶段。E0106= 「你没告诉我这个引用从哪来」;E0515= 「你告诉我了,但那个来源活不过函数」。
生命周期标注是「关系」而不是「时长」
先记住一句反直觉但极其重要的话:
生命周期参数
'a不表示「活多久」,它表示「这些引用必须活得一样久」这个约束。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 读作:
对于任意一个生命周期
'a,只要x和y都至少活到'a, 那么返回值也至少活到'a。
它不要求 x 和 y 本身同样长 —— 只是要求它们覆盖同一个区间, 由调用方各自「取交集」。
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string is long");
{
let s2 = String::from("xyz");
let result = longest(s1.as_str(), s2.as_str());
println!("最长的字符串是 {result}"); // 输出:最长的字符串是 long string is long
}
// 出了这个块 result 就不能再用了:'a 被取成了 s2 的存活区间
} 'a 的实际取值 = 两个实参存活区间的交集(短的那个)
s1 存活 ├────────────────────────────────────────────┤
s2 存活 ├──────────────────┤
'a ├──────────────────┤ ← 交集,由 s2 决定
result 只能在这个区间内使用如果强行越界使用:
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string is long");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str());
} // s2 在这里 drop
println!("最长的字符串是 {result}"); // E0597
}
// error[E0597]: `s2` does not live long enough
// | let s2 = String::from("xyz");
// | -- binding `s2` declared here
// | result = longest(s1.as_str(), s2.as_str());
// | ^^ borrowed value does not live long enough
// | } - `s2` dropped here while still borrowed
// | println!("最长的字符串是 {result}");
// | ------ borrow later used here⚠️ 陷阱:
'a不是「变量的寿命」,也不是一个可以「延长」的东西。 你无法通过「把'a写大一点」让代码通过编译 —— 编译器只会告诉你实参不允许。
生命周期省略规则(elision)
如果每个引用都要手写生命周期,签名会非常吵。所以编译器内置三条省略规则。 重要前提:省略规则只在「函数 / 方法签名」上生效,结构体定义上不生效(见「结构体中的引用与生命周期参数」)。
规则 1(输入位置):每个省略了生命周期的引用参数,各自获得一个独立的生命周期参数。
fn f(x: &str, y: &str) ⇒ fn f<'a, 'b>(x: &'a str, y: &'b str)
规则 2(输出位置——若只有一个输入生命周期):输出引用的生命周期 = 那个唯一的输入生命周期。
fn f(x: &str) -> &str ⇒ fn f<'a>(x: &'a str) -> &'a str
规则 3(方法接收者):若签名里有 &self 或 &mut self,则输出引用的生命周期 = self 的生命周期。
fn f(&self, x: &str) -> &str ⇒ 输出与 &self 同寿命('b 被忽略)
若三条规则都不能确定输出引用的生命周期 ⇒ 报 E0106,要求你手写。rust
// 规则 2 的例子:一个输入,输出跟着它,无需手写生命周期
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
fn main() {
println!("{}", first_word("hello world")); // 输出:hello
}cpp
// 规则 2 不适用:两个输入,输出不确定借谁 ⇒ 必须手写 'a(否则 E0106)
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
println!("{}", longest("abcd", "xy")); // 输出:abcd
}💡 对照:C++ 完全没有这个概念 —— 返回引用是否悬垂靠你自己看;
std::string_view的悬垂是 C++ 社区公认的雷区。Rust 把「这个 view 能活多久」 变成函数类型签名的一部分,调用方拿到返回值就知道能用多久。 代价是你要写'a。
&'static str:字符串字面量
rust
fn main() {
let s: &'static str = "我在程序的只读数据段里";
let owned: String = s.to_string(); // 需要可修改/可拥有时再转成 String
println!("{s} / {owned}");
}
&'static str表示「这个引用在整个程序运行期间都有效」。 字符串字面量被编译进二进制(通常是.rdata只读段),因此天然是'static。
⚠️ 陷阱:不要为了让
E0106消失而随手加'static。fn f(x: &str) -> &'static str意味着「返回的东西不依赖x」, 而fn no_dangle() -> &'static String编译也会继续失败(String不是'static数据)。'static是最强的承诺,只在返回字面量或全局常量时才对。
🚀 进阶:
const X: &str = "..."与static X: &str = "..."都是'static; 泛型约束里的T: 'static含义是「T不含任何短命借用」,不是「T是全局变量」, 常被误读。详见〈智能指针与闭包〉讲Box<dyn Trait + 'static>的地方。
结构体中的引用与生命周期参数
结构体持有引用必须标注生命周期
结构体定义上,省略规则不生效:只要字段里有引用,就必须给结构体加生命周期参数。
rust
struct Excerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("找不到 '.'");
let excerpt = Excerpt { part: first_sentence };
println!("节选:{}", excerpt.part); // 输出:节选:Call me Ishmael
}Excerpt<'a> 读作:「一个 Excerpt 实例,不能比它 part 字段借用的那份数据活得更久。」
novel(String,拥有堆缓冲区)
│
│ &novel[0..16] ← 只是两个整数的描述符(ptr + len)
▼
┌──────────────────┐ ┌─────────────┐
│ excerpt.part ────┼───────►│ Call me ... │ 堆上数据仍归 novel 所有
└──────────────────┘ └─────────────┘
excerpt 只借,不拥有;novel 若被 drop/move,excerpt 立刻失效rust
// 编译失败示例
struct Excerpt<'a> {
part: &'a str,
}
fn main() {
let excerpt;
{
let novel = String::from("Call me Ishmael.");
excerpt = Excerpt { part: novel.as_str() };
} // novel drop 了
println!("{}", excerpt.part); // E0597: `novel` does not live long enough
}🧠 原理:生命周期参数在结构体上是「传染性」的:
Excerpt<'a>的实例一旦存在, 编译器就会追踪'a,直到最后一个实例消失才允许novel被释放。 这就是为什么'a常被描述为「关系」:它把Excerpt和novel绑在一起。
初学者为什么更该让结构体「拥有」数据
rust
// 推荐写法:结构体拥有数据,没有任何生命周期参数
struct Excerpt {
part: String,
}
impl Excerpt {
fn from_sentence(text: &str) -> Self {
let end = text.find('.').unwrap_or(text.len());
Excerpt { part: text[..end].to_string() }
}
}
fn main() {
let e = Excerpt::from_sentence("Call me Ishmael. Some years ago...");
println!("节选:{}", e.part); // 输出:节选:Call me Ishmael
}代价与收益:
| 方案 | 优点 | 代价 |
|---|---|---|
struct Excerpt<'a> { part: &'a str } | 零拷贝;适合解析器、零分配场景 | 生命周期参数传染到所有使用它的函数/结构体;难以放进 Vec、跨线程、跨 async 边界 |
struct Excerpt { part: String } | 自包含、可自由 move / 存入集合 / 跨线程;API 简单 | 一次分配 + 一次拷贝 |
⚠️ 陷阱:初学阶段强行设计「零拷贝结构体」,会让每个函数签名都长出
<'a>, 报错也从「借用的值活了多久」变成一片E0597/E0515。 先让结构体拥有String,把程序跑通;等到剖析(profiling)证明拷贝是瓶颈再改。
🚀 进阶:真正的零拷贝解析库(如
serde的#[serde(borrow)]、nom的&'a [u8]输出)确实大量使用生命周期参数,但那是性能驱动的设计, 不是入门姿势。
切片与所有权
切片是「借来的一段连续数据」
切片(slice)是胖指针(fat pointer):一个地址 + 一个长度,共 16 字节(64 位平台), 指向别人拥有的连续内存。它自己不拥有任何数据。
&str 或 &[T] 的运行时表示(16 字节,栈上)
┌──────────────────┬──────────┐
│ ptr ─────────────┼─► 数据 │ 数据本身归 String / Vec / 数组所有
│ 0x55f0_2c10 │ len = 5 │ 切片只是「这个范围借我看看」
└──────────────────┴──────────┘&str 是 String 的借用
rust
fn main() {
let s = String::from("hello world");
let hello: &str = &s[0..5]; // 借用第 0..5 字节
let world: &str = &s[6..11];
let all: &str = &s[..];
let tail: &str = &s[6..];
println!("{hello} / {world} / {all} / {tail}");
// 输出:hello / world / hello world / world
} s ──[ptr|11|11]──►┌─h─e─l─l─o─ ─w─o─r─l─d─┐
└─┬───────────┬───────────┘
│ │
hello = [ptr+0, 5] ─┘ └─ world = [ptr+6, 5]
三个变量共享同一块堆内存,没有任何字节被复制⚠️ 陷阱:
&s[0..5]按字节切。如果切在 UTF-8 字符中间, 运行期 panic:end byte index 2 is not a char boundary; it is inside '你', 这不是编译错误。想要安全地按字符处理,用s.chars()、s.char_indices()或s.get(0..5)(返回Option<&str>而不是 panic)。
rust
fn main() {
let s = String::from("你好世界");
// &s[0..2] 会 panic:'你' 占 3 字节。安全写法:
let safe = s.get(0..3).unwrap_or("");
println!("{safe}"); // 输出:你
}&[T] 是 Vec<T> 与数组的借用
rust
fn main() {
let v: Vec<i32> = vec![1, 2, 3, 4, 5];
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let vs: &[i32] = &v; // Vec<i32> → &[i32]
let as_: &[i32] = &arr; // [i32; 5] → &[i32]
let mid: &[i32] = &v[1..3]; // 子切片,元素是 2, 3
println!("{} {} {:?}", vs.len(), as_.len(), mid); // 输出:5 5 [2, 3]
}同一个函数可以同时处理 Vec 和数组,因为它接受的是切片:
cpp
fn sum_all(values: &[i32]) -> i32 {
values.iter().sum()
}
fn main() {
let v = vec![1, 2, 3];
let a = [4, 5];
println!("{}", sum_all(&v)); // 输出:6
println!("{}", sum_all(&a)); // 输出:9
}💡 对照:
&[T]≈ C++ 的std::span<T>(C++20)或 Go 的切片(但 Go 切片自带 长度且不区分所有权);&str≈std::string_view(C++17)。 区别在于 Rust 的借用检查器会保证被切的那块内存活得比切片长。
参数类型选择的经验法则
| 不要写 | 改写成 | 原因 |
|---|---|---|
fn f(s: &String) | fn f(s: &str) | &String 只接受 &String;&str 同时接受 &String、&str、&'static str。靠解引用强制转换(deref coercion)自动完成转换 |
fn f(v: &Vec<T>) | fn f(v: &[T]) | 同理:&[T] 还能接受数组、子切片、Vec 的切片 |
fn f(s: String) | fn f(s: &str) 或保持 String | 需要拥有权(存进结构体、跨线程)才要 String;只读就借 |
fn f(v: Vec<T>) | fn f(v: &[T]) | 只读时没必要夺取 Vec 的所有权 |
rust
// 反面示例:&String 参数把调用方限制死了
fn old_style(s: &String) -> usize { s.len() }
// 正面示例:&str 参数接受多种实参
fn new_style(s: &str) -> usize { s.len() }
fn main() {
let owned = String::from("hello");
let literal = "hello";
println!("{} {}", old_style(&owned), new_style(literal)); // 输出:5 5
// old_style(literal); // 编译失败:expected `&String`, found `&str`
println!("{}", new_style(&owned)); // 输出:5
}🧠 原理:解引用强制转换只朝一个方向走:
&String → &str、&Vec<T> → &[T]、&Box<T> → &T。反过来不行(&str不能变成&String)。 所以参数类型要选更宽松的那一端。clippy的ptr_arglint 会自动指出这类写法:cargo clippy见「常见坑与编译错误」。
🚀 进阶:
String→&str可以发生在AsRef<str>泛型参数上 (fn f(s: impl AsRef<str>)),但会让签名变复杂且影响类型推断。 入门与多数库代码用&str就够了。
内部可变性预告
一句话预告
借用规则听起来很死:「共享就不许改,改就不许共享」。 但有些数据结构(缓存、图、观察者列表、Rc 的共享状态)逻辑上必须在共享的同时修改。 Rust 的出路叫内部可变性(interior mutability):把借用检查推迟到运行期, 用 Cell/RefCell/Rc/Mutex/RwLock 这些类型自己保证安全。 完整讲解见内部可变性与 Arc。
Cell / RefCell / Rc 适用场景总结
| 类型 | 共享方式 | 可变性 | 是否线程安全 | 典型用途 |
|---|---|---|---|---|
Cell<T> | 按值进出(get/set/replace),从不给出 &T | 通过 &self 改内部值(要求 T: Copy 或用 replace) | 否(!Sync) | 计数器、标志位、Cell<bool> 做惰性初始化开关 |
RefCell<T> | 运行时借用:borrow() 给 Ref<T>,borrow_mut() 给 RefMut<T> | 通过 &self 改;违反规则时 panic 而不是编译错误 | 否(!Sync) | 单线程图、树节点互指、测试替身(mock)、需要共享可变的缓存 |
Rc<T> | 多个所有者,引用计数 +1/-1 | 不可变(Rc<T> 只给 &T,要改就套 RefCell) | 否(!Send/!Sync,计数非原子) | 单线程多所有者;Rc<Node> 构成的图 / DOM 树 |
Rc<RefCell<T>> | 多所有者 + 可改 | 组合:Rc 管共享,RefCell 管可变 | 否 | 单线程共享可变状态的事实标准写法 |
Arc<T> | 原子引用计数 | 不可变 | 是(Send/Sync,要求 T: Send + Sync) | 跨线程共享只读数据 |
Arc<Mutex<T>> | 原子计数 + 互斥锁 | 加锁后可改 | 是 | 跨线程共享可变状态 |
Mutex<T> / RwLock<T> | 独占 / 读写锁 | 加锁后可改 | 是 | 线程间互斥;RwLock 适合读多写少 |
⚠️ 陷阱:
RefCell的失败是运行期 panic(already borrowed: BorrowMutError), 不是编译错误。这正是它被称为「把借用检查搬到了运行期」的含义 —— 你换来了灵活性,放弃了编译期保证。别把它当默认选择。⚠️ 陷阱:
Rc的循环引用会泄漏(计数永远不归零)。 需要打破环时用Weak<T>(弱引用,不增加强计数)。
与其他语言的对照
同一件事,五种语言
| 场景 | Java | Python | C++ | Go | Rust |
|---|---|---|---|---|---|
赋值 b = a(对象 / 堆类型) | 拷贝引用(别名),a、b 同一对象 | 拷贝引用(别名) | 拷贝构造:深拷贝一份(a 仍可用) | 拷贝指针(别名),GC 管回收 | move:a 立即作废,堆数据不复制 |
| 想让两个名字都能用 | 天然可以(共享可变) | 天然可以 | b = a; 隐式拷贝构造 | 天然可以 | 显式 a.clone() 或借用 &a |
| 想让「交出所有权」 | 无此概念 | 无此概念(del a 只是删名字) | b = std::move(a);(a 未指定状态) | 无此概念 | let b = a;(默认行为) |
| 只读传参 | 引用传递,靠约定不改 | 引用传递,靠约定不改 | const T& | 值传递或指针,无 const 保证 | &T,编译期保证 |
| 可写传参 | 引用传递,直接改 | 引用传递,直接改 | T& | 指针 *T | &mut T,且调用方需 let mut |
| 返回内部数据的引用/视图 | 返回引用,GC 保证不悬垂 | 返回引用,GC 保证不悬垂 | 返回 T& 或 string_view,可能悬垂(UB) | 返回指针/slice,GC 保证不悬垂 | 返回值必须带生命周期,E0515 拦下悬垂 |
| 释放内存 | GC 自动 | 引用计数 + GC | 析构函数(RAII)+ 手动 delete | GC 自动 | 作用域结束自动 drop(编译期定时机) |
| 双重释放 | 不可能 | 不可能 | 可能(delete p; delete p;) | 不可能 | 不可能(Copy 与 Drop 互斥) |
| 内存泄漏 | 可能(长生命周期容器) | 可能(循环引用 / 全局缓存) | 可能(漏 delete、异常路径) | 可能(goroutine 持有、全局) | 可能(Rc 成环、Box::leak、mem::forget) |
| 读写别名同时存在 | 允许(线程不安全则运行期出错) | 允许(GIL 掩盖了部分问题) | 允许(UB 的常见来源) | 允许(go vet 只能警告一部分) | 编译错误(E0502/E0499) |
| 共享可变状态的默认解法 | synchronized / AtomicXxx / 不可变类 | threading.Lock / 不可变类型 | std::mutex / 智能指针 | sync.Mutex / channel | RefCell(单线程)/ Arc<Mutex<T>>(多线程) |
| 迭代中修改容器 | ConcurrentModificationException(运行期) | RuntimeError: dictionary changed size(运行期) | UB(未定义行为) | 行为未定义或 panic | 编译错误(E0502) |
「Rust 的 move 在机器码上常常就是 memcpy,甚至什么都不做」
这一句要拆成三层来理解:
- 栈上描述符的复制:
String的 move 在机器码上等价于 3 个机器字(24 字节)的复制, 通常就是两三条mov指令,或者一个内联后的memcpy。 堆上的实际数据完全没动,也没有free/malloc。 - 优化器经常把它消掉:如果 move 之后旧变量再没人用, LLVM 会把新旧变量合并成同一个栈槽或寄存器,此时 move 在机器码上一条指令都没有。 它纯粹是一个编译期的「所有权归属」变更。
- 编译期约束,零运行期机制:没有引用计数、没有标记位、没有运行期检查。 Rust 没有「这个值被 move 过」的运行时标志 —— 一旦你违反规则,程序根本编译不出来。
Rust move 的三个层次
源码: let b = a; ← 你看到的
├─ 语义层(编译期):所有权从 a 转移到 b,a 作废 ← 编译器实际做的事
├─ MIR/HIR 层:a 被标记为「已移动」,后续使用报 E0382
└─ 机器码层:3 个机器字复制 / 或被优化器完全消除 ← 通常是 0~3 条指令🧠 原理:这就是「零成本抽象(zero-cost abstraction)」的字面含义 —— 你得到的是编译期的安全检查,付出的运行期成本是 0; 付出的编译期成本是「借用检查器可能拒绝你」。
💡 对照:Go 的切片/指针对应「总是别名 + GC」;Go 里想把
[]byte交给别人 而不担心它被改,只能copy()一份。Rust 里&[u8]就够, 因为只要这个借用活着,原作者根本改不了。