从C到Rust:移动语义、引用传递与生命周期——一次讲清楚

移动语义、引用传递与生命周期------一次讲清楚

独立文章。从 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 = bs2 = 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 被调用且只被调用一次" 这个约束。

相关推荐
星栈1 小时前
Dioxus 表单处理:从输入、校验到文件上传,一条链路讲透
前端·rust·前端框架
doiito1 小时前
【Agent Harness】Gliding Horse 上下文动态感知与智能压缩:让 Agent 真正“听得进”每一句话
ai·rust·架构设计·系统设计·ai agent
Bigger11 小时前
Tauri (26)——托盘图标总对不上系统主题?一行 Template Image 搞定
前端·rust·app
doiito17 小时前
【Agent Harness】TPS的“自工程完结”教会了我一件事:别把Bug留给下一道工序
架构·rust
doiito1 天前
【Agent Harness】Gliding Horse 记忆系统深度剖析:像 CPU 一样思考的 AI 记忆架构
ai·rust·架构设计·系统设计·ai agent
doiito2 天前
【Agent Harness】Gliding Horse 给 Agent OS 装上双曲空间引擎与默克尔树边云同步
ai·rust·架构设计·系统设计·ai agent