Rust 把”内存什么时候释放”这个问题,从运行期的程序员负担和 GC 负担,搬到了编译期的类型系统里。这套机制叫所有权(ownership)。
我自己是 C++ 背景,对 C++11 引入的右值引用和移动语义只是模糊地用过几次。这篇笔记就以”C++ 程序员视角”为主线,把 Rust 的所有权、借用、智能指针,和 C++ 里大致对应的概念逐一对照。
1. 前言:为什么需要所有权?
主流的内存管理大致三种范式:
- 手动管理:C、C++ 早期风格,
malloc/free、new/delete。性能最好,但容易写出泄漏、悬垂指针、double free。 - GC:Java、Go、Python 这一派。程序员不用管释放,运行时帮你扫。代价是停顿、内存占用、以及”对象什么时候真的没了”不可预测。
- 所有权:Rust 的路线。编译器在编译期通过静态规则保证内存安全,运行期没有 GC。
Rust 的设计目标可以一句话概括:内存安全 + 零成本抽象 + 不要 GC。这三个目标互相拉扯,最终落点就是所有权系统。
直觉上,所有权 ≈ 把 C++ 里靠 RAII + 编码规范实现的东西,做成语言强制规则。RAII 思想 Rust 几乎照搬,但 Rust 多走了一步:用编译器替你检查那些 RAII 没法强制的事。
2. 所有权的三条规则
Rust 的所有权可以浓缩成三句话:
- 每个值有且仅有一个所有者(owner)。
- 同一时刻只能有一个所有者。
- 所有者离开作用域时,值被自动 drop。
fn main() { { let s = String::from("hello"); // s 是 "hello" 的所有者 println!("{}", s); } // 作用域结束,s 被 drop,堆上的 "hello" 被释放 // 此处再用 s 编译错误:s 已不在作用域}C++ 里写类似代码是这样:
{ std::string s = "hello"; // 构造 std::cout << s << "\n";} // 作用域结束,~string() 释放堆内存两者看起来一样:都靠”离开作用域 → 析构”来释放资源,这就是 RAII。区别在于:
- C++ 不强制”唯一所有者”。一个对象可以被多个变量持有副本,也可以被裸指针四处指引用,规则全靠程序员自觉。
- Rust 把”唯一所有者”做成了编译期硬规则,违反就编译不过。
3. Move 语义:Rust vs C++
这是整套所有权机制最核心、也最容易被 C++ 程序员误读的部分。
3.1 Rust 的 move(默认行为)
fn main() { let s1 = String::from("hello"); // s1 拥有堆上的 "hello" let s2 = s1; // 所有权 move 到 s2
// println!("{}", s1); // ❌ 编译错误:value borrowed here after move println!("{}", s2); // ✅ 现在 s2 是所有者}赋值 let s2 = s1 在 Rust 里默认就是 move(除非 s1 的类型实现了 Copy)。move 之后,编译器把 s1 标记为”已移动”,再使用它直接编译错误。
底层上发生了什么?String 实际是 (ptr, len, cap) 三元组放在栈上,数据在堆上。move 只是把这三个字段按位拷给 s2,堆上的 "hello" 一个字节都没动。原本 s1 持有的指针现在归 s2,于是 s1 必须失效——否则两个变量持有同一个堆指针,离开作用域时会被 free 两次(double free)。
3.2 C++ 的 move(需要显式 std::move)
#include <iostream>#include <string>#include <utility>
int main() { std::string s1 = "hello"; std::string s2 = std::move(s1); // 显式 move
std::cout << s2 << "\n"; // ✅ s2 拥有 "hello" std::cout << "s1.size() = " << s1.size() << "\n"; // ⚠️ 合法,但值未定义 return 0;}C++ 的规则截然不同:
- 默认赋值是拷贝(copy ctor / copy assignment)。需要
std::move(...)把左值显式转成右值,才会触发移动构造/移动赋值。 - move 之后,原对象处于标准说的 “valid but unspecified” 状态。意思是:还能调析构、还能再赋值给它,但读它的值是未定义结果。
- 编译器不会报错你访问 moved-from 对象,这类 bug 全靠程序员(或 clang-tidy 这类静态分析)自觉避免。
3.3 两者并排对比
下面这张图把”move 前后”的内存与变量状态对比画出来:
| Rust | C++ | |
|---|---|---|
| 默认赋值语义 | move | copy |
| 需要显式 move 吗? | 不需要 | 需要 std::move |
| moved-from 后能不能用? | 用即编译错误 | 合法,但状态未定义 |
| 谁来保证 use-after-move 不出 bug? | 编译器 | 程序员 |
| 是否需要写 move ctor? | 不需要 | 类型设计者要写 |
一句话总结这一节:Rust 把 C++ 里的”移动语义”做成了默认行为,并且用编译期检查替你管住了 use-after-move。
4. Copy vs Move:什么时候不是 move?
并不是所有 Rust 类型在赋值时都会 move。如果类型实现了 Copy trait,赋值就变成按位拷贝,原变量仍然可用:
fn main() { let x: i32 = 5; let y = x; // i32 实现了 Copy,这里是按位拷贝 println!("{} {}", x, y); // ✅ x 和 y 都能用}哪些类型自动是 Copy?规则简单:
- 所有简单标量:整数、浮点、
bool、char。 - 全部由
Copy类型组成的元组、数组、结构体(且类型本身没有手写Drop)。 - 不可变引用
&T也是Copy(注意:&mut T不是)。
这里的 Drop 是 Rust 的一个 trait,类似 C++ 的析构函数。当值离开作用域时,Rust 会自动调用 drop——如果类型实现了 Drop,就执行用户自定义的清理逻辑(例如释放堆内存、关闭文件句柄)。
struct MyResource { name: String,}
impl Drop for MyResource { fn drop(&mut self) { println!("释放资源:{}", self.name); // 类似 C++ 析构函数 }}
fn main() { let a = MyResource { name: String::from("A") }; { let b = MyResource { name: String::from("B") }; } // b 在这里离开作用域 → 打印"释放资源:B" println!("main 结束前");} // a 在这里离开作用域 → 打印"释放资源:A"
// 输出顺序:// 释放资源:B// main 结束前// 释放资源:ACopy 和 Drop 不能共存,原因很直接:Copy 意味着任意按位复制,Drop 意味着离开作用域要做特殊清理——两者叠加会导致同一份资源被 drop 多次。下面这段代码编译器直接拒绝:
#[derive(Clone, Copy)] // ❌ 编译错误:Copy 和 Drop 不能同时实现struct Bad { ptr: *mut i32, // 裸指针,假设指向堆上的 i32}
impl Drop for Bad { fn drop(&mut self) { unsafe { drop(Box::from_raw(self.ptr)); } // 释放堆内存 }}// 如果 Bad 能被 Copy,那么:// let x = Bad { ... };// let y = x; // 按位复制,x 和 y 持有同一个 ptr// // 离开作用域时 x 和 y 各 drop 一次 → double free → 崩溃反过来,只要持有堆资源、或者实现了 Drop,就一定不是 Copy。所以 String、Vec<T>、Box<T>、File 这些默认是 move。
对比 C++:默认所有类型都是 copy(编译器自动生成 copy ctor)。要禁用 copy,得显式 T(const T&) = delete;。哲学上正好相反——Rust 默认”动一动权属”,C++ 默认”复制一份”。
5. 函数调用中的所有权转移
函数参数和返回值同样遵循 move 规则。
fn take_ownership(s: String) { println!("我拿到了:{}", s);} // s 在这里离开作用域,被 drop
fn give_back(s: String) -> String { println!("处理中:{}", s); s // 把所有权再 move 出去(return)}
fn main() { let a = String::from("hello"); take_ownership(a); // println!("{}", a); // ❌ a 已经被 move 进 take_ownership
let b = String::from("world"); let b = give_back(b); // 把 b 借进去,又拿回来 println!("{}", b); // ✅ b 还活着,因为又 move 回来了}对于 Copy 类型则是另一回事:
fn add_one(n: i32) -> i32 { n + 1 }
fn main() { let x = 10; let y = add_one(x); // i32 是 Copy,x 没被 move println!("{} {}", x, y); // ✅}C++ 的对应是:参数按值传递 = copy(除非显式 T&& + std::move),返回大对象有 RVO/NRVO 优化,必要时编译器自动选择 move。Rust 没有 RVO/NRVO 的复杂规则,它本来就是 move,简单干净。
不过函数每次都把所有权传进传出,写起来非常啰嗦。这就引出了下一节——借用。
6. 借用(Borrowing):解决”传进去就拿不回来”
借用的核心想法很朴素:我把值借给你用一下,所有权还是我的。Rust 用 & 和 &mut 两种引用来表达。
fn length(s: &String) -> usize { // 借用,不获取所有权 s.len()}
fn append_world(s: &mut String) { s.push_str(" world");}
fn main() { let s = String::from("hello"); let n = length(&s); // 把 s 不可变借用给 length println!("{} 长度 {}", s, n); // ✅ s 没被 move
let mut s = String::from("hello"); append_world(&mut s); // 把 s 可变借用给 append_world println!("{}", s); // ✅ "hello world"}借用规则只有两条,但杀伤力极大:
- 不可变借用
&T可以同时有任意多个(读不冲突)。 - 可变借用
&mut T同一时刻最多只有一个,并且不能与任何&T共存(写排他)。
这两条规则的本质就是”读写锁的编译期版本”,目标是杜绝数据竞争和迭代器失效一类的问题。
下面是一个会编译错误的例子:
fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; let r3 = &mut s; // ❌ cannot borrow `s` as mutable because it is also borrowed as immutable println!("{} {} {}", r1, r2, r3);}借用规则用一张图能更直观:
对比 C++:const T& 大致对应 &T,T& 大致对应 &mut T。但 C++ 完全没有借用检查器——你可以同时拥有 100 个 T&,可以 const_cast 把 const 撬掉,可以让引用比被引用对象活得更长(典型的悬垂引用,UB)。Rust 把这一整类问题用借用检查器(borrow checker)一刀切掉了。
顺带一提:C++ 引用必须在初始化时绑定且不能改绑,但 Rust 引用可以被重新赋值(让变量本身绑到另一个引用),这点和 C++ 不一样。
7. 生命周期标注(简介)
借用必须比被借用者活得短。这是借用检查器的另一条铁律:
fn dangling() -> &String { // ❌ 不能返回栈上局部变量的引用 let s = String::from("oops"); &s} // s 在这里 drop,返回的引用立刻悬空当一个函数同时拿多个引用并返回引用时,编译器需要知道返回的引用究竟和哪个参数绑定。这就需要生命周期标注 'a:
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("short"); result = longest(s1.as_str(), s2.as_str()); println!("{}", result); // ✅ 在 s2 作用域内是合法的 } // println!("{}", result); // ❌ s2 已 drop,result 可能悬空}'a 不是某段时间的实际值,它只是个标签,告诉编译器:“这些引用的有效期必须满足同一个生命周期 'a”。具体生命周期由编译器从调用处推断。
生命周期是 Rust 学习曲线里最陡的一段,本文不展开,留作后续笔记。这里只需要知道:借用检查器除了管”谁能借”,还管”借多久”,多出来的那部分约束用 'a 表达。
8. 智能指针:所有权的不同形态
到目前为止讲的所有权和借用,都是建立在”普通栈变量 + 借用”的基础上的。但很多场景需要更灵活的所有权形态:堆上独占、共享所有权、跨线程共享、运行时借用检查……Rust 把这些用一组智能指针类型表达。对 C++ 程序员来说,这一节大体是熟悉地形,区别在于 Rust 把语义切得更细。
8.1 Box<T> ↔ std::unique_ptr<T>
Box<T> 是最简单的智能指针:把一个值放到堆上,唯一所有权。
fn main() { let b: Box<i32> = Box::new(42); println!("{}", *b); // 解引用得到 42} // b 离开作用域,堆上 i32 被释放典型用途:递归类型。因为 enum/struct 的大小必须在编译期确定,而递归类型的大小是无穷的,所以必须借助堆上指针打破:
enum List { Cons(i32, Box<List>), Nil,}
use List::{Cons, Nil};
fn main() { let list = Cons(1, Box::new( Cons(2, Box::new( Cons(3, Box::new(Nil)))))); // 略 let _ = list;}对比 C++ 的 unique_ptr:
#include <memory>
struct Node { int value; std::unique_ptr<Node> next;};
int main() { auto head = std::make_unique<Node>(Node{ 1, std::make_unique<Node>(Node{ 2, std::make_unique<Node>(Node{3, nullptr}) }) }); return 0;}两者语义几乎一一对应:
- 堆上独占。
- move 是默认行为(C++ 的
unique_ptr禁用了 copy)。 - 析构时自动释放。
差别主要在写法层面:Rust 的 Box 用得更自然,因为 move 本来就是默认行为;C++ 要严守”不要 copy unique_ptr”的纪律。
8.2 Rc<T> / Arc<T> ↔ std::shared_ptr<T>
当一个值需要被多个所有者共享时,用引用计数:
use std::rc::Rc;
fn main() { let a = Rc::new(String::from("shared")); println!("count = {}", Rc::strong_count(&a)); // 1
let b = Rc::clone(&a); // 不是深拷贝,只是计数 +1 println!("count = {}", Rc::strong_count(&a)); // 2
{ let c = Rc::clone(&a); println!("count = {}", Rc::strong_count(&a)); // 3 } // c drop,计数 -1
println!("count = {}", Rc::strong_count(&a)); // 2} // a、b drop,计数 → 0,String 被释放注意 Rc::clone(&a) 不是深拷贝整个 String,只是把引用计数 +1,然后返回一个新的 Rc 句柄。Rust 故意没有重载赋值,写 Rc::clone(&a) 比写 a.clone() 更显眼,目的是让 reader 一眼看出”这里有一次计数操作”。
Rc<T> 只能在单线程内用,因为它的计数是非原子的。跨线程要用 Arc<T>:
use std::sync::Arc;use std::thread;
fn main() { let data = Arc::new(vec![1, 2, 3]); let mut handles = vec![];
for i in 0..3 { let data = Arc::clone(&data); handles.push(thread::spawn(move || { println!("thread {} sees {:?}", i, data); })); }
for h in handles { h.join().unwrap(); }}对比 C++ 的 shared_ptr:
#include <memory>#include <vector>
int main() { auto data = std::make_shared<std::vector<int>>( std::vector<int>{1, 2, 3}); auto copy = data; // 计数 +1 // 跨线程也是同样的 shared_ptr,因为它内部一直是原子计数}关键差异:
- C++ 的
shared_ptr始终使用原子引用计数,不管你是不是在多线程环境。在单线程场景下这就是无谓的性能损耗。 - Rust 把它切成两种类型:
Rc<T>用普通整数计数(快),Arc<T>用原子计数(线程安全)。编译器靠Send/Synctrait 检查你有没有把Rc跨线程传递——传了就编译错误。 - 一句话:Rust 把”是否线程安全”做成了类型层面的区分。
8.3 Weak<T> ↔ std::weak_ptr<T>
Rc/Arc 共享所有权时,如果两个对象互相 Rc 持有,就会形成循环:strong count 永远归不了零,对象永远不释放。这就是经典的循环引用泄漏。
Weak<T> 解决这个问题:它持有一个不计入 strong count 的弱引用。用 Weak 访问对象时要 upgrade(),如果对象已经被释放,得到的就是 None。
下图把”正常共享 / 循环引用 / 用 Weak 解开循环”三种情况画在一起:
代码层面的标准例子是父子节点树。子节点要能访问父节点,但又不能持有父节点的强引用——否则就形成环。
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct Node { value: i32, parent: RefCell<Weak<Node>>, // 👈 弱引用,避免循环 children: RefCell<Vec<Rc<Node>>>, // 👈 强引用,父持有子}
fn main() { let leaf = Rc::new(Node { value: 3, parent: RefCell::new(Weak::new()), // 暂时没父节点 children: RefCell::new(vec![]), });
let branch = Rc::new(Node { value: 5, parent: RefCell::new(Weak::new()), children: RefCell::new(vec![Rc::clone(&leaf)]), // 把 leaf 强持有 });
// leaf 用 Weak 指向 branch *leaf.parent.borrow_mut() = Rc::downgrade(&branch);
// 通过 upgrade() 访问父节点 println!( "leaf 的父节点 value = {}", leaf.parent.borrow().upgrade().unwrap().value );}对比 C++ 的 weak_ptr:动机一致,用法相似,都是用来打破 shared_ptr 之间的循环。
#include <memory>#include <vector>
struct Node { int value; std::weak_ptr<Node> parent; std::vector<std::shared_ptr<Node>> children;};8.4 RefCell<T> / Cell<T>:内部可变性
Rust 的借用检查规则是编译期生效的。但有些场景下,借用规则在静态分析下过不去、但运行时确实不会冲突——这时候需要把检查推迟到运行期。这就是内部可变性(interior mutability)。
RefCell<T> 是最常见的工具:它允许通过 &self(不可变引用)去修改内部的值,但内部的”借用”由运行时维护。
use std::cell::RefCell;
struct Counter { count: RefCell<i32>, // 即使外部只持有 &Counter,内部也能改}
impl Counter { fn increment(&self) { *self.count.borrow_mut() += 1; // 注意是 &self,不是 &mut self }
fn get(&self) -> i32 { *self.count.borrow() }}
fn main() { let c = Counter { count: RefCell::new(0) }; c.increment(); c.increment(); c.increment(); println!("{}", c.get()); // 3
// 运行时借用冲突会 panic,而不是编译错误: let _b1 = c.count.borrow_mut(); // let _b2 = c.count.borrow_mut(); // ⚠️ 运行时 panic: already borrowed}Cell<T> 是更轻量的版本,只支持把整个值”换出/换入”,不返回内部引用,所以不会运行时 panic,但只适合 Copy 类型或要 replace 的场景。
C++ 里没有直接对应物。最接近的概念是类成员上的 mutable 关键字——它允许 const 方法修改某个成员,常见于缓存计算结果。但 mutable 完全没有 Rust RefCell 的运行时借用检查。
典型的”共享 + 可变”组合是 Rc<RefCell<T>>:多个所有者共享一份可变数据。
use std::cell::RefCell;use std::rc::Rc;
fn main() { let shared = Rc::new(RefCell::new(vec![1, 2, 3]));
let a = Rc::clone(&shared); let b = Rc::clone(&shared);
a.borrow_mut().push(4); b.borrow_mut().push(5);
println!("{:?}", shared.borrow()); // [1, 2, 3, 4, 5]}跨线程版本是 Arc<Mutex<T>> 或 Arc<RwLock<T>>,思路一样:Arc 管共享所有权,Mutex/RwLock 管并发访问。
8.5 小结
| 用途 | Rust | C++ |
|---|---|---|
| 堆上独占 | Box<T> | unique_ptr<T> |
| 引用计数(单线程) | Rc<T> | shared_ptr<T>(原子,偏重) |
| 引用计数(多线程) | Arc<T> | shared_ptr<T> |
| 弱引用 | Weak<T> | weak_ptr<T> |
| 内部可变性 | RefCell<T> / Cell<T> | 无直接对应(mutable 成员勉强类似) |
| 共享 + 可变(单线程) | Rc<RefCell<T>> | shared_ptr<T>(语言不强制) |
| 共享 + 可变(多线程) | Arc<Mutex<T>> | shared_ptr<T> + mutex |
实际工程里挑哪个,可以参考下面这张决策树:
9. 实战代码:用所有权改写一个 C++ 树
把前面所有的概念组合起来,做一个”父子节点都能互相访问”的树结构。这是一个最能体现智能指针组合用法的场景。
C++ 版本
#include <iostream>#include <memory>#include <string>#include <vector>
struct TreeNode { std::string name; std::weak_ptr<TreeNode> parent; // 弱引用避免循环 std::vector<std::shared_ptr<TreeNode>> children;};
void add_child(const std::shared_ptr<TreeNode>& parent, const std::shared_ptr<TreeNode>& child) { child->parent = parent; parent->children.push_back(child);}
int main() { auto root = std::make_shared<TreeNode>(); root->name = "root";
auto a = std::make_shared<TreeNode>(); a->name = "A"; add_child(root, a);
auto b = std::make_shared<TreeNode>(); b->name = "B"; add_child(a, b);
// 从 b 反向走到 root if (auto p = b->parent.lock()) { std::cout << "b 的父节点是 " << p->name << "\n"; // A if (auto pp = p->parent.lock()) { std::cout << "b 的祖父是 " << pp->name << "\n"; // root } }}Rust 版本
use std::cell::RefCell;use std::rc::{Rc, Weak};
struct TreeNode { name: String, parent: RefCell<Weak<TreeNode>>, // 弱引用,避免循环 children: RefCell<Vec<Rc<TreeNode>>>, // 父持有子(强引用)}
impl TreeNode { fn new(name: &str) -> Rc<Self> { Rc::new(Self { name: name.to_string(), parent: RefCell::new(Weak::new()), children: RefCell::new(vec![]), }) }}
fn add_child(parent: &Rc<TreeNode>, child: &Rc<TreeNode>) { // 子节点记录父亲(弱引用) *child.parent.borrow_mut() = Rc::downgrade(parent); // 父节点持有子节点(强引用) parent.children.borrow_mut().push(Rc::clone(child));}
fn main() { let root = TreeNode::new("root"); let a = TreeNode::new("A"); let b = TreeNode::new("B");
add_child(&root, &a); add_child(&a, &b);
// 从 b 反向走到 root if let Some(p) = b.parent.borrow().upgrade() { println!("b 的父节点是 {}", p.name); // A if let Some(pp) = p.parent.borrow().upgrade() { println!("b 的祖父是 {}", pp.name); // root } }}并排看下来,两边代码量、结构、思路几乎一样:
- C++ 用
shared_ptr + weak_ptr;Rust 用Rc + Weak。 - 修改
children/parent:C++ 直接改成员(成员本身不是 const);Rust 因为只有&Rc<TreeNode>(外层不可变),必须套一层RefCell才能改内部。 - 安全性:两边都避免了循环引用,但 Rust 还顺便保证了”没有数据竞争”和”没有 use-after-free”——前者是
Send/Sync管的(这个例子是单线程,没体现),后者是 borrow checker +Weak::upgrade返回Option共同保证的。
一个细节:C++ 的
weak_ptr::lock()返回shared_ptr(可能为 nullptr),Rust 的Weak::upgrade()返回Option<Rc<T>>。语义对齐,只是 Rust 用类型系统让”可能为空”显式化了,不能忘记判空。
10. 总结:C++ 程序员视角的 Rust 所有权
把前面的内容串起来,C++ → Rust 的”心智迁移”大致是这样一张映射:
| C++ 里的概念/坑 | Rust 里如何处理 |
|---|---|
| RAII(析构释放资源) | 沿用,更彻底 |
默认 copy,需要 delete 才禁拷 | 默认 move,自动派生 Copy 才会拷 |
std::move 把左值转右值 | 不需要显式 move,赋值就是 move |
| use-after-move 是程序员责任 | 编译期检查,违反就 build 失败 |
T& / const T& 容易悬垂 | borrow checker + 生命周期管住 |
unique_ptr<T> | Box<T> |
shared_ptr<T>(恒为原子) | Rc<T>(单线程)+ Arc<T>(多线程)切开 |
weak_ptr<T> | Weak<T> |
mutable 成员(const 方法里修改) | RefCell<T> / Cell<T>,但有运行时检查 |
多线程共享 = shared_ptr<T> + mutex 自己组合 | Arc<Mutex<T>>,类型层面就拆分清楚 |
最后总结三条心得:
- 先按”所有权流向”思考。每个值有唯一所有者,函数调用、赋值、返回都是所有权的移动或借用,写代码时脑子里先画一遍这条流向,再看是否需要借用。
- 能借用就别 move。借用是几乎零成本的,move 偶尔需要重新构造,能用
&T/&mut T就用。 - 智能指针是”逃生通道”,不是默认选择。
Rc、RefCell都引入了一些运行时成本(计数、运行时借用检查),普通代码里用栈变量 + 借用 +Box就已经能搞定大部分场景;只有真的需要”共享所有权”或”绕过编译期借用检查”时再上Rc<RefCell<T>>。
如果你已经熟悉 C++11 的移动语义和智能指针,那么 Rust 所有权对你不是新东西——更像是把那些 “我本来就应该这么做”的规矩 做成了语言层面的强制约束。代价是写代码的时候编译器经常打你的手,但对应的好处是:编译过了,那一类问题基本就不存在了。