智能指针
Rust 里“智能指针”这块,按 “它解决什么问题 + 它的所有权/借用规则 + 运行时成本” 来系统学。下面把需要掌握的知识点尽量全列出来,并按学习路径组织。
定义
什么算“智能指针”
- 智能指针 vs 普通引用
&T/&mut T:智能指针是“像指针一样用(Deref)+ 还带管理/策略”的类型 - 三个核心 trait:
Deref/DerefMut:让*p、方法调用、解引用自动转换成立Drop:离开作用域时自动清理资源(RAII)CoerceUnsized/DispatchFromDyn(了解即可):Box<T>到Box<dyn Trait>的不定大小转换等
要理解为什么 Box 或 String 是智能指针,必须先看它们背后的两个关键特征。智能指针本质上是一个实现了 Deref 和 Drop 的结构体。
A. Deref 特征:让结构体“像”指针
普通的结构体不能被解引用(即不能用 *)。实现了 Deref 之后,智能指针就可以像普通引用一样工作。
- 解引用强制转换 (Deref Coercion):这是
Rust的魔法。如果一个函数需要&str参数,你传给它&String(String实现了Deref),Rust会自动帮你转换。
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
// 实现 Deref 告诉编译器:当我对 MyBox 使用 * 时,返回内部的那个值
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y); // 这里的 *y 实际上执行的是 *(y.deref())
println!("{}", *y);
}
B. Drop 特征:自动清理的“析构函数”
Drop 特征允许你自定义:当一个变量离开作用域时该发生什么。对于智能指针,这通常意味着释放它所拥有的堆内存。
- 自动调用:你不需要手动调用
drop,Rust 会自动在变量生命周期结束时插入清理代码。 - 防止泄漏:有了
Drop,Rust 确保了即便程序在中间出错退出,堆内存也会被正确回收。
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("正在清理数据: {}!", self.data);
}
}
fn main() {
let c = CustomSmartPointer { data: String::from("我的数据") };
let d = CustomSmartPointer { data: String::from("其他数据") };
println!("智能指针已创建。");
// 函数结束时,d 先被清理,c 后被清理(先进后出)
}
智能指针通过 Deref 让我们能方便地访问数据,通过 Drop 让我们不再担心内存回收。
1. Box<T>:堆分配 & 递归类型
你要会的点:
- 什么时候需要
Box- 把值放到堆上(存储实际的数据
T),而在栈(存储指针地址,64 位系统通常是8字节)上仅保留一个指向堆数据的指针 - 处理 递归类型 (编译期需要知道每个类型占用多少内存空间–如:链表)
- 作为 trait object 的持有者:当你需要一个“实现了某个特征的类型”而不是具体类型时,通常使用
Box<dyn Trait> - 转移大数据所有权,如果你有一个非常大的结构体或数组,将其作为参数传递给函数时,如果不使用指针,
Rust默认会进行栈拷贝,这非常消耗性能。
- 把值放到堆上(存储实际的数据
- 所有权语义 :
Box<T>独占所有权,同一时间只能有一个Box指向该堆数据,移动后原变量不可用 - 解引用与方法调用 :
Box<T>自动Deref到T
示例:递归类型(链表)
enum List {
Cons(i32, List), // 错误:List 包含 List,会导致无限大小
Nil,
}
编译器会报错,因为 Cons 变体包含另一个 List,这会形成无限递归,编译器无法计算 List 结构的大小。解决方案: 使用Box 智能指针,将递归类型“在堆上间接存储”,List本身就有固定大小了。
// 递归 enum:如果没有 Box,编译器无法在编译期知道 List 的大小。
// Box<List> 让递归“在堆上间接存储”,List 本身就有固定大小了。
#[derive(Debug)]
enum List {
Cons(i32, Box<List>),//Cons 占用:一个 i32 的空间 + 一个指针的空间
Nil,
}
impl List {
fn new() -> Self {
List::Nil
}
fn prepend(self, v: i32) -> Self {
List::Cons(v, Box::new(self))
}
fn len(&self) -> usize {
match self {
List::Cons(_, next) => 1 + next.len(),
List::Nil => 0,
}
}
}
fn main() {
let list = List::new().prepend(3).prepend(2).prepend(1);
println!("{list:?}");
println!("len = {}", list.len());
}
示例:返回 trait object(动态分发)
trait Draw {
fn draw(&self);
}
struct Circle {r: f32}
impl Draw for Circle {
fn draw(&self) {
println!("Circle radius = {}", self.r);
}
}
struct Square {side: f32}
impl Draw for Square {
fn draw(&self) {
println!("Square side = {}", self.side);
}
}
// 返回 Box<dyn Draw>:调用者只知道“它能 draw”,不关心具体类型。
// 这会使用动态分发(vtable)。
fn make_shape(kind: &str) -> Box<dyn Draw> {
match kind {
"circle" => Box::new(Circle { r: 2.0 }),
_ => Box::new(Square { side: 3.0 }),
}
}
fn main() {
let s1 = make_shape("circle");
let s2 = make_shape("square");
s1.draw();
s2.draw();
}
2. Rc<T>:单线程引用计数共享所有权
在 Rust 的所有权规则中,通常一个值只能有一个所有者。但在某些复杂的应用场景中(例如图结构、社交网络或共享配置),一个数据可能需要被多个不同的部分共同拥有。这时,Box<T> 的独占所有权就不够用了,我们需要 Rc<T>(Reference Counted引用计数)。
你要会的点:
- 为什么需要 Rc :它允许一个数据拥有多个所有者。它在堆上存储数据,并额外记录一个“引用计数器”,用来统计目前有多少个指针指向这份数据
- 只读共享:
Rc<T>默认只允许你不可变地借用数据。如果你想通过其中一个Rc修改数据,编译器会拒绝(除非配合后面要讲的RefCell)。 - 共享而非拷贝:当你“克隆”一个
Rc<T>时,Rust 并不会在堆上重新分配内存并拷贝数据,而是- 增加引用计数:计数器加 1。
- 拷贝指针地址:在栈上创建一个新的指针指向同一个堆位置。
- 只有当计数器归零时: 堆上的数据才会被真正清理。
- 强引用/弱引用
Rc::clone(&rc)增加强引用计数(不是深拷贝)Weak<T>用于打破环,避免内存泄漏
- 循环引用问题 :
Rc形成环会 泄漏 (计数永远不为 0) - 常用 API:
Rc::new、Rc::clone、Rc::strong_count、Rc::weak_count、Rc::downgrade
示例:共享一段数据
use std::rc::Rc;
fn main() {
// 1. 在堆上创建共享数据,初始计数为 1
let config = Rc::new(String::from("核心配置程序"));
println!("--- 初始状态 ---");
println!("内容: {}", config);
println!("引用计数: {}", Rc::strong_count(&config));
// 2. 共享数据:创建两个新的指针指向同一块堆内存
// Rc::clone 仅增加计数,不会拷贝字符串文本
let user_a = Rc::clone(&config);
let user_b = Rc::clone(&config);
println!("\n--- 共享后 ---");
println!("User A 看到的内容: {}", user_a);
println!("User B 看到的内容: {}", user_b);
println!("当前总引用计数: {}", Rc::strong_count(&config));
// 3. 释放其中一个引用
drop(user_a);
println!("\n--- 释放 User A 后 ---");
println!("剩余引用计数: {}", Rc::strong_count(&config));
}
- 内存物理表现:
config、user_a和user_b是三个存储在栈上的指针,它们内部存储的内存地* 址完全相同,都指向堆上的同一个String。 - 内容访问:由于实现了
Deref特征,你可以像使用普通String一样直接打印user_a或user_b。 - 计数变化:每次调用
Rc::clone,计数器加 1;每次指针离开作用域(或被drop),计数器减 1。
强引用 (Strong Reference) 与 弱引用 (Weak Reference)
这是管理 Rc 生命周期的两种手段:
-
强引用 (
Rc<T>):- 行为:使用
Rc::clone(&rc)会增加强引用计数。 - 本质:它不是深拷贝(Deep Copy),只是在栈上多了一个指向堆内存的指针,并在堆上把计数器加 1。
- 作用:只要强引用计数大于 0,堆上的数据就绝对不会被销毁。
- 行为:使用
-
弱引用 (
Weak<T>):- 行为:通过
Rc::downgrade(&rc)创建。 - 本质:它会增加弱引用计数,但不影响强引用计数。
- 作用:它不拥有数据的所有权。即便还有 100 个
Weak指针指向数据,只要强引用计数归零,数据依然会被清理。 - 使用:因为数据可能已被销毁,使用前必须通过
weak_ptr.upgrade()将其“升级”回Option<Rc<T>>进行检查。
- 行为:通过
use std::rc::Rc;
fn main() {
// 1. 创建一个强引用 Rc
let strong_ptr = Rc::new(String::from("Rust 智能指针"));
// 2. 从强引用创建一个弱引用
// downgrade 不会增加 strong_count
let weak_ptr = Rc::downgrade(&strong_ptr);
let weak_ptr2 = Rc::downgrade(&strong_ptr);
println!("--- 初始状态 ---");
println!("强引用计数: {}", Rc::strong_count(&strong_ptr)); // 1
println!("弱引用计数: {}", Rc::weak_count(&strong_ptr)); // 2
// 3. 尝试使用弱引用访问数据
// 必须通过 upgrade() 升级为 Option<Rc<T>>
match weak_ptr.upgrade() {
Some(rc) => println!("弱引用升级成功,得到数据: {}", rc),
None => println!("弱引用升级失败,数据已销毁"),
}
// 销毁一个弱引用
drop(weak_ptr2);
println!("销毁一个弱引用后强引用计数: {}", Rc::strong_count(&strong_ptr)); // 1
println!("销毁一个弱引用后弱引用计数: {}", Rc::weak_count(&strong_ptr)); // 1
println!("\n--- 销毁强引用后 ---");
// 4. 手动销毁强引用(模拟离开作用域)
drop(strong_ptr);
// 5. 再次尝试使用弱引用访问数据
// 此时强引用计数为 0,数据已被回收
match weak_ptr.upgrade() {
Some(rc) => println!("弱引用升级成功: {}", rc),
None => println!("弱引用升级失败,数据已销毁"),
}
}
Rc 环
循环引用问题:这是Rc 最危险的陷阱。
- 成因:如果两个
Rc指针互相指向对方(例如:节点 A 拥有指向节点 B 的 Rc,而节点 B 也拥有指向节点 A 的 Rc),就会形成一个环。 - 后果:由于环的存在,这两个对象的强引用计数永远至少为 1。当外部作用域结束时,它们无法被清理,导致内存泄漏(
Memory Leak)。 - 解决方案:将其中一条路径改为
Weak<T>。例如:父节点用Rc指向子节点(强引用),而子节点用Weak指向父节点(弱引用)。这样环就被打破了。
由于 Rc<T> 默认是只读的,为了在创建节点后能修改指针指向对方,我们需要配合使用 RefCell<T>演示环形引用。
use std::rc::Rc;
use std::cell::RefCell;
// 定义一个简单的 Node,它可以指向另一个 Node
#[derive(Debug)]
struct Node {
next: RefCell<Option<Rc<Node>>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("Node 被销毁了!");
}
}
fn main() {
// 1. 创建两个节点 A 和 B
let a = Rc::new(Node { next: RefCell::new(None) });
let b = Rc::new(Node { next: RefCell::new(None) });
println!("--- 建立环之前 ---");
println!("A 的强引用计数: {}", Rc::strong_count(&a)); // 1
println!("B 的强引用计数: {}", Rc::strong_count(&b)); // 1
// 2. 建立环:A 指向 B,B 指向 A
*a.next.borrow_mut() = Some(Rc::clone(&b));
*b.next.borrow_mut() = Some(Rc::clone(&a));
println!("--- 建立环之后 ---");
println!("A 的强引用计数: {}", Rc::strong_count(&a)); // 2
println!("B 的强引用计数: {}", Rc::strong_count(&b)); // 2
// 3. 函数结束前,我们尝试让 a 和 b 离开作用域
println!("--- main 函数即将结束 ---");
}
// 正常情况下,这里应该打印两次 "Node 被销毁了!",但实际上什么都不会打印。
笔记:为什么会泄漏?
- 计数器逻辑:当 main 函数结束时,变量 a 和 b 被丢弃。
- a 被丢弃,其对应的堆内存计数从 2 降到 1。
- b 被丢弃,其对应的堆内存计数从 2 降到 1。
- 死循环:堆内存 A 还在等待堆内存 B 释放以便将其计数减为 0;而堆内存 B 也在等待堆内存 A 释放。
- 结果:两个内存块的计数器永远卡在 1,drop 方法永远不会被触发,这块堆内存就泄漏了。
解决方案
use std::cell::RefCell;
use std::rc::{Rc, Weak};
// 一个“父-子”节点结构:
// - 子节点用 Rc 拥有(多个地方可以共享孩子)
// - 父指针用 Weak 指向父(避免形成 Rc 环)
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>, // Weak<Node>:这是指向父节点的指针, Weak 不增加强引用计数
children: RefCell<Vec<Rc<Node>>>, // children 拥有子节点
}
impl Drop for Node {
fn drop(&mut self) {
println!("Node 被销毁了!");
}
}
fn main() {
let parent = Rc::new(Node {
value: 1,
parent: RefCell::new(Weak::new()),// 初始化时,父节点没有“父”,设为空弱引用
children: RefCell::new(vec![]),// 初始化时,子节点列表为空向量
});//将 Node 实例放入堆中,并返回一个智能指针,此时 parent 的强引用计数为 1,弱计数 = 0
let child = Rc::new(Node {
value: 2,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});//child 的强计数 = 1,弱计数 = 0
// 建立父 -> 子 的强引用关系
parent.children.borrow_mut().push(Rc::clone(&child));
// 通过 borrow_mut() 获取 Vec 的可变借用,然后把 child 的一个克隆存进去。
// 此时 child 的强引用计数变为 2(一个是变量 child,一个是 parent.children 里的克隆)
// 建立子 -> 父 的弱引用关系(关键:避免环)
*child.parent.borrow_mut() = Rc::downgrade(&parent);
//通过 Rc::downgrade 将 parent 的强引用转为弱引用并存入 child.parent
//parent 的强计数保持为 1。parent 的弱计数变为 1。
println!("parent strong = {}, weak = {}",
Rc::strong_count(&parent),
Rc::weak_count(&parent)
);
println!("child strong = {}, weak = {}",
Rc::strong_count(&child),
Rc::weak_count(&child)
);
// Weak::upgrade:尝试把 Weak 变回 Rc(如果父节点已释放则返回 None)
if let Some(p) = child.parent.borrow().upgrade() {
println!("child's parent value = {}", p.value);
} else {
println!("parent already dropped");
}
}
核心设计:为什么这样设计结构?
在 Rust 中,指针是表示内存地址的类型。为了实现父子双向链接,代码采用了“强弱结合”的策略:
children: RefCell<Vec<Rc<Node>>>(向下强引用):- 父节点需要“拥有”它的子节点,所以使用
Rc<Node>。只要父节点存在,子节点就不会被销毁。
- 父节点需要“拥有”它的子节点,所以使用
parent: RefCell<Weak<Node>>(向上弱引用):- 这是破环关键。子节点只需要“知道”父节点是谁,但不应该“拥有”父节点。
- 使用
Weak<Node>不会增加父节点的强引用计数,因此不会阻止父节点被回收。
RefCell(内部可变性):- 因为节点创建时是孤立的,必须在创建后修改属性(建立连接),所以需要 RefCell 绕过编译期的不可变检查。
| 执行阶段 | Parent 计数 (Strong/Weak) | Child 计数 (Strong/Weak) | 逻辑说明 |
|---|---|---|---|
| 创建后 | 1 / 0 | 1 / 0 | 变量 parent 和 child 各自拥有 1 个所有权 |
| 建立父->子连接 | 1 / 0 | 2 / 0 | parent.children 存了一个 Rc::clone(&child),强计数加 1 |
| 建立子->父连接 | 1 / 1 | 2 / 0 | child.parent 存了一个 downgrade(&parent),仅增加弱计数 |
常用 API 速查表
| API | 功能描述 |
|---|---|
Rc::new(val) | 创建一个新的 Rc 实例,初始强计数为 1。 |
Rc::clone(&rc) | 增加强引用计数,返回新指针。 |
Rc::strong_count(&rc) | 查看当前的强引用数量。 |
Rc::weak_count(&rc) | 查看当前的弱引用数量。 |
Rc::downgrade(&rc) | 获取一个 Weak<T> 指针。 |
weak.upgrade() | 尝试将弱引用转回强引用,返回 Option<Rc<T>>。 |
3. Arc<T>:多线程引用计数共享所有权
如果在多线程环境下使用 Rc<T>,编译器会直接报错,因为 Rc<T> 内部的引用计数是非原子的,无法在多个线程间安全地更新。为了解决这个问题,Rust 提供了 Arc<T>(Atomic Reference Counted 原子引用计数),它允许在多个线程之间安全地共享同一个堆数据的所有权。和 Rc<T> 一样,Arc<T> 默认也是只读的。如果你需要多个线程同时修改数据,你还需要配合下一阶段我们要讲的锁机制(如 Mutex)。
在计算机中,普通的整数加减(如 count += 1)并不是一个不可分割的操作。如果两个线程同时尝试修改 Rc 的计数器,可能会导致计数错误,进而引发提前释放内存或内存泄漏。Arc<T> 使用了 原子操作来更新计数。这是一种硬件层面的特殊指令,能确保在多线程竞争时计数器依然准确。
你要会的点:
Arc= Atomic Rc(线程安全,计数操作是原子的)- 成本:比
Rc更贵(原子操作),单线程场景优先Rc以获得最佳性能 - 常见组合:
Arc<Mutex<T>>、Arc<RwLock<T>> - 线程安全标志:
Rc<T>未实现Send和Sync特征,因此不能跨线程传递Arc<T>实现了Send和Sync(前提是内部的T也是线程安全的),可以在线程间自由穿梭
示例:多线程共享只读
use std::sync::Arc;
use std::thread;
fn main() {
// 1. 创建一个 Arc 指针
let data = Arc::new(String::from("多线程共享数据"));
println!("Arc-data 初始计数: {}", Arc::strong_count(&data));
let mut handles = vec![];
for i in 0..5 {
// 2. 克隆 Arc 指针:这只是增加原子计数,不拷贝字符串本身
let data_clone = Arc::clone(&data);
let handle = thread::spawn(move || {
// 3. 在子线程中使用数据
println!("线程 {} 看到的数据: {}", i, data_clone);
});
handles.push(handle);
}
// 等待所有线程结束
for handle in handles {
handle.join().unwrap();
}
println!("所有线程已完成,最后计数: {}", Arc::strong_count(&data));
}
4. 线程安全内部可变性:Mutex<T> / RwLock<T>
在多线程环境下,单靠 Arc<T> 只能解决“谁拥有数据”的问题,但由于 Arc<T> 提供的引用是不可变的,我们无法修改数据。为了在多线程中修改数据,我们需要使用“锁”。
你要会的点:
Mutex<T>:互斥锁,提供独占可变访问(锁守卫MutexGuard)RwLock<T>:读写锁,多读单写- 死锁、锁粒度、持锁时间 (工程上很关键)
- 常见组合:
Arc<Mutex<T>>、Arc<RwLock<T>> - 运行时成本:加锁/解锁、竞争、可能阻塞
示例:Mutex<T> (互斥锁)
Mutex 是 Mutual Exclusion(互斥)的缩写。它保证在任何时刻,只有一个线程可以访问数据。
工作机制:
- 线程尝试通过
.lock()获取锁。 - 如果锁已被占用,线程会阻塞(等待)。
- 获取成功后,返回一个
MutexGuard(智能指针)。 - 自动释放:由于
MutexGuard实现了Drop特征,当它离开作用域时,锁会自动释放,无需手动解锁。
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
// lock() 返回一个 Result,因为如果另一个线程在持有锁时 panic,锁会变得“中毒”
let mut num = m.lock().unwrap();
*num = 6; // 通过解引用修改内部数据
} // num 离开作用域,锁自动释放
println!("m = {:?}", m);
}
示例:RwLock<T> (读写锁)
RwLock 代表 Read-Write Lock。它比 Mutex 更灵活,遵循“多读单写”规则:
- 多读:允许多个线程同时持有只读锁 (
.read())。 - 单写:同一时间只允许一个线程持有写锁 (
.write())。此时不允许任何读锁。
适用场景:适用于“读多写少”的场景,性能通常优于 Mutex。
Mutex<T> 的规则很简单:任何人要用,必须排队。
而 RwLock<T> 引入了“多读”逻辑,这虽然提高了并发效率,但也让逻辑变得复杂:你不能直接把一个读锁“升级”为写锁。你必须先释放读锁,然后再去竞争写锁
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
fn main() {
// 1. 使用 Arc 包装 RwLock,实现多线程共享所有权
let lock = Arc::new(RwLock::new(5));
let mut handles = vec![];
// --- 模拟多个读取者 ---
for i in 0..3 {
let lock_clone = Arc::clone(&lock);
let handle = thread::spawn(move || {
// 获取读锁:允许多个线程同时进入此处
let r = lock_clone.read().unwrap();
println!("读者 {} 读取到的值: {}", i, *r);
// 读锁在这里自动释放
});
handles.push(handle);
}
// --- 模拟一个写入者 ---
{
let lock_clone = Arc::clone(&lock);
let handle = thread::spawn(move || {
println!("写入者正在尝试获取写锁...");
// 获取写锁:此时所有读锁和其他写锁都会被阻塞
let mut w = lock_clone.write().unwrap();
*w += 10;
println!("写入者已将值修改为: {}", *w);
// 写锁在这里自动释放
});
handles.push(handle);
}
// 等待所有线程结束
for handle in handles {
handle.join().unwrap();
}
println!("最终结果: {}", *lock.read().unwrap());
}
关键细节解析
-
.read().unwrap():- 返回一个
ReadGuard。只要这个 guard 存在,其他线程也可以调用.read()获取读锁。 - 限制:一旦有人持有读锁,任何尝试调用
.write()的线程都会进入睡眠等待状态。
- 返回一个
-
.write().unwrap():- 返回一个
WriteGuard。 - 排他性:只有当没有任何人持有读锁且没有任何人持有写锁时,它才能成功获取。
- 返回一个
-
死锁风险:
- 如果在同一个线程中,你已经持有了读锁,又尝试去获取写锁,会导致死锁。
- 在写操作频繁的场景下,可能会出现“读者饥饿”。
总结对比:Mutex vs RwLock
| 特性 | Mutex<T> | RwLock<T> |
|---|---|---|
| 访问规则 | 一次只能一个线程访问, | 多个读者 OR 一个写者 |
| 内部可变性 | 是(通过锁获取可变引用) | 是(通过写锁获取可变引用) |
| 线程安全 | 是 | 是 |
| 性能 | 简单、开销固定 | 读操作多时性能更好,管理成本略高 |
💡 笔记要点:为什么它们也叫“内部可变性”?
因为即使你只有一个指向 Mutex<T> 的不可变引用 &Mutex<T>,你依然可以通过调用 .lock() 方法来修改其内部包裹的数据。这种“外表不可变,内部可变”的特性正是它们被称为内部可变性的原因。
示例:多线程共享可变(Arc<Mutex<T>>)
核心分工:
- Arc(所有权管理者):解决“谁拥有数据”的问题。它让多个线程可以同时持有指向同一个堆内存的指针
- Mutex(访问权限管理者):解决“谁能修改数据”的问题。它确保即使多个线程都有指针,同一时间也只有一个线程能真正碰到里面的数据
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));//最外层是 Arc,给这把锁加上引用计数功能,中间层是 Mutex,给数据加上一把锁
let mut handles = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);//克隆的是指针地址和计数器,锁和数据始终只有一份
handles.push(thread::spawn(move || {
// lock() 返回一个 guard:guard 活着就持有锁
let mut guard = c.lock().unwrap();
*guard += 1;
// 不需要手动写 unlock(), guard drop 时自动解锁(RAII)
}));
}
for h in handles {
h.join().unwrap();
}
println!("counter = {}", *counter.lock().unwrap()); // 4
}
示例:多线程共享可变(读多写少Arc<RwLock<T>>)
核心分工:
- Arc:解决“谁拥有数据”的问题,确保堆内存地址在所有线程执行完毕前保持有效。
- RwLock:解决“如何高效访问”的问题。它允许 100 个线程同时读取(并发),但只要有 1 个线程在写,其他人都必须等待(排他)。
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
fn main() {
// 1. 初始化数据,Arc 负责跨线程共享,RwLock 负责并发权限
let data = Arc::new(RwLock::new(0));
let mut handles = vec![];
let start_time = std::time::Instant::now();
// 2. 开启 5 个读者线程
for i in 0..5 {
let d = Arc::clone(&data);
handles.push(thread::spawn(move || {
// 获取读锁:注意!多个线程可以同时成功获取读锁
let _guard = d.read().unwrap();
println!("读者 {} 正在读取...", i);
// 模拟一个耗时的读取操作(1秒)
thread::sleep(Duration::from_secs(1));
println!("读者 {} 读取完毕", i);
}));
}
// 3. 开启 1 个写者线程
let d_writer = Arc::clone(&data);
handles.push(thread::spawn(move || {
// 模拟等待一段时间再写,确保读者们已经先拿到了锁
thread::sleep(Duration::from_millis(500));
println!("写者:尝试获取写锁(会被阻塞,直到所有读者读完)...");
// 获取写锁:必须等所有读锁释放
let mut w = d_writer.write().unwrap();
*w += 1;
println!("写者:修改完毕!");
}));
for h in handles {
h.join().unwrap();
}
println!("--- 总耗时: {:?} ---", start_time.elapsed());
}
代码逻辑深度拆解
- 读锁并发 (
.read().unwrap())- 当
reader1调用read()时,如果此时没有写线程,它会立即获得ReadGuard。 - 优势:如果有
reader2、reader3同时进来,只要当前没有线程持有“写锁”, 它们都可以获取读锁。这比Mutex快得多,因为Mutex会强制让读者也排队。
- 当
- 写锁独占 (
.write().unwrap()):- 当
writer调用write()时,写锁具有排他性, 它必须等待所有正在读取的线程释放读锁,且没有其他写线程在工作。反之,只要有任何读锁未释放,写锁获取也会被阻塞。
- 当
5. Cell<T>:Copy 类型的轻量内部可变性
在掌握了 Rc 和 Arc 之后,你可能已经发现它们默认是不可变共享的。为了在“不可变”的外壳下修改数据,我们需要内部可变性(Interior Mutability)。
在 Rust 中,如果你使用普通引用,最怕的是:“我手里拿着指向数据的指针,结果别人把数据改了/删了,导致我手里的指针失效”。
但 Cell 完美避开了这个问题,因为它禁止你获取内部数据的指针:
- 没有借用:你调用
c.get()时,它直接给你一个全新的副本。你手里的副本和Cell里面的原始数据已经没关系了。 - 随便覆盖:既然谁也拿不到指向
Cell内部的指针,那么无论你调用多少次set,都只是在修改那一块内存的值,不会破坏任何人的指针(因为根本没人持有指针)。
你要会的点:
Cell<T>主要用于实现了Copy特征的类型(如i32, bool, f64等简单类型)Cell的操作逻辑不是“借用”,而是“值拷贝”。它不会给你内部数据的引用,而是让你把值取出来或存进去。- 与其它智能指针不同,
Cell不提供.borrow()或.borrow_mut()方法,只有两个核心动作:.get():返回内部值的一个全新的副本(Copy).set(value):将新值拷贝进去,覆盖旧值
- 应用场景:
- 简单标志位:在结构体内部存储一些状态标记(如
is_valid: Cell<bool>),即使结构体是以不可变引用的形式传递,也能随时更新这些标记。 - 性能敏感场景:如果你只需要操作简单的数字或布尔值,且不需要获取它们的引用,选
Cell而不是RefCell。
- 简单标志位:在结构体内部存储一些状态标记(如
use std::cell::Cell;
fn main() {
// 即使变量 c 本身没有声明为 mut
let c = Cell::new(10);
// 我们可以通过不可变引用修改它的值
let c_ref1 = &c;
let c_ref2 = &c;
c_ref1.set(20);
c_ref2.set(30);
println!("当前值: {}", c.get()); // 输出 30
}
6. RefCell<T>:运行时借用检查(内部可变性)
如果说 Cell<T> 是通过“不给你指针”来保证安全,那么 RefCell<T>(Reference Cell) 就是通过雇佣一个运行时保安来让你安全地持有指针。
你要会的点:
- 用于那些不方便拷贝(非
Copy类型,比如Vec或自定义结构体),且你需要获取其引用的场景 - 内部可变性(Interior Mutability) :即便外面是不可变绑定,也能在内部修改
- 借用规则从编译时延后到运行时:
borrow()获取一个不可变引用, 得到Ref<T>borrow_mut()获取一个可变引用, 得到RefMut<T>- 在程序运行时,RefCell 内部会维护一个计数器来记录当前的借用状态,运行时违反了规则则会 panic
- 与
Rc组合:Rc<RefCell<T>>是单线程常见共享可变方案
示例:共享 + 可变(单线程)
use std::cell::RefCell;
fn main() {
// 数据包裹在 RefCell 中
let data = RefCell::new(vec![1, 2, 3]);
// 即使 data 是不可变的,我们也能获取可变借用
{
let mut mut_ref = data.borrow_mut();
mut_ref.push(4);
} // mut_ref 在这里离开作用域,借用标记被释放
// 获取两个不可变借用
let ref1 = data.borrow();
let ref2 = data.borrow();
println!("数据内容: {:?}", ref1);
}
为什么会 Panic?(违反规则的后果)
如果你尝试在同一个作用域内同时进行不可变和可变借用,RefCell 就会报错:
let data = RefCell::new(5);
let r1 = data.borrow(); // 运行时:不可变借用计数 +1
let r2 = data.borrow_mut(); // 运行时:发现已有不可变借用,直接 PANIC!
Cell<T> 与 RefCell<T> 的深度对比
这是笔记中最重要的部分:
| 特性 | Cell<T> | RefCell<T> |
|---|---|---|
| 适用类型 | 实现了 Copy 的简单类型 | 任何类型(通常是较大的对象) |
| 获取方式 | 返回值的副本(.get()) | 返回数据的引用(.borrow()) |
| 性能开销 | 零开销(仅内存拷贝) | 有开销(运行时维护借用计数) |
| 安全性 | 编译时安全(无引用) | 运行时可能 Panic |
| 线程安全 | 不安全 (!Sync) | 不安全 (!Sync) |
💡 为什么需要 RefCell?
最常见的场景是:你实现了一个 Trait,而该 Trait 的方法签名要求使用不可变引用 &self,但你的具体实现却需要修改内部状态(比如缓存、日志记录等)。
总结一句话
Cell是“搬家”(拷贝值),RefCell是“登记处”(在运行时盯着谁拿了钥匙,违规就报警)。
Rc<RefCell<T>>:单线程共享 + 可变
这是 Rust 单线程开发中最强大的组合拳。如果你理解了 Rc(解决所有权共享)和 RefCell(解决不可变外壳下的修改),那么把它们套在一起就得到了:一个可以被多个地方同时持有、且每个地方都能修改的“共享变量”。
在 Rust中,指针是表示内存地址的类型。为了实现“多处读写”这种在其他语言中很常见的行为,我们需要这种“套娃”结构。
核心逻辑
- 外层
Rc<T>**:负责共享**。它允许多个变量持有指向同一块堆内存的指针,解决了“谁能拿到这块内存”的问题。 - 内层
RefCell<T>**:负责修改**。它允许你在只有Rc提供的不可变引用的情况下,通过运行时检查来修改内部数据。
当你创建 let x = Rc::new(RefCell::new(5)) 时,内存中发生了以下情况:
- 栈上:有一个
Rc指针 - 堆上:分配了一块空间,包含
Rc的引用计数器RefCell的借用状态标志位- 实际的数据
T。
代码示例:共享计数器
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
// 1. 创建共享的可变数据
let shared_data = Rc::new(RefCell::new(vec![1, 2, 3]));
// 2. 克隆 Rc 指针(增加强引用计数)
let shared_data_clone = Rc::clone(&shared_data);
// 3. 在一处修改
{
let mut mut_ref = shared_data.borrow_mut();
mut_ref.push(4);
}
// 4. 在另一处读取,发现数据已经变了
println!("克隆端看到的数据: {:?}", shared_data_clone.borrow());
// 输出: [1, 2, 3, 4]
}
深度对比:单线程 vs 多线程
这个组合有一个完美的“多线程对应版本”,请务必记在笔记中对比:
| 场景 | 工具组合 | 逻辑 |
|---|---|---|
| 单线程 | Rc<RefCell<T>> | 引用计数 + 运行时借用检查 |
| 多线程 | Arc<Mutex<T>> | 原子计数 + 互斥锁 |
风险警示:运行时崩溃
虽然这个组合很强,但它保留了 RefCell 的风险:
- 如果你在同一个线程中,通过
shared_data.borrow()拿到了一个引用还没放手,又尝试用shared_data_clone.borrow_mut()去修改,程序会直接 Panic。 - 它依然不是线程安全的,不能跨线程传递。
7. Cow<'a, T>:写时克隆(Copy-On-Write)
这是 Rust 中一个非常“聪明”且能显著提升性能的智能指针。Cow 的全称是 Copy-On-Write(写时克隆)。它的核心思想是:不到万不得已,绝不分配内存。将内存分配推迟到真正发生修改的那一刻。
核心定义
Cow 是一个枚举类型,包含两个变体:Borrowed(&'a T) 和 Owned(T::Owned)。它允许你以一种统一的方式处理借用的数据和拥有的数据。
Borrowed(&'a T):持有数据的只读引用Owned(T::Owned):持有数据的所有权(通常在堆上)
懒惰是美德
- 初始状态: 当你创建一个
Cow时,它通常从Borrowed开始。这不需要分配新内存,开销极小。 - 读取数据:当你只需要读取时,它保持 Borrowed 状态,性能损耗为
O(1)。 - 修改数据:当你调用
.to_mut()尝试修改数据时,Cow会检查:如果已经是Owned,直接返回引用。如果是Borrowed,此时才会执行克隆(Clone),将数据变为Owned,然后再让你修改。
你要会的点:
- 表示“要么借用,要么拥有”
- 读操作零拷贝,写操作才会
to_mut()触发克隆 - 常见场景:字符串处理、API 既接受
&str又能返回String的优化
use std::borrow::Cow;
// 如果不需要修改,就借用输入(零拷贝);
// 如果需要修改,再转成拥有的 String。
fn normalize(s: &str) -> Cow<'_, str> {
if s.contains(' ') {
// 需要修改:分配新 String(Owned)
Cow::Owned(s.replace(' ', "_"))
} else {
// 不需要修改:直接借用(Borrowed)
Cow::Borrowed(s)
}
}
fn main() {
let a = normalize("hello");
let b = normalize("hello world");
println!("a = {}", a);
println!("b = {}", b);
}
8. Pin<P>:禁止被移动(自引用/异步)
这是 Rust 中最“玄学”但也最底层、最重要的智能指针之一。如果说 Box 是为了放进堆里,那么 Pin 就是为了锁死内存地址。
Define
A. 核心危机:自引用结构 (Self-referential Structs)
在 Rust 中,几乎所有类型都是可以移动的(Move)。这意味着如果你把一个变量传给另一个函数,它的内存地址可能会发生改变。但在某些特殊场景下,这种“移动”会导致程序崩溃。
想象一个结构体,它的一个字段是指向另一个字段的指针:
struct SelfRef {
data: String,
ptr: *const String, // 这个指针指向同一个结构体里的 data
}
- 初始状态:
ptr记录了data在内存中的地址(比如0x100)。 - 发生移动:如果你把这个结构体移动到堆上或者传给另一个线程,整个结构体的内存地址变了(变成了
0x200)。 - 结果:
data的新地址是0x200,但ptr仍然指着旧地址0x100。指针失效了! 这会导致未定义行为。
B. Pin<P> 的作用
Pin 的作用就是:给指针加一个约束,保证它指向的数据在内存中永远不会被移动。
- 一旦数据被
Pin住,你无法再通过mem::swap或移动所有权等方式改变它的物理位置。 - 它主要配合
P(通常是Box或引用)使用,形成Pin<Box<T>>或Pin<&mut T>。
C. 为什么异步(Async/Await)必须用它?
这是 Pin 最主要的应用场景。
- 当你写
async块时,编译器会将其转换为一个状态机(实现了Futuretrait)。 - 这个状态机内部经常包含自引用(例如:一个局部变量被引用后跨越了
.await点)。 - 因此,
Future的poll方法签名必须是fn poll(self: Pin<&mut Self>, ...)。没有Pin,异步代码就无法保证内存安全。
D. Unpin 特征:谁可以豁免?
并不是所有东西都需要被“锁死”。Rust 定义了一个标记特征 Unpin:
Unpin类型:绝大多数普通类型(如i32,String,Box)。它们即便被移动也是安全的。对于这些类型,Pin<P>没有任何实际限制,可以随意拿回可变引用。!Unpin类型:不能被移动的类型(如异步生成的Future、自引用结构)。它们必须被Pin保护,否则编译器会阻止某些危险操作。
如何通俗理解 Pin?
- 普通指针:像一张写着地址的便签。你可以把便签传来传去,房子(数据)也可能搬家。
Pin指针:像一颗钉子。它不仅告诉你地址,还将房子死死地钉在原地,不准搬家,直到房子被拆除(Drop)为止。
选型指南
- 如果你的数据不包含指向自身的指针,**不需要
Pin**。 - 如果你在手写复杂的
Future或者构建自引用底层库,**必须用Pin**。
这个示例只是让你看到 Pin<Box<T>>的写法;真正需要 Pin 的场景主要在 async/Future 或自引用结构里。
use std::pin::Pin;
fn main() {
let x = Box::new(123);
// Pin<Box<T>>:把堆上的 T “固定住”,承诺之后不会再移动它的内存地址
let pinned: Pin<Box<i32>> = Box::pin(*x);
// 访问内部值:as_ref 得到 Pin<&T>,get_ref 拿到 &T
let r: &i32 = pinned.as_ref().get_ref();
println!("pinned value = {}", r);
}
9. Box<dyn Trait> / Rc<dyn Trait>:trait object 与动态分发
这是 Rust 智能指针系列的最后一项核心应用。如果你需要处理不同类型但实现了相同特征的对象(例如一个数组里既有“圆形”又有“正方形”),你就必须用到 Box<dyn Trait> 或 Rc<dyn Trait>。
在 Rust 中,指针是表示内存地址的类型。由于不同类型的大小不同,我们无法直接在栈上存储“某个特征”,必须通过智能指针将其包装成 Trait Object。
你要会的点:
- 静态分发(泛型) vs 动态分发(trait object)
- 对象安全(object safety)限制:哪些 trait 能变成
dyn Trait - fat pointer(数据指针 + vtable),大小/性能直觉
前置知识
A. 为什么必须用 Box 或 Rc?
特征(Trait)本身是 DST (Dynamically Sized Type),即“动态大小类型”。
- 编译器在编译时不知道具体是哪个结构体实现了该特征,因此不知道它占用多少内存。
- 解决方案:将对象放入堆中。
Box<dyn Trait>的大小是固定的(指针大小),无论堆上的对象实际有多大。
B. 物理结构:胖指针 (Fat Pointer)
当你使用 Box<dyn Trait> 时,这个指针在栈上占用 2 个单元(通常是 16 字节):
- 数据指针:指向堆内存中具体的对象数据。
- vtable 指针:指向一个“虚函数表”。表中记录了该特定类型实现该 Trait 的方法地址。
C. 静态分发 vs 动态分发
| 特性 | 静态分发 (Generics <T: Trait>) | 动态分发 (dyn Trait) |
|---|---|---|
| 原理 | 编译时为每种类型生成一份代码(单态化)。 | 运行时通过 vtable 查找函数地址。 |
| 性能 | 极快。编译器可以进行内联优化。 | 略慢。存在指针跳转开销,无法内联。 |
| 灵活性 | 集合中只能存同一种类型。 | 极高。集合中可以存多种不同类型。 |
D. 代码示例:不同类型的“绘图”
trait Draw {
fn draw(&self);
}
struct Button { width: u32 }
impl Draw for Button {
fn draw(&self) { println!("渲染按钮,宽度: {}", self.width); }
}
struct Image { url: String }
impl Draw for Image {
fn draw(&self) { println!("渲染图片,源自: {}", self.url); }
}
fn main() {
// 使用 Box<dyn Draw> 存储不同类型的对象
let components: Vec<Box<dyn Draw>> = vec![
Box::new(Button { width: 100 }),
Box::new(Image { url: String::from("logo.png") }),
];
for comp in components {
comp.draw(); // 运行时动态分发:查表并调用对应方法
}
}
什么时候用 Box<dyn Trait>?
- 异质集合:当你需要一个
Vec存储多种实现了相同接口的类型时。 - 解耦:当你不想在函数签名中暴露具体类型,只想表达“只要实现了这个特征就行”时。
- 减少编译时间:泛型会导致代码膨胀(单态化),而
dyn Trait只有一份代码,可以缩短大型项目的编译时间。
10. 自定义智能指针(进阶)
你要会的点:
- 如何实现自己的指针类型:
- 实现
Deref/DerefMut,让它像引用一样用 - 实现
Drop,在释放时执行资源回收
- 实现
- RAII 模式(文件句柄、网络连接、锁守卫等)
PhantomData(用于告诉编译器“我逻辑上拥有/借用某个 T”,影响 drop check/variance)——偏高级,但智能指针写多了会遇到
要自定义一个智能指针,本质上是创建一个结构体,并为它实现两个核心特征(Trait):Deref 和 Drop。在 Rust 中,正是这两个特征让普通结构体拥有了“像指针一样工作”和“自动管理资源”的超能力。
1. 自定义智能指针的核心公式
Deref特征:允许你通过解引用操作符(*)访问内部数据。Drop特征:定义当指针离开作用域时该执行什么清理逻辑(例如释放内存、关闭文件、打印日志)。
2. 实战演练:创建一个简单的 MyBox<T>
我们将模仿 Box<T> 的行为,创建一个能包裹数据的智能指针。
use std::ops::Deref;
//A. 定义结构体
struct MyBox<T>(T); // 元组结构体
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
//B. 实现 `Deref` (让它能被 `*` 解引用)
//如果不实现 `Deref`,编译器就不知道执行 `*my_box` 时该返回什么。
impl<T> Deref for MyBox<T> {
type Target = T; // 关联类型,指定解引用后得到的类型
fn deref(&self) -> &Self::Target {
&self.0 // 返回元组中的第一个元素引用
}
}
//C. 实现 `Drop` (赋予它自动清理的能力)
impl<T> Drop for MyBox<T> {
fn drop(&mut self) {
println!("MyBox 指针被销毁了,资源已释放!");
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y); // 这里触发了 y.deref()
println!("y 的值是: {}", *y);
} // y 在这里离开作用域,触发 drop() 方法
3. 深度原理:Deref 强制转换 (Deref Coercion)
这是 Rust 智能指针极其好用的秘密武器。Deref 强制转换可以将一个实现了 Deref 的类型的引用转换为它内部类型的引用。
例子:
如果你有一个 MyBox<String>,Rust 可以自动将其转换为 &str:
fn hello(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let m = MyBox::new(String::from("Rust"));
// &m 是 &MyBox<String>
// Rust 自动调用 deref 将其变为 &String
// String 也实现了 Deref,再次调用 deref 变为 &str
hello(&m);
}
11. 选择指南
| 需求场景 | 推荐指针 | 备注 |
|---|---|---|
| 堆分配、大数据传递、递归类型 | Box<T> | 唯一所有权,开销最低。 |
| 单线程、多处共享只读数据 | Rc<T> | 引用计数。 |
| 多线程、多处共享只读数据 | Arc<T> | 原子引用计数,线程安全。 |
| 单线程、小对象内部可变 (Copy类型) | Cell<T> | get/set 值拷贝。 |
| 单线程、大对象内部可变 (非Copy) | RefCell<T> | 运行时借用检查。 |
| 单线程、多所有权共享且可修改 | Rc<RefCell<T>> | 经典套娃组合。 |
| 多线程、共享且可修改 | Arc<Mutex<T>> | 并发黄金搭档。 |
| 避免循环引用、内存泄漏 | Weak<T> | 配合 Rc 或 Arc 使用。 |
| 性能优化、按需克隆 | Cow<T> | 写时克隆。 |
| 异步编程、锁死内存地址 | Pin<P> | 防止自引用结构移动。 |