所有权:内存安全的地基
很多初学者第一次接触 Rust 都会被所有权规则劝退:一个值在同一时刻只能有一个所有者,所有者离开作用域值就被释放,想借给别人看还得先问编译器同不同意。这些约束看起来处处掣肘,但它换来的东西非常硬核——不用垃圾回收、也不用手动管理内存,程序却天然没有悬垂指针、没有双重释放、没有数据竞争。
理解所有权,最好的切入点是把每个值都想象成一块带标签的内存。标签上写着一个名字:当前这块内存归谁管。Rust 的三条基本规则是:每个值只有一个所有者;所有者可以转让(移动)或出借(借用);所有者离开作用域,值立即被销毁、内存被回收。规则越简单,编译器能检查的东西就越确定。
这跟 C++ 的智能指针思路很像,但 Rust 把它从「运行时靠引用计数兜底」升级成了「编译期直接判定」。所以 Rust 程序里没有析构顺序猜谜,也不会有两个 shared_ptr 同时想释放同一块堆内存的尴尬。
所有权的本质是:让「谁负责释放内存」这个问题,在编译期就有一个唯一且确定的答案。
- 所有权三规则唯一所有者、转让与出借、离开作用域即释放。
- 移动语义赋值与传参会转移所有权,原变量失效。
- Copy / Clone哪些类型赋值是复制,哪些是移动。
- 借用规则不可变借用可多个,可变借用只能一个。
- Rc / RefCell不放弃安全前提下的共享可变状态。
移动语义与 Copy / Clone
先看一个让新手最容易困惑的现象:下面这段代码在 Rust 里编译不过,因为 s1 的所有权在赋值时被「移动」给了 s2。
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动给 s2
println!("{}", s1); // 编译错误:s1 已被移动,不可再使用
为什么 String 赋值不是复制而是移动?因为 String 内部是一个指向堆内存的指针加长度、容量信息。如果复制,两个变量会指向同一块堆内存,离开作用域时各释放一次,就是双重释放。所以 Rust 干脆规定:像 String、Vec 这样管理堆内存的类型,赋值即移动,旧变量立即失效,杜绝了双释放。
而像整数、布尔、字符、浮点数这样的简单类型,它们在栈上自包含、不引用别的内存,复制一份毫无风险,于是实现了 Copy trait。实现了 Copy 的类型,赋值不会移动,新旧变量都有效。
let a = 42;
let b = a; // i32 实现了 Copy,b 是 a 的副本
println!("{} {}", a, b); // 都能用
#[derive(Clone)]
struct Config { name: String, port: u16 }
自己定义的结构体默认既不是 Copy 也不是 Clone。想要复制语义,得显式 #[derive(Clone)],需要深拷贝时调用 .clone()。这套设计把「什么时候复制是安全的」交给了类型系统,而不是程序员的记性。
不可变借用与可变借用
移动是把东西整个交出去,很多时候我们只想「借来看一眼」。Rust 用 &T 表示不可变借用,用 &mut T 表示可变借用。借用不转移所有权,借用结束后原变量还能正常使用。
fn len(s: &String) -> usize { s.len() } // 不可变借用
let mut msg = String::from("rust");
let r1 = &msg; // 不可变借用,可同时存在多个
let r2 = &msg; // 没问题
println!("{} {}", r1, r2);
let r3 = &mut msg; // 可变借用:同一时刻只能有一个
r3.push('!');
借用规则是所有权规则的延伸,只有两条,但威力巨大:任意时刻要么有任意多个不可变借用,要么只有一个可变借用,不能两者并存。这直接消灭了一整类数据竞争——两个线程同时读没问题,一个写一个读就不行,编译器在编译期就拦住。
可变借用的存在也意味着:只要你在借用一个值,就不能再把它移动走。借出去的东西还没还,主人怎么能把房子卖了?这种直觉在 Rust 里被精确地表达成编译错误,而且报错信息通常会告诉你问题出在哪一行、建议怎么改。
借用检查器与悬垂引用
借用检查器(borrow checker)是 Rust 编译器的核心组件,它做的其实是一件很小的事:沿着代码路径检查每个引用的生命周期是否被包含在其指向值的生命周期之内。如果引用的存活时间超过值本身,就是悬垂引用,直接报错。
fn dangling() -> &String {
let s = String::from("hi");
&s // 编译错误:返回的引用指向已释放的局部变量
}
这段代码在 C 或 C++ 里能编译、能运行,但行为未定义——函数返回后 s 的内存已经归还,外面的调用者拿到的是一根悬空指针,随时可能读到被复用的内存。Rust 的借用检查器在编译期就把这种错误钉死:你不可能从函数里返回一个指向局部变量的引用。
借用检查器还能发现生命周期过长的借用,比如把借来的引用存进一个活得更久的容器。报错时编译器会画出引用的「出生地」和「使用地」,并提示引入显式生命周期标注。虽然第一次看到 'a、'b 这种标注会头大,但要记住:标注只是把借用检查器已经知道的关系写出来给人看,不是额外的负担。
理解借用检查器的最好心态是:它不是来找茬的,它是替你背锅的。你负责描述意图,它负责保证这些意图在内存上永远成立。
Rc 与 RefCell:共享可变状态
所有权规则严格,但真实程序总有「多个地方要引用同一个对象」的需求。Rust 的标准答案不是放开规则,而是提供受控的例外:Rc<T> 做共享所有权,RefCell<T> 做运行时借用检查。
use std::rc::Rc;
use std::cell::RefCell;
let shared = Rc::new(RefCell::new(5));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);
*shared.borrow_mut() += 1; // 可变借用:运行时检查
println!("{} {}", *a.borrow(), *b.borrow()); // 6 6
Rc 让多个变量共享同一块内存,内部维护引用计数,最后一个引用离开作用域时释放内存——这和 C++ 的 shared_ptr 很像,区别是它只在单线程里安全。跨线程共享要用 Arc(原子引用计数)。
RefCell 则把「同一时刻只能有一个可变借用」的检查从编译期推迟到运行时。代价是:如果违反了规则,程序会在运行到那行时直接 panic,而不是给你一个编译错误。所以 RefCell 适合用在借用关系动态、无法静态证明的场景,比如实现图结构、缓存或者测试替身,但用的时候要格外小心,别让运行时借用冲突成为线上事故。
实战:写一个可迭代的双向链表
把前面几节串起来,写一个小的双向链表节点。用 Rc 处理共享的 next 指针,用 RefCell 让节点可以原地修改。
use std::cell::RefCell;
use std::rc::Rc;
type Link = Option<Rc<RefCell<Node>>>;
struct Node {
val: i32,
next: Link,
}
impl Node {
fn new(v: i32) -> Self {
Node { val: v, next: None }
}
}
fn main() {
let head = Rc::new(RefCell::new(Node::new(1)));
let second = Rc::new(RefCell::new(Node::new(2)));
head.borrow_mut().next = Some(Rc::clone(&second));
let mut cur = Some(Rc::clone(&head));
while let Some(node) = cur {
let n = node.borrow();
print!("{} ", n.val);
cur = n.next.clone();
}
// 输出:1 2
}
这里每一步都在践行所有权思想:Rc::clone 明确地说「我多借一份引用计数」,borrow_mut 明确地说「我要短暂地改一下」。没有任何一步是隐式的,编译器也没法骗过运行时检查。写惯了裸指针链表的人,第一次写这种代码会觉得啰嗦,但跑起来之后会发现它真的不会崩。
所有权思维:从 C++ 到 Rust
如果你从 C++ 过来,会发现在 Rust 里很多「经验」变成了「规则」。C++ 里你要靠纪律避免悬垂引用、靠 review 防止双重释放、靠锁来挡住数据竞争;Rust 把这三件事都搬进了编译器。代价是学习曲线陡峭,收益是交付时心里有底。
日常写 Rust 的几个实用心法:优先用借用而不是克隆,性能更好;确实需要共享时先想清楚单线程还是多线程,再选 Rc 还是 Arc;可变状态要共享,先试试 RefCell,不够再上锁;写泛型代码时,让 trait 约束说清楚你的类型需要什么能力。
所有权不是限制你写代码,而是把「内存安全」从运行时的事故,变成编译期的事前检查。