智能指针
什么是智能指针 (Smart Pointer)?
普通指针 (Pointer) 就像一张**“小纸条”**,上面只写着一个门牌号(内存地址)。它很傻,不知道房子里住着谁,也不知道房子什么时候该拆。
智能指针 (Smart Pointer) 就像一个**“私人管家”**。
-
它手里也有那张小纸条(包含指针)。
-
它有额外的信息(比如数据的长度、容量、引用计数)。
-
它有超能力(行为) :
Deref(解引用) :你要找数据时,管家帮你开门,让你直接操作数据,感觉不到管家的存在。Drop(析构) :当你不需要数据时(变量离开作用域),管家会自动把房子打扫干净、退租(释放内存/释放锁)。
总结:智能指针 = 指针 + 元数据 + 自动管理逻辑。
1. Box<T> —— 搬家公司(把数据搬到堆上)
原理:
Rust 里的变量默认是存在栈 (Stack) 上的。栈很快,但是空间小,且要求数据大小必须固定。 Box<T> 的作用就是:把原本要在栈上的数据,强行搬到 堆 (Heap) 上去,只在栈上留一个只有指针大小的“提货单”。
- 所有权:独占。
Box拥有堆上那块数据。Box死了,堆上的数据也得死。
用法:
Rust
fn main() {
// 10 这种小整数本来应该在栈上
// Box::new 把 10 扔到了堆上,x 只是栈上的一个指针
let x = Box::new(10);
// *x 解引用,管家带你找到堆上的 10
println!("x is {}", *x);
} // x 离开作用域,Box 自动释放堆内存
设计意图与场景:
-
逃离栈大小限制:如果你有一个巨大的结构体(几百 MB),放在栈上会把栈撑爆(Stack Overflow)。用
Box把它扔到堆上,栈上只存个小指针。 -
递归类型 (Recursive Type) :这是面试必问。
- 比如定义一个链表节点:
struct Node { next: Node }。 - 编译器疯了:“
Node里有个Node,那个Node里还有个Node……这一层套一层,这结构体到底多大?我算不出来,不能在栈上分配!” - 解决:
struct Node { next: Box<Node> }。编译器安心了:“哦,Box我知道,固定 8 个字节(64位系统指针大小)。后面那一串不管多长,反正都在堆上。”
- 比如定义一个链表节点:
-
Trait 对象:当你想在一个数组里存不同类型的对象(只要它们实现了同一个接口)时,必须用
Box<dyn Trait>。
2. Cow<'a, B> —— 聪明的懒汉(写时克隆)
原理:
全名 Copy on write(写时复制)。 它是一个枚举 (Enum) ,它有两种状态:
- Borrowed (借来的) :手里拿的是个引用
&T(只读,数据不在我这,不用我管)。 - Owned (拥有的) :手里拿的是个真值
T(通常是String或Vec,数据归我管)。
它的哲学是:能“借”就绝不“买”。 只要你只是读取数据,我就一直用引用的方式(零成本)。只有当你突然说:“我要修改这个数据!”时,我才会在最后一刻把数据克隆一份给你改,变成“拥有”状态。
用法:
假设你要处理一个字符串,如果字符串里有空格就替换,没有空格就原样返回。
Rust
use std::borrow::Cow;
// 输入是 &str (引用),返回是 Cow (可能是引用,可能是新的 String)
fn remove_spaces(input: &str) -> Cow<str> {
if input.contains(' ') {
// 有空格,必须修改!
// 触发 Clone,生成新的 String,包装成 Owned 返回
Cow::Owned(input.replace(' ', ""))
} else {
// 没空格,不需要改。
// 直接把传进来的引用原样返回,包装成 Borrowed。
// 一次内存分配都没发生!极快!
Cow::Borrowed(input)
}
}
fn main() {
let s1 = "hello_world";
let result1 = remove_spaces(s1); // 这里零拷贝,全是引用
let s2 = "hello world";
let result2 = remove_spaces(s2); // 这里发生了拷贝
}
设计意图与场景:
- 极致的性能优化:当你写一个函数,输入是引用。你希望在大多数情况下直接返回引用(省内存、省时间),只有在极少数需要修改的情况下才分配新内存。
- 读多写少:比如解析配置文件、URL 处理。99% 的情况可能都是合法的,不需要改动,用
Cow可以避免无数次无意义的String::clone()。
3. MutexGuard<T> —— 临时门禁卡
原理:
这是多线程编程中 Mutex (互斥锁) 的核心伴侣。 很多人以为 Mutex 是锁,其实 Mutex 是带锁的房间。 而 MutexGuard 才是如果你能进房间,给你的那把临时钥匙。
-
获取:当你调用
mutex.lock().unwrap()时,如果抢到了锁,你得到的不是原本的数据T,而是一个MutexGuard<T>。 -
智能之处:
- Deref:它让你感觉它就是数据
T,你可以直接修改。 - Drop:这是最关键的!当
MutexGuard离开作用域(比如函数结束,或者你手动drop)时,它会在销毁的一瞬间,自动帮你把锁解开 (Unlock) 。
- Deref:它让你感觉它就是数据
用法:
Rust
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5); // 房间里放着 5
{
// lock() 成功,返回一个 MutexGuard,命名为 num
// 此时,门锁上了,别人进不来
let mut num = m.lock().unwrap();
// 这一步使用了 Deref,我们像操作 &mut i32 一样操作 Guard
*num = 6;
} // <--- 重点!大括号结束,num (Guard) 离开作用域。
// Guard 的 Drop 被调用 -> 自动执行 unlock()。
// 房间门开了。
}
设计意图与场景:
- 防止忘记解锁:在 C/C++ 时代,手动
lock()后,如果中间发生错误提前return了,或者发生了异常,程序员经常忘记unlock(),导致死锁 (Deadlock) ——其他线程永远在等。 - RAII (资源获取即初始化) :Rust 利用
MutexGuard绑定了“锁的生命周期”和“变量的作用域”。只要变量还在,锁就在;变量没了,锁就开了。 彻底杜绝了忘记解锁的问题。 - 数据与锁的绑定:你不可能在没有锁的情况下访问数据(因为数据被包在 Mutex 里,想摸到数据必须拿 Guard,想拿 Guard 必须上锁)。
总结:三者的设计哲学
| 智能指针 | 绰号 | 核心哲学 | 解决的问题 |
|---|---|---|---|
Box<T> | 搬家公司 | 所有权转移 | 怎么把大东西塞进小栈里?怎么处理未知大小的类型? |
Cow<T> | 懒汉 | 按需分配 | 怎么在“只读”场景下避免无意义的内存复制,节省 CPU? |
MutexGuard<T> | 门禁卡 | 生命周期绑定 | 怎么保证锁一定会被释放?怎么防止数据在没锁时被访问? |
这三个指针展示了 Rust 的核心魅力:利用类型系统和生命周期(Deref/Drop),把内存管理和并发安全的负担,从程序员的脑子里,转移到了编译器的检查里。
集合容器
集合容器,顾名思义,就是把一系列拥有相同类型的数据放在一起,统一处理;
切片
切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结
构,用 [T] 来表述。因为长度不确定,所以切片是个 DST(Dynamically Sized Type)
fn main() {
// 1. 定义一个定长数组 (Array)
// 数据存储在【栈 (Stack)】上,类型是 [i32; 5]
let arr = [1, 2, 3, 4, 5];
// 2. 定义一个动态向量 (Vector)
// 数据存储在【堆 (Heap)】上,类型是 Vec<i32>
let vec = vec![1, 2, 3, 4, 5];
// 3. 创建切片 s1
// 借用了 arr 的前两个元素。s1 的类型是 &[i32]。
// 底层结构:{ ptr: 指向 arr[0] 的地址, len: 2 }
let s1 = &arr[..2];
// 4. 创建切片 s2
// 借用了 vec 的前两个元素。s2 的类型也是 &[i32]。
// 底层结构:{ ptr: 指向 vec 堆内存首地址, len: 2 }
let s2 = &vec[..2];
// 打印出来都是 [1, 2]
println!("s1: {:?}, s2: {:?}", s1, s2);
// 5. 比较两个切片
// 结果为 true。
assert_eq!(s1, s2);
// 6. 交叉比较
// 切片 vs 向量,切片 vs 数组
// 结果都为 true。
assert_eq!(&arr[..], vec);
assert_eq!(&vec[..], arr)
}
在 Rust 中,切片(类型标记为 &[T])是一个动态大小的视图(View) ,它指向一段连续的内存序列。
核心概念如下:
-
不拥有数据:切片只是“借用”了数据,它不拥有底层数据的所有权。
-
胖指针 (Fat Pointer) :在底层,切片实际上由两部分组成:
- 指针 (ptr) :指向数据的起始位置。
- 长度 (len) :切片包含的元素个数。
-
屏蔽底层差异:无论数据是存放在栈(如 Array)上,还是堆(如 Vector)上,一旦变成了切片
&[T],它们看起来就是一模一样的。
形象的比喻: 你可以把 arr(数组)和 vec(向量)看作两本不同的书。
arr是一本只有 5 页的硬皮书(固定大小)。vec是一本活页夹,可以随时加页(动态大小)。- 切片
s1和s2则是“复印件”或“扫描件” 。虽然原件来源不同(一本是硬皮书,一本是活页夹),但只要扫描出来的内容都是 "第1页和第2页",那么这两份扫描件就是一样的。
把切片(Slice)和迭代器(Iterator)称为“孪生兄弟”非常贴切。
为了让你这个“小白”彻底理解,我们用一个**“MP3 播放列表”**的例子来打比方。
1. 极其通俗的类比
想象你有一张长长的歌单(比如包含 100 首歌)。
切片 (Slice): “一张写着歌名的静态纸条”
- 本质:它是静态的视图。
- 动作:你截取了歌单里的第 1 首到第 5 首。这就是一个切片。
- 状态:它就在那里,静静地躺着。它知道“开头在哪里”以及“一共有几首”。
- 你可以做什么:你可以一眼看到所有这 5 首歌的名字。但它自己不会动,不会自动放歌。
迭代器 (Iterator): “播放器里的进度条”
- 本质:它是动态的逻辑工具。
- 动作:你把刚才那个“切片”(那 5 首歌)塞进了播放器。
- 状态:它有一个**“当前指针”**(或者叫游标)。刚开始指向第 1 首。
- 你可以做什么:你必须按一下“下一首”(
next()),它才会吐出第 1 首歌给你听,然后指针自动跳到第 2 首。如果你不按,它就永远不动。
2. 为什么说是“孪生兄弟”?
因为在 Rust 中,绝大多数迭代器都是由切片变出来的。
- 你先有了数据(数组或 Vector)。
- 你切了一刀,拿到了切片(确定了要处理的数据范围)。
- 你对切片喊了一声
.iter(),它就变身成了迭代器(准备好一个一个处理数据)。
技术上的转换过程:
- 切片拥有数据的所有权或借用权,但它是一整块肉。
- 迭代器通常会在内部“持有”这个切片(或者指向它的指针),然后通过内部记录位置,一点一点把切片里的肉“喂”给你。
3. 代码实战:从切片到迭代器
看着段代码,我们来“慢动作”回放:
Rust
fn main() {
// 1. 源数据
let data = [10, 20, 30, 40, 50];
// 2. 切片 (Slice)
// s1 是一个切片,它看到了 [20, 30, 40]
// 此时 s1 是静态的,它知道自己长为 3,起点在 20
let s1 = &data[1..4];
println!("切片长这样: {:?}", s1);
// 3. 迭代器 (Iterator)
// 重点来了!我们对 s1 调用了 .iter()
// 此时 iter 是一个“准备好干活的机器人”
let mut iter = s1.iter();
// 4. 迭代器开始工作 (调用 next())
// 第一次按按钮:
println!("第一口: {:?}", iter.next()); // 输出 Some(20)
// 第二次按按钮:
println!("第二口: {:?}", iter.next()); // 输出 Some(30)
// 第三次按按钮:
println!("第三口: {:?}", iter.next()); // 输出 Some(40)
// 第四次按按钮:
println!("第四口: {:?}", iter.next()); // 输出 None (没啦!)
}
这里的核心区别:
- 切片
s1:一直都在,内容一直是[20, 30, 40],不会变。 - 迭代器
iter:它是有状态的(所以必须声明为mut)。每调用一次next(),它内部的指针就往后移一格。它会“消耗”自己的进度。
4. 深入一点点:迭代器是如何工作的?
这部分能帮你打通任督二脉。
虽然我们说切片是 (ptr, len),那迭代器长什么样呢? 在 Rust 标准库中,切片的迭代器(Use slice::Iter)内部结构大致逻辑如下(简化版):
Rust
// 伪代码:切片迭代器的心理活动
struct SliceIterator<'a, T> {
ptr: *const T, // 当前指向哪里
end: *const T, // 终点在哪里
}
当你对切片 &[1, 2, 3] 调用 .iter() 时:
-
迭代器记住了切片的开始位置和结束位置。
-
当你调用
next():- 它返回
ptr指向的值(比如 1)。 - 它把
ptr往后挪一位(现在指向 2)。
- 它返回
-
直到
ptr == end,它就返回None。
所以:迭代器就是一个不断“蚕食”切片范围的小机器。
5. 懒惰的迭代器 (Lazy Evaluation)
这是小白最容易晕的地方。
切片是现成的,迭代器是懒惰的。
Rust
let vec = vec![1, 2, 3];
let slice = &vec[..];
// 这行代码什么都不会发生!
// 就像你买了个跑步机放在家里,但你如果不上去跑,它就只是个摆设。
let iter = slice.iter().map(|x| x + 1);
// 只有当你开始“收集”或者“遍历”它时,它才开始动:
let new_vec: Vec<_> = iter.collect(); // 这一步才会真正执行 x + 1
总结
| 特性 | 切片 (Slice) &[T] | 迭代器 (Iterator) |
|---|---|---|
| 比喻 | 菜单 | 服务员 |
| 核心能力 | 展示一段连续的数据 | 一个接一个地提供数据 |
| 状态 | 静态的(除非你改数据) | 动态的(有个内部指针在走) |
| 核心方法 | len(), get(), is_empty() | next() |
| 关系 | 是迭代器的数据源 | 是切片的消费方式 |
一句话记住:切片是地图,告诉你路全貌;迭代器是导航,一步一步告诉你怎么走。
HashMap,也就是哈希表
如果数据结构的输入和输出能一一对应,那么可以使用列表,如果无法一一对应,那么就需要使用哈希表
二次探查(quadratic probing)和SIMD 查表(SIMD lookup)
第一部分:直觉理解 —— 为什么需要哈希表?
你那句话总结得很对,但我们可以说得更精确一点:
-
列表 (Array/Vec): 适用于**“钥匙是连续整数”**的情况。
- 比喻:这是一个只有编号的储物柜(0号,1号,2号...)。
- 如果你拿着
0号牌,你不需要思考,直接走到第 1 个柜子就行。速度极快 (O(1))。 - 局限:如果你拿着一把钥匙叫
"张三",你没法把“张三”塞进整数编号里。或者你的钥匙是1000000,你不可能为了存一个东西开一百万个柜子(浪费空间)。
-
哈希表 (HashMap): 适用于**“钥匙是任意东西”**的情况。
- 比喻:这是一个带前台接待的储物柜。
- 你拿着
"张三"去存包。你不知道存哪里。 - 哈希函数 (Hash Function) 就是那个前台接待员。他看一眼
"张三",拿出计算器算了一下,说:“张三啊,你去 5号 柜子”。 - 下次你来取包,报上
"张三",接待员一算,还是 5号,你就取到了。
核心矛盾:如果 "李四" 来了,接待员一算,发现也是 5号,这就叫哈希冲突 (Collision) 。5号柜子已经满了,李四去哪?
这时候,二次探查就登场了。
第二部分:解决冲突 —— 什么是“二次探查”?
当“5号柜子”被张三占了,李四需要找下一个空位。找空位的方法主要有两种:
1. 线性探查 (Linear Probing) —— 笨办法
- 规则:5号满了?看看6号。6号满了?看看7号... 一步一步挪(步长为 1)。
- 缺点:这会导致**“拥堵” (Clustering)**。如果5、6、7都被占了,新来一个想去5号的人,得一路走到8号。数据会像堵车一样连成一大片,查询越来越慢。
2. 二次探查 (Quadratic Probing) —— 聪明的跳跃
-
定义:并不是一步一步挪,而是大步跳跃。
-
规则:
- 第一次尝试:Hash (比如 5号) -> 满了
- 第二次尝试:Hash+12 (5 + 1 = 6号)
- 第三次尝试:Hash+22 (5 + 4 = 9号)
- 第四次尝试:Hash+32 (5 + 9 = 14号)
- ...
-
为什么这么做?
- 李四如果被5号挤走了,他会迅速跳到很远的地方(9号、14号)。
- 好处:这就像停车场,如果你发现门口满了,你不会一个个车位看,你会直接把车开到后面空旷的区域。这有效地避免了数据“扎堆”拥堵,保持了查找的高速。
第三部分:极致速度 —— 什么是“SIMD 查表”?
这是现代哈希表(如 Rust 的 HashMap)快到飞起的秘密武器。
1. 背景
传统的查找逻辑是:
“是这个吗?不是。是那个吗?不是。是下一个吗?不是...”
哪怕用了二次探查,你还是一次只看一个柜子。CPU 很不喜欢这种一次做一件事的节奏。
2. SIMD 是什么?
-
全称:Single Instruction, Multiple Data(单指令流多数据流)。
-
通俗解释:一次动作,并行处理一批数据。
-
比喻:
- 普通查找:老师点名找人,一个一个问:“你是张三吗?” “不是”。“你是张三吗?” “不是”。
- SIMD 查找:老师拿个大喇叭喊一声,同时盯着16个学生的脸。只要其中有一个人眼神对上了,老师瞬间就锁定了位置。
3. 在哈希表中怎么用?
Rust 的 HashMap (基于 Google 的 SwissTable 设计) 把哈希表分成了两部分:
- Control Bytes (元数据区) :存哈希值的缩略图(比如只存哈希值的后7位),每个只有 1 字节大小。
- Slots (数据区) :存真正的 Key-Value。
SIMD 查表过程: 当你查找 "张三" 时:
-
CPU 拿到张三哈希值的缩略图(比如是
0x7F)。 -
CPU 使用 SIMD 指令,一口气抓取 16 个柜子的缩略图(128位宽的寄存器正好放下 16 个字节)。
-
在一个 CPU 周期内,并行比较这 16 个缩略图里有没有
0x7F。- 如果有,算出它是第几个,直接去取数据。
- 如果没有,再用二次探查跳到下一组 16 个。
总结:三者的关系
如果我们要设计一个完美的哈希表:
- 宏观架构:我们用哈希表而不是列表,是为了解决 Key 不是整数的问题。
- 解决冲突:我们用二次探查,是为了防止数据扎堆(Clustering),让数据在内存中分布得更均匀。
- 微观加速:我们用SIMD 查表,是为了在寻找空位或寻找目标时,能一次看一群(比如一次看16个位置),而不是一个一个看,从而把 CPU 的性能榨干。
这就是为什么 Rust 的 HashMap 即使在数据量很大的情况下,依然快得惊人的原因。它结合了数学上的策略(二次探查)和硬件上的暴力美学(SIMD)。