Rust 集合与内存性能技巧:少 clone、用 entry、提前分配容量
Rust 的性能问题,很多时候不是语言慢。
而是代码里悄悄做了太多不必要的事情:
1 | 重复分配 |
这些问题单独看都不大。
但在热路径里堆起来,就会变成真实的性能损耗。
这篇文章整理一些 Rust 集合和内存使用的小技巧。它们不玄学,也不需要 unsafe,基本都是标准库就能解决的问题。
一、先选对集合
Rust 标准库里常用集合主要有这些:
1 | Vec |
不要所有场景都先上 Vec。
简单选择:
- 顺序列表:
Vec<T> - 频繁从头尾插入删除:
VecDeque<T> - 按 key 快速查找:
HashMap<K, V> - 需要 key 有序:
BTreeMap<K, V> - 去重和集合判断:
HashSet<T> - 需要有序集合:
BTreeSet<T> - 优先级队列:
BinaryHeap<T>
最常见的错误是:
1 | let users: Vec<User> = load_users(); |
如果只是偶尔查一次,没问题。
如果高频按 id 查用户,就应该考虑:
1 | use std::collections::HashMap; |
数据结构选错,后面再怎么微优化都很有限。
二、知道数量时提前分配容量
Vec 会自动扩容。
这很方便,但扩容可能意味着:
1 | 申请更大的内存 |
如果你提前知道大概数量,可以用:
1 | let mut rows = Vec::with_capacity(records.len()); |
字符串也一样:
1 | let mut output = String::with_capacity(1024); |
这不是让你到处猜容量。
但在这些场景很值得用:
- 从一个集合转换到另一个集合
- 生成固定格式的字符串
- 批量解析 CSV / JSON 行
- 构建响应列表
- 热路径里的临时 buffer
提前分配容量不是为了炫技,而是避免明知道数量还让集合反复扩容。
三、少 clone,先问能不能借用
Rust 新手很容易用 clone 消灭编译错误。
比如:
1 | fn print_name(name: String) { |
如果函数只是读字符串,应该接收借用:
1 | fn print_name(name: &str) { |
这个小变化很重要。
String 表示拥有一段堆内存。
&str 表示借用一段字符串视图。
只读场景优先用 &str,可以同时接受:
1 | String |
这类函数签名更灵活,也更少分配。
四、参数类型尽量接收借用
如果函数不需要拥有数据,不要写成:
1 | fn normalize(input: String) -> String { |
更常见的是:
1 | fn normalize(input: &str) -> String { |
如果是路径:
1 | use std::path::Path; |
调用时:
1 | load_config(Path::new("config.toml"))?; |
对于库代码,还可以使用更泛化的参数:
1 | fn load_config(path: impl AsRef<Path>) -> std::io::Result<String> { |
但业务代码里不要为了泛化而泛化。
简单项目里,&str、&Path、&[T] 已经很好。
五、用 slice 替代 Vec 引用
如果函数只需要读取一组数据,不要限定必须传 Vec<T>:
1 | fn average(nums: &Vec<i32>) -> f64 { |
更推荐:
1 | fn average(nums: &[i32]) -> f64 { |
&[T] 可以接受:
Vec<T>- 数组
- slice
- 某个集合的局部区间
比如:
1 | let nums = vec![1, 2, 3, 4]; |
这种写法比 &Vec<T> 更通用。
六、HashMap 用 entry,避免查两次
统计词频时,很多人会这样写:
1 | use std::collections::HashMap; |
这段代码查了不止一次。
更好的写法是 entry:
1 | use std::collections::HashMap; |
entry 的含义是:
1 | 给我这个 key 对应的位置。 |
这在统计、聚合、缓存初始化里非常常用。
比如分组:
1 | let mut groups: HashMap<String, Vec<User>> = HashMap::new(); |
如果不想 clone key,就要重新设计数据所有权。不要一边想把 user 移进去,一边又想借用它内部字段当 key。Rust 会逼你把所有权边界想清楚。
七、字符串拼接不要在循环里疯狂 format
不要这样:
1 | let mut output = String::new(); |
这会反复创建新字符串。
更好的方式:
1 | let mut output = String::new(); |
如果需要格式化数字,可以用 write!:
1 | use std::fmt::Write; |
注意这里的 Write 是:
1 | std::fmt::Write |
不是 std::io::Write。
字符串构建属于内存里的格式化,不是文件 I/O。
八、Cow 适合“多数借用,少数修改”
有些函数大多数时候不需要分配新字符串,只有少数情况需要修改。
比如规范化用户名:
1 | fn normalize_name(name: &str) -> String { |
这里就算不需要修改,也会返回新的 String。
可以用 Cow:
1 | use std::borrow::Cow; |
调用方如果只读:
1 | let name = normalize_name("Alice"); |
没有修改时就不会分配新字符串。
Cow 适合:
- 可能返回原始借用
- 也可能返回修改后的 owned 数据
- 想避免无意义 clone
但不要滥用。
如果函数总是需要生成新字符串,直接返回 String 更清楚。
九、retain 比手动重建集合更直接
过滤 Vec 时,很多人会这样写:
1 | let mut active_users = Vec::new(); |
如果你已经有一个可变 Vec,可以原地保留:
1 | users.retain(|user| user.active); |
HashMap 也有 retain:
1 | cache.retain(|_, value| !value.expired()); |
适合清理缓存、过滤列表、删除无效项。
不过如果你需要把过滤掉的数据另外保存,retain 就不适合了。那时可以考虑 drain 或者重新构建。
十、Arc 不是免费复制数据
看到 Arc<T>,很多人会以为:
1 | clone 很便宜,所以随便 clone。 |
Arc::clone 的确不会复制内部数据,它只是增加引用计数。
但它仍然有成本,尤其在多线程下引用计数是原子操作。
更重要的是:Arc<T> 只解决共享所有权,不解决可变性。
如果你需要多线程修改内部数据,通常会看到:
1 | use std::sync::{Arc, Mutex}; |
这能工作,但不要把所有状态都塞进一个大 Mutex。
锁粒度过大,会让并发变成排队。
我的建议:
- 只读共享,用
Arc<T> - 少量共享可变状态,用
Arc<Mutex<T>> - 读多写少,可以看
RwLock - 能通过消息传递解决,就不要共享可变状态
Rust 会让并发状态显式化,这是好事。
十一、优化前先确认是不是热路径
这些技巧很有用。
但不要把每一行代码都写成性能谜题。
普通业务代码里,清楚比极致重要。
真正值得优化的是:
- 循环次数很多的地方
- 请求主路径
- 大文件处理
- 高频字符串拼接
- 大集合查找和聚合
- 被 benchmark 证明慢的函数
如果一段代码一天只运行几次,写得清楚就好。
如果一段代码每秒运行几万次,再认真看分配、clone、集合结构。
十二、我的建议
日常写 Rust,先守住这些规则:
- 按访问模式选集合
- 知道数量时用
with_capacity - 只读参数优先用
&str、&[T]、&Path - 不要用
clone消灭所有权问题 HashMap更新优先看entry- 循环拼字符串用
push_str或write! - 多数借用、少数修改时考虑
Cow - 原地过滤用
retain Arc解决共享所有权,不是性能魔法- 优化前先确认热路径
Rust 性能好,不代表随便写都好。
真正稳定的性能,来自清楚的数据所有权、合适的数据结构,以及少做无意义的分配。