移动语义、引用传递与生命周期------一次讲清楚
独立文章。从 C 程序员的视角,拆解 Rust 中三个最容易被误解的概念:
移动语义、引用传递的方向性、生命周期标注的真正意义。
一、移动语义:Drop 需要被调用且只调用一次
1.1 C 的赋值------对所有类型做同一件事
c
// C ------ 赋值就是复制字节
int x = 42;
int y = x; // y = 42, x 仍然是 42------两者都可用
// 对结构体也一样:
struct Point { int x, y; };
struct Point a = {1, 2};
struct Point b = a; // memcpy------a 和 b 都有效
// 对资源也一样:
FILE* f = fopen("a.txt", "r");
FILE* f2 = f; // ✅ C 允许------复制指针
// f 和 f2 指向同一个 FILE
C 的赋值对所有类型做同一件事:复制字节。原变量继续可用。
1.2 Rust 的赋值------也是复制字节
Rust 的赋值在运行时和 C 完全一样------就是复制字节:
rust
// Rust 的赋值在底层也是 memcpy
let s1 = String::from("hello");
// s1 = { ptr: 0x..., len: 5, cap: 5 } ← 24 字节(三个 usize)
let s2 = s1;
// s2 = { ptr: 0x..., len: 5, cap: 5 } ← 同样的 24 字节被复制
// 运行时:和 C 的 memcpy 一样,只是一次内存复制
运行时没有区别。 区别在编译期------复制字节之后,编译器做了什么。
1.3 核心约束:Drop 必须被调用且只被调用一次
Rust 有一个 C 没有的机制:Drop。
当一个类型持有资源(堆内存、文件句柄、锁),它实现 Drop,在离开作用域时自动释放资源。
rust
{
let s = String::from("hello");
// s 的堆内存
} // ← 这里 s.drop() 被自动调用,释放堆内存
一个值被 Drop 一次------两次是 double-free,零次是内存泄漏。
Rust 的编译器必须保证:每个值的 Drop 被调用且只被调用一次。
1.4 Copy 与 Drop 互斥------这是关键
rust
// 如果 Copy 和 Drop 可以共存:
#[derive(Copy)]
struct File { fd: i32 }
impl Drop for File {
fn drop(&mut self) { close(self.fd); }
}
let f = File { fd: 3 };
let g = f; // Copy:两个变量都有效
// 离开作用域时:
// g.drop() → close(3) ✅
// f.drop() → close(3) ❌ double-free!
这就是为什么 Copy 和 Drop 互斥。如果一个类型需要 Drop,它就不能是 Copy。
rust
类型需要释放资源 → 实现 Drop → 不能是 Copy → 赋值时不能复制字节后让原值继续有效
1.5 移动语义:保证 Drop 恰好调用一次的唯一方式
回到赋值:
rust
let s1 = String::from("hello");
let s2 = s1;
String 实现了 Drop(它持有堆内存),所以它不是 Copy。
不是 Copy,意味着赋值后不能两个变量都有效------否则离开作用域时 Drop 会调用两次,double-free。
所以编译器只能做一件事:赋值后把 s1 标记为失效。
这就是"移动语义"的全部含义。
sql
赋值 = 复制字节(memcpy) + 编译器把原变量标记为"失效"
为什么失效?
因为如果原变量仍然有效,两个变量指向同一块堆内存,
两个 Drop → double-free。
所以:
左值获得数据 → 离开作用域时 Drop 释放资源 ✅ 一次
右值被标记失效 → 不会调用 Drop ✅ 不 double-free
1.6 从汇编看移动和复制
rust
let a: i32 = 42; // i32 是 Copy
let b = a; // 编译为一条 mov 指令
// a 和 b 都有效
let s1 = String::from("hello"); // String 不是 Copy
let s2 = s1; // 编译为同一条 mov 指令
// s1 被标记失效
运行时,a = b 和 s2 = s1 的汇编没有区别------都是一次数据复制。区别只在编译器的静态分析中:
-
i32是 Copy → 编译器不标记a失效 -
String不是 Copy → 编译器标记s1失效,之后对s1的访问都报错
移动语义不是"让数据移动了"------数据没动,字节被复制了。移动语义是"让原变量失效了"------防止 double-free。
1.7 C 中没有移动语义的代价
c
FILE* f = fopen("a.txt", "r");
FILE* f2 = f; // C 不标记 f 失效
fclose(f); // 释放
fclose(f2); // ❌ double-free------编译器不阻止
// 解决方案:靠人记住"赋值后不要用原变量"
// 或者靠编码规范:"FILE* 赋值后原指针即失效"
// 但规范不是编译器------不会阻止你犯错
rust
let f = File::open("a.txt")?;
let f2 = f; // f 被标记失效
// println!("{}", f); // ❌ 编译错误------编译器阻止你
// f2 离开时 Drop 调用 close------一次,正确
Rust 用编译器的"原变量失效"规则,替代了 C 中靠人记忆的"赋值后不要用原变量"约定。
二、引用只能沿着栈向下传递
2.1 栈的方向
scss
调用栈(从高地址到低地址):
┌────────────────┐ ← 高地址
│ main() │
│ ├─ data │
├────────────────┤
│ func_a() │
│ ├─ x: &data │ ← main::data 的引用(向下传递)
├────────────────┤
│ func_b() │
│ ├─ y: &x │ ← func_a::x 的引用(再向下传递)
└────────────────┘ ← 低地址(栈顶)
引用从调用者传到被调用者------这是"向下"。被调用者的生命周期包含在调用者的生命周期之内,所以引用总是有效的。
2.2 为什么不能"向上"传递
rust
fn bad() -> &i32 {
let x = 42;
&x // ❌ 编译错误
} // x 在函数返回时被销毁
函数的局部变量在函数返回时被销毁。如果你返回了对它的引用,调用者拿到的就是一个悬垂指针。
引用的生命周期不能超过它指向的数据的生命周期。 在栈上它等价于"引用只能向下传递"。
2.3 C 中同样的问题
c
int* bad() {
int x = 42;
return &x; // ⚠️ 警告------但代码能编译
} // x 被销毁
void caller() {
int* p = bad();
printf("%d", *p); // ❌ 未定义行为
}
C 编译器对这种错误只给一个警告(如果能给的话),代码仍然能编译和运行。
Rust 的改进:把"不能返回局部引用"变成编译错误,而不是运行时问题。
2.4 向下传递的常见形式
rust
// 1. 函数参数传引用(向下)
fn process(data: &[i32]) {
// data 来自上层------安全
}
// 2. 方法调用
fn get(&self) -> &T {
// self 来自调用者------安全
}
// 3. 从集合中迭代
for item in &collection {
// item 指向 collection 内部------安全
}
// 4. 调用链中传递
fn a(data: &[i32]) {
b(data); // data 向下传
}
fn b(data: &[i32]) {
c(data); // data 再向下传
}
2.5 但有时数据需要"向上"传递------堆内存
引用不能向上传递。但如果数据确实需要离开当前函数,怎么办?
三个选择:
r
选择 1:返回值本身(栈上)------数据被复制一份到调用者的栈帧
适用:数据小、不需共享
选择 2:返回 Box<T>(堆上唯一所有权)------数据在堆上,函数返回一个指针
适用:数据大、递归类型、trait 对象
选择 3:返回 Rc<T> 或 Arc<T>(堆上共享所有权)------多个消费者共享同一数据
适用:需要多方引用同一数据
关键区别:引用不能向上传递,但拥有所有权的堆指针可以。
rust
// ❌ 引用不能向上传递
fn bad() -> &i32 {
let x = 42;
&x // 编译错误------x 在栈上,离开函数就销毁
}
// ✅ 栈上的值可以向上传递(被复制)
fn good() -> i32 {
let x = 42;
x // 42 被复制到调用者的栈帧
}
// ✅ 堆上的数据可以向上传递(所有权转移)
fn heap_good() -> Box<i32> {
let b = Box::new(42);
b // Box 的所有权转移给调用者,堆上的 42 继续存活
}
Box<i32> 的所有权从函数内转移到函数外------堆上的 i32 不受栈帧销毁影响。
从 Box 到 Rc 到 Arc:一步步看
第一步:Box------唯一所有权
场景:一个数据只能有一个拥有者。
c
// C 的做法
struct Data { int value; };
struct Data* create_data(int val) {
struct Data* d = malloc(sizeof(*d));
d->value = val;
return d; // 调用者负责 free
}
void use_data() {
struct Data* d = create_data(42);
// 使用 d ...
free(d); // 必须记得------否则泄漏
// 如果要传给另一个函数:
process(d); // d 的所有权还在调用者这儿
free(d); // 仍然要 free
// 如果 process 内部也 free 了?
// double-free!C 不阻止
}
rust
// Rust 的做法
struct Data { value: i32 }
fn create_data(val: i32) -> Box<Data> {
Box::new(Data { value: val })
// Box 的所有权转移给调用者
// 不需要手动 free------Drop 自动释放
}
fn use_data() {
let d = create_data(42);
// 使用 d ------ 通过 Deref 自动解引用
println!("{}", d.value);
// d 离开作用域 → Box::drop → dealloc 堆内存
// 不可能忘记,不可能 double-free
// 转移给另一个函数:
process(d); // d 被 move 到 process
// println!("{}", d.value); // ❌ d 已移出------编译器阻止
}
Box<T> = 堆内存的唯一拥有者。它和 C 的 malloc + free 做同样的事,但自动配对、不会忘。
第二步:Rc------单线程共享所有权
场景:多个部分需要共享同一数据,谁最后一个用完谁释放。
c
// C 的做法------手动引用计数
struct RefCounted {
int refcount;
int value;
};
void ref_inc(struct RefCounted* obj) {
obj->refcount++;
}
void ref_dec(struct RefCounted* obj) {
obj->refcount--;
if (obj->refcount == 0) {
free(obj); // 最后一个使用者释放
}
}
void use_shared() {
struct RefCounted* data = malloc(sizeof(*data));
data->refcount = 1;
data->value = 42;
// 传给模块 A
ref_inc(data); // refcount = 2
module_a_process(data);
ref_dec(data); // refcount = 1
// 传给模块 B
ref_inc(data); // refcount = 2
module_b_process(data);
ref_dec(data); // refcount = 1
// 自己用完
ref_dec(data); // refcount = 0 → free
}
手动引用计数的问题:
css
1. ref_inc 和 ref_dec 必须严格配对------漏一个就泄漏,多一个就提前释放
2. 没有编译期检查配对是否完整
3. 循环引用:A 引用 B,B 引用 A → 两者 refcount 永不归零
rust
// Rust 的做法------Rc 自动管理引用计数
use std::rc::Rc;
fn use_shared() {
let data = Rc::new(Data { value: 42 });
// refcount = 1
// 传给模块 A------Rc::clone 递增引用计数
let a_data = Rc::clone(&data); // refcount = 2
module_a_process(a_data);
// a_data 离开作用域 → refcount = 1
// 传给模块 B
{
let b_data = Rc::clone(&data); // refcount = 2
module_b_process(b_data);
} // refcount = 1
// 自己用完
// data 离开作用域 → refcount = 0 → Drop 自动释放
// ✅ 不可能漏------Rc::clone() 自动 inc,Drop 自动 dec
}
Rc<T> = 自动化的引用计数。clone() 递增,Drop 递减,归零时自动释放。
但 Rc<T> 不是线程安全的------它的引用计数不是原子操作。所以:
第三步:Arc------多线程共享所有权
场景:多个线程共享同一数据。
c
// C 的做法------原子引用计数
#include <stdatomic.h>
struct AtomicRefCounted {
atomic_int refcount;
int value;
};
// 必须用原子操作
atomic_fetch_add(&obj->refcount, 1); // inc
if (atomic_fetch_sub(&obj->refcount, 1) == 1) {
free(obj); // 最后一个使用者释放
}
// 还得选对 memory order:
atomic_fetch_add(&obj->refcount, 1, memory_order_relaxed);
// 如果选错了------诡异的并发 bug,极其难调试
rust
// Rust 的做法------Arc 自动使用正确的内存序
use std::sync::Arc;
use std::thread;
fn use_multi_thread() {
let data = Arc::new(Data { value: 42 });
let mut handles = vec![];
for _ in 0..5 {
let d = Arc::clone(&data); // ✅ 正确的内存序自动选择
handles.push(thread::spawn(move || {
println!("{}", d.value); // ✅ Arc 是 Send + Sync
}));
}
// 所有线程结束后,引用计数归零 → 自动释放
}
三者的关系
r
Box<T> = 唯一所有权(≈ C 的 malloc + 手动 free,但自动 Drop)
↑
Rc<T> = 单线程共享(≈ C 的手动 refcount,但 clone/drop 自动配对)
↑
Arc<T> = 多线程共享(≈ C 的原子 refcount,但内存序自动选对)
用量级递进:
rust
能用 Box 不用 Rc------唯一所有权更简单、更轻量
能用 Rc 不用 Arc------非原子计数更快(单线程)
Arc 只在真正需要多线程共享时才用
对比:三种方式的一句话总结
| C 的做法 | Rust 的做法 | 解决的问题 |
|---------|-----------|-----------|
| malloc + 手动 free | Box::new + 自动 Drop | 忘记 free / double-free |
| 手动 refcount++/-- | Rc::clone / Drop 自动配对 | 漏掉 inc/dec 配对 |
| 原子 fetch_add + 手动选 order | Arc::clone 自动选正确 order | 选错 memory order |
三、生命周期标注:只在"传入引用并返回引用"时需要
3.1 什么时候不需要写生命周期
大多数情况下,你不需要写生命周期标注:
rust
// 情况 1:没有引用参数------不需要
fn add(x: i32, y: i32) -> i32 { x + y }
// 情况 2:有引用参数但不返回引用------不需要
fn process(data: &[i32]) {
for x in data { println!("{}", x); }
}
// 情况 3:只有"一个"引用参数并返回引用------不需要(生命周期的第一省略规则)
fn first(x: &[i32]) -> &i32 {
&x[0]
}
// 情况 4:方法中有 &self 并返回引用------不需要(第二省略规则)
impl Foo {
fn get(&self) -> &i32 {
&self.x
}
}
3.2 什么时候需要
只有当函数接收多个引用/借用参数,并返回其中一个引用时------才需要生命周期标注。
rust
// 两个参数,返回其中一个------编译器不知道是哪个
fn longest(x: &str, y: &str) -> &str {
// ❌ 编译错误
if x.len() > y.len() { x } else { y }
}
// 需要告诉编译器:"返回值的生命周期和 x 相同"
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// 或者:"和 y 相同"
fn longest<'a>(x: &str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
生命周期标注的唯一作用:把返回值的生命周期连接到某个输入参数的生命周期上。
3.3 方法中的特殊情况
rust
struct Foo { x: i32 }
impl Foo {
// 不需要写生命周期------编译器自动用 &self 的
fn get(&self) -> &i32 {
&self.x
}
// 等价于:fn get<'a>(&'a self) -> &'a i32
}
因为所有方法都默认把 &self 的生命周期连接到返回值(如果返回值是引用)。
3.4 省略规则总结
rust
规则 1:每个输入引用获得自己的生命周期
fn f(x: &T) → fn f<'a>(x: &'a T)
fn f(x: &T, y: &U) → fn f<'a, 'b>(x: &'a T, y: &'b U)
规则 2:如果只有一个输入引用,它的生命周期赋给所有输出引用
fn f(x: &T) -> &U → fn f<'a>(x: &'a T) -> &'a U
规则 3(方法):如果 &self 存在,它的生命周期赋给所有输出引用
fn f(&self) -> &T → fn f<'a>(&'a self) -> &'a T
需要手写生命周期的唯一场景:多个输入引用 + 输出引用。
rust
fn compare<'a>(x: &'a str, y: &str) -> &'a str // 返回和 x 同生命周期
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str // 取较小的生命周期
四、结构体中的引用:拆成两部分来理解
4.1 问题
rust
struct Foo<'a> {
x: &'a i32, // 引用------需要告诉编译器它指向哪里
y: i32, // 值------没有生命周期问题
}
把结构体拆成两部分来看:
rust
struct Foo {
┌─ 引用部分:x: &'a i32 → 需要 'a,因为 x 指向别处的数据
└─ 值部分: y: i32 → 不需要生命周期,值就地存储
}
4.2 从"拆开传递"理解生命周期
rust
struct Foo<'a> {
x: &'a i32,
y: i32,
}
// 当你把 Foo 传给一个函数时:
fn process(f: &Foo) -> i32 {
*f.x + f.y
}
// 可以理解为结构体被"拆开"成两个参数传递:
// fn process(f_x: &i32, f_y: i32) -> i32 {
// *f_x + f_y
// }
// f_x 对应引用部分(需要生命周期),f_y 对应值部分(不需要)
结构体中的引用字段和值字段,在编译器眼中本来就是分开跟踪的:
rust
// 值部分(y: i32):没有生命周期问题,直接复制
// 引用部分(x: &'a i32):需要知道它指向的数据的生命周期
fn borrow_foo(f: &Foo) -> &i32 {
f.x // ✅ 返回 f.x------生命周期等于 f 的 'a
}
fn copy_val(f: &Foo) -> i32 {
f.y // ✅ 返回 f.y------值,没有生命周期问题
}
4.3 为什么 struct 需要生命周期标注
rust
// 如果没有生命周期标注:
struct Foo {
x: &i32, // ❌ 编译器不知道 &i32 指向的数据能活多久
y: i32,
}
// 编译器无法检查:
fn example() -> &i32 {
let f = Foo { x: &42, y: 0 };
&f.x // 返回了 f.x------但 f 离开函数后会被销毁
}
// 如果没有 'a,编译器无法知道这个错误
所以:
rust
struct Foo<'a> {
x: &'a i32, // 'a 告诉我们:"这个引用至少活 'a 这么长"
y: i32,
}
// 'a 不是 Foo 的生命周期------它是 x 指向的数据的生命周期
// Foo 本身的生命周期 ≤ 'a(因为内部的引用不能超过指向的数据)
4.4 结构体字段省略示例
rust
// 例 1:只有值字段------不需要生命周期
struct Point { x: f64, y: f64 }
// 例 2:只有一个引用字段------需要一个生命周期
struct Name<'a>(&'a str);
// 例 3:两个引用字段------各自的来源不同
struct TwoRefs<'a, 'b> {
first: &'a str,
second: &'b str,
}
// 例 4:值 + 引用------只需要给引用加生命周期
struct Config<'a> {
name: &'a str, // 需要 'a
version: u32, // 不需要
}
// 理解:version 没有生命周期问题,name 需要生命周期
五、把它们串起来
5.1 完整的逻辑链
sql
1. Rust 有 Drop 机制------值离开作用域时自动释放资源
2. Drop 必须被调用且只被调用一次
- 零次 = 内存泄漏
- 两次 = double-free(UB)
3. Drop 与 Copy 互斥(否则 Copy + Drop → double-free)
4. 所以对于非 Copy 类型(即实现了 Drop 的类型),
赋值时不能让两个变量都有效------否则 Drop 被调两次
5. 解决方案:赋值后把原变量标记为失效------这就是"移动语义"
运行时:和 C 一样是 memcpy
编译期:编译器不允许再使用原变量
6. 引用必须遵守方向性(向下传递,不能向上)
和移动语义无关------引用不拥有数据,不存在 Drop 问题
但引用指向的数据不能先于引用被销毁
7. 生命周期标注只在一个场景需要:
多个输入引用 + 返回其中一个引用时,告诉编译器返回值与哪个输入关联
8. 结构体中的引用字段:
拆开为"引用部分"和"值部分"理解
引用部分需要生命周期标注,值部分不需要
5.2 和 C 的对比
c
// C 中没有移动语义、没有引用方向、没有生命周期
// 一切都靠程序员记住
// C 程序员需要自己做 Rust 编译器做的事:
// 1. 记得不要 double-free → Rust: move 语义 + Drop + Copy 互斥
// 2. 记得不要返回局部变量地址 → Rust: 编译错误
// 3. 记得引用不能超过被引用者的寿命 → Rust: 生命周期检查
// 4. 记得结构体中的指针要指向有效内存 → Rust: 'a 标注 + 检查
一句话总结 :移动语义不是因为 Rust 发明了"移动"这个操作------运行时移动和复制都是 memcpy,没有区别。移动语义是因为 Rust 有 Drop 机制,而 Drop 和 Copy 互斥,所以非 Copy 类型在赋值后必须让原变量失效------这样就叫"移动"。整件事的起点,是 "Rust 需要保证 Drop 被调用且只被调用一次" 这个约束。