本文写作时,极大的借鉴了《The Rust Programming Language》(俗称“Rust 圣经”)中相关章节的内容和结构,在此表示感谢。
写 Rust 的第一道坎,不是语法,也不是宏,而是“我明明只是把变量传给你用一下,怎么它就不属于我了?”
这类困惑通常并不奇怪,因为我们习惯了别的语言那套“内存默认有人兜底”的模型,比如 Javascript、Golang 的自动垃圾回收机制。Rust 恰恰相反:它要求你把内存这件事想清楚,然后把规则写进类型系统,交给编译器在编译期强制执行——这就是所有权系统的核心意义。
为了尽量讲清楚,本文按一条线往前走:先讲堆栈,再讲所有权,再讲借用。
内存管理这件事,语言大致分三派
所有程序都要用内存:申请空间、使用、释放。麻烦在于“什么时候释放、谁来释放”。历史上主流语言大致走过三条路:
- GC:运行时找出不再使用的内存并回收(Javascript、Golang)。
- 手动管理:程序员显式分配/释放(C/C++)。
- 所有权:编译期按规则检查,运行期不额外付费(Rust)。
Rust 选择第三条路的“野心”很明确:既想要接近 C/C++ 的性能,又想把悬空指针、二次释放、数据竞争这类内存安全问题,尽量提前到编译阶段解决。
先把地基打牢:栈(Stack)与堆(Heap)
如果你只写脚本语言,可能一辈子不必深究堆栈。但在 Rust 里,理解堆栈会直接决定你是否看得懂“移动/借用”的行为。
栈:后进先出,大小必须固定
栈像一叠盘子:只能从顶部放、从顶部拿,后进先出。入栈/出栈非常快,但前提是:每个值的大小在编译期必须已知且固定,否则你无法“精确地弹出”你想要的那块数据。
你可以把一次函数调用想成:
- 参数、局部变量依次压栈
- 函数结束按相反顺序出栈
- 出栈就意味着那段栈内存可以被复用(所以“拿着栈上局部变量的地址回去”很危险)
堆:存放大小可变的数据,用指针去找它
堆适合“大小未知或可能变化”的数据。分配时,操作系统找一块足够大的空位,标记已使用,并返回该位置的指针。这个过程叫在堆上分配内存(allocating)。
关键细节是:堆上的数据本体不在栈里,但指向它的指针通常在栈里(因为指针大小固定)。你之后每次访问堆数据,都是通过栈上的指针去“导航”到堆上。
为什么堆更麻烦
栈是“自动管理”的:作用域结束就出栈; 堆是“散装的”:不跟着某个作用域自动消失,如果你不追踪它何时释放,就可能内存泄漏。Rust 的所有权系统,本质上就是把“堆上资源的释放责任”用规则固定下来。
所有权三条规则:把“释放责任”写死
Rust 的所有权规则可以背下来(先别急着理解,后面会用例子把它磨清楚):
- Rust 中每个值都有一个变量作为它的所有者
- 同一时刻一个值只能有一个所有者
- 所有者离开作用域时,这个值会被丢弃(drop)
注意第三条:“drop”不是抽象概念,它就是“释放资源”的那个动作——对栈上简单值没什么特别,对堆上数据就非常关键,因为它决定了堆内存何时释放。
一个最常见的误会:String 赋值为什么会让旧变量失效?
来看两段对比代码。
i32 这种简单类型:复制(Copy)就完事了
1let x = 5; 2let y = x; 3println!("x = {}, y = {}", x, y); // x 仍然可用 4
整数是固定大小的简单值,放在栈上,“复制 4 个字节”非常快,所以 Rust 直接做拷贝,x 不会失效。
String:背后有堆内存,不能随便“默认复制”
1let s1 = String::from("hello"); 2let s2 = s1; 3println!("s1 = {}, s2 = {}", s1, s2); // 编译报错,s1 失效 4
String 本质上是一个“句柄”:栈上存着(堆指针、长度、容量),真正的字符数据在堆上。
这时如果允许 s1、s2 同时指向同一块堆内存,就会撞上所有权第二条:一个值只能有一个所有者。更现实的问题是:作用域结束时,谁来 drop?如果两个都 drop,就可能二次释放。Rust 不赌运气,它选择在赋值时把所有权转移给新变量,让旧变量立刻失效,从根上切断这类问题。
编译器的报错也会直接告诉你:String 没实现 Copy,因此发生了 move,之后再用旧变量就不行。
Move / Clone / Copy:三个词,三种成本
把这三者分清,所有权就通了八成。
Move:转移“释放责任”,通常不复制堆数据
对 String 这种拥有堆资源的类型,let s2 = s1; 的核心不是“复制内容”,而是“把释放责任交给 s2”。这样性能很好,因为你没有做深拷贝。
Clone:深拷贝,复制堆上内容(贵)
如果你确实需要两份独立的数据,用 clone():
1let s1 = String::from("hello"); 2let s2 = s1.clone(); 3println!("s1 = {}, s2 = {}", s1, s2); 4
Rust 不会自动深拷贝;你显式 clone,就等于显式选择了更高成本。官方也提醒:热点路径上滥用 clone 会显著拖慢性能。
Copy:对栈上固定大小类型,赋值就是复制
Rust 有个 Copy 特征:实现了它的类型,在赋值/传参时会发生拷贝,旧变量仍可用。大体规则是:不需要分配内存、没有“释放资源”负担的类型往往可 Copy,比如整数、bool、浮点、char、只包含 Copy 成员的元组、不可变引用 &T 等。
这里顺便点一下:可变引用 &mut T 不能 Copy,因为“到处复制可写钥匙”会直接破坏后面的借用安全规则。
函数调用:传参和返回值同样会触发 Move/Copy
很多人第一次“被 Rust 教育”,就是在函数参数这里。
1fn takes_ownership(some_string: String) { /* ... */ } 2fn makes_copy(some_integer: i32) { /* ... */ } 3 4fn main() { 5 let s = String::from("hello"); 6 takes_ownership(s); // move,s 失效 7 8 let x = 5; 9 makes_copy(x); // copy,x 仍然可用 10} 11
String 传入函数后,所有权移动到形参;函数结束形参离开作用域,触发 drop,堆内存被释放。i32 因为 Copy,不影响外面的 x。
函数返回值也一样带着所有权:谁接住,谁就成为新所有者。
到这里你会发现:如果每次只是“借来用一下”,却必须 move 进去、再 move 出来,代码会很啰嗦。这正是下一章:借用 要解决的问题。
借用:我只想用你的数据,但不想拿走它
借用(borrowing)就是:创建引用,用引用访问数据,但不夺走所有权。现实比喻很直白:别人拥有某样东西,你可以借来用,用完要还。
不可变引用:只读借阅,不改内容
1fn calculate_length(s: &String) -> usize { 2 s.len() 3} 4 5fn main() { 6 let s1 = String::from("hello"); 7 let len = calculate_length(&s1); 8 println!("{} {}", s1, len); // s1 仍然可用 9} 10
这里发生了两件重要的事:
- 参数类型从
String变成&String,所以不会 move 所有权 - 引用离开作用域时,不会 drop 其指向的值,因为引用不是所有者
你可以把 &String 想成“只读门禁卡”:能进门看房间(访问数据),但不能装修(修改数据),也不能决定拆房(释放内存)。
可变引用:可以修改,但“同一时刻只能有一把可写钥匙”
如果你想通过借用去修改数据,需要 &mut:
1fn change(s: &mut String) { 2 s.push_str(", world"); 3} 4 5fn main() { 6 let mut s = String::from("hello"); 7 change(&mut s); 8} 9
关键限制来了:同一作用域内,特定数据只能存在一个可变引用。否则编译报错。
这条限制不是为了折磨人,而是为了在编译期消灭“数据竞争”的经典成因:
- 多个指针同时访问同一数据
- 至少一个在写
- 没有同步机制 这三条凑齐,就可能出现未定义行为。Rust 选择直接不让这种代码通过编译。
一个很实用的小技巧是:用 {} 缩小借用作用域,让前一个可变借用尽早结束,再创建下一个。
可变与不可变不能混用:读者不希望书被当场改写
借用还有一条总规则(也是你未来最常用的心法):
- 同一时刻:要么一个可变引用,要么任意多个不可变引用
- 引用必须总是有效的
直觉解释:多个只读同时存在没问题,因为大家都不写;但只要有人写,就必须保证“写的时候没人读、没人也在写”,否则你读到的可能是半更新状态的数据。
你以为引用的作用域跟 {} 一样?Rust 还做了 NLL 优化
很多“看起来应该能过”的代码,卡在借用检查上,原因往往是:你把引用的有效期想成了“到花括号结束”,但 Rust 新编译器会更聪明:引用的有效期持续到最后一次使用。
这种优化叫 Non-Lexical Lifetimes(NLL)。它让很多过去需要“手动改结构”的代码,现在可以自然通过。
悬垂引用:Rust 在编译期就把“拿着空气地址”这事堵死
悬垂引用(Dangling Reference)就是:指针还在,但它指向的值已经被释放或被重用。很多语言里这是运行时炸弹;Rust 的目标是让它变成编译时错误。
经典错误示例:
1fn dangle() -> &String { 2 let s = String::from("hello"); 3 &s 4} // s 离开作用域被释放 5
函数返回了 s 的引用,但 s 在函数结束时就被 drop 了,引用将指向无效内存。Rust 会直接拒绝编译,并提示:返回类型包含借用值,但找不到可借用的来源。
解决方式往往很“Rust”:直接返回拥有所有权的值,让所有权移动给调用者:
1fn no_dangle() -> String { 2 let s = String::from("hello"); 3 s 4} 5
这段就没有悬垂引用风险了。
把它们串起来:一个实用的心智模型
到这里,你可以用一句话把所有权系统记住:
Rust 用“唯一所有者 + 借用规则”来管理堆上资源的释放责任,并在编译期阻止别名写入、悬垂引用和数据竞争。
再把它映射到堆栈:
- 栈上简单值(固定大小)大多 Copy:复制成本小,没释放负担
- 堆上资源(
String、Vec等)默认 Move:转移释放责任,避免二次释放 - 借用(引用)让你“不拿走所有权也能用”:但读写别名必须被规则约束,否则就回到 C 的不安全世界
你会逐渐发现:Rust 不是“限制多”,而是把过去运行时才爆炸的问题,提前到你写代码的那一刻就指出来。它让你慢一点写对,但快很多维护。
借用规则速记卡
- 值的所有者离开作用域就 drop(对堆资源尤其关键)
String这类拥有堆资源的类型,赋值/传参默认 move(旧变量失效)- 需要两份数据就
clone(),但要意识到它会深拷贝、会贵 - 借用总结:同一时刻要么 1 个
&mut,要么 N 个&;引用必须有效 - 返回局部变量引用会导致悬垂引用风险,Rust 会拒绝编译;通常改为返回拥有所有权的值
(完)
《Rust 所有权与借用:从堆栈开始建立心智模型》 是转载文章,点击查看原文。