Rust 集合与内存性能技巧:少 clone、用 entry、提前分配容量
wxk1991 Lv5

Rust 集合与内存性能技巧:少 clone、用 entry、提前分配容量

Rust 的性能问题,很多时候不是语言慢。

而是代码里悄悄做了太多不必要的事情:

1
2
3
4
5
重复分配
到处 clone
字符串循环 format
HashMap 查两次
该用借用的地方拿了所有权

这些问题单独看都不大。

但在热路径里堆起来,就会变成真实的性能损耗。

这篇文章整理一些 Rust 集合和内存使用的小技巧。它们不玄学,也不需要 unsafe,基本都是标准库就能解决的问题。


一、先选对集合

Rust 标准库里常用集合主要有这些:

1
2
3
4
5
6
7
Vec
VecDeque
HashMap
BTreeMap
HashSet
BTreeSet
BinaryHeap

不要所有场景都先上 Vec

简单选择:

  • 顺序列表:Vec<T>
  • 频繁从头尾插入删除:VecDeque<T>
  • 按 key 快速查找:HashMap<K, V>
  • 需要 key 有序:BTreeMap<K, V>
  • 去重和集合判断:HashSet<T>
  • 需要有序集合:BTreeSet<T>
  • 优先级队列:BinaryHeap<T>

最常见的错误是:

1
2
3
let users: Vec<User> = load_users();

let user = users.iter().find(|user| user.id == id);

如果只是偶尔查一次,没问题。

如果高频按 id 查用户,就应该考虑:

1
2
3
4
5
6
use std::collections::HashMap;

let users_by_id: HashMap<u64, User> = users
.into_iter()
.map(|user| (user.id, user))
.collect();

数据结构选错,后面再怎么微优化都很有限。


二、知道数量时提前分配容量

Vec 会自动扩容。

这很方便,但扩容可能意味着:

1
2
3
申请更大的内存
把旧数据搬过去
释放旧内存

如果你提前知道大概数量,可以用:

1
2
3
4
5
let mut rows = Vec::with_capacity(records.len());

for record in records {
rows.push(convert(record));
}

字符串也一样:

1
2
3
let mut output = String::with_capacity(1024);
output.push_str("hello");
output.push_str(" rust");

这不是让你到处猜容量。

但在这些场景很值得用:

  • 从一个集合转换到另一个集合
  • 生成固定格式的字符串
  • 批量解析 CSV / JSON 行
  • 构建响应列表
  • 热路径里的临时 buffer

提前分配容量不是为了炫技,而是避免明知道数量还让集合反复扩容。


三、少 clone,先问能不能借用

Rust 新手很容易用 clone 消灭编译错误。

比如:

1
2
3
4
5
6
fn print_name(name: String) {
println!("{}", name);
}

let name = user.name.clone();
print_name(name);

如果函数只是读字符串,应该接收借用:

1
2
3
4
5
fn print_name(name: &str) {
println!("{}", name);
}

print_name(&user.name);

这个小变化很重要。

String 表示拥有一段堆内存。

&str 表示借用一段字符串视图。

只读场景优先用 &str,可以同时接受:

1
2
3
String
&String
&str

这类函数签名更灵活,也更少分配。


四、参数类型尽量接收借用

如果函数不需要拥有数据,不要写成:

1
2
3
fn normalize(input: String) -> String {
input.trim().to_lowercase()
}

更常见的是:

1
2
3
fn normalize(input: &str) -> String {
input.trim().to_lowercase()
}

如果是路径:

1
2
3
4
5
use std::path::Path;

fn load_config(path: &Path) -> std::io::Result<String> {
std::fs::read_to_string(path)
}

调用时:

1
load_config(Path::new("config.toml"))?;

对于库代码,还可以使用更泛化的参数:

1
2
3
fn load_config(path: impl AsRef<Path>) -> std::io::Result<String> {
std::fs::read_to_string(path)
}

但业务代码里不要为了泛化而泛化。

简单项目里,&str&Path&[T] 已经很好。


五、用 slice 替代 Vec 引用

如果函数只需要读取一组数据,不要限定必须传 Vec<T>

1
2
3
4
fn average(nums: &Vec<i32>) -> f64 {
let sum: i32 = nums.iter().sum();
sum as f64 / nums.len() as f64
}

更推荐:

1
2
3
4
fn average(nums: &[i32]) -> f64 {
let sum: i32 = nums.iter().sum();
sum as f64 / nums.len() as f64
}

&[T] 可以接受:

  • Vec<T>
  • 数组
  • slice
  • 某个集合的局部区间

比如:

1
2
3
let nums = vec![1, 2, 3, 4];
average(&nums);
average(&nums[1..3]);

这种写法比 &Vec<T> 更通用。


六、HashMap 用 entry,避免查两次

统计词频时,很多人会这样写:

1
2
3
4
5
6
7
8
9
10
11
12
use std::collections::HashMap;

let mut counts = HashMap::new();

for word in words {
if counts.contains_key(word) {
let count = counts.get_mut(word).unwrap();
*count += 1;
} else {
counts.insert(word, 1);
}
}

这段代码查了不止一次。

更好的写法是 entry

1
2
3
4
5
6
7
use std::collections::HashMap;

let mut counts = HashMap::new();

for word in words {
*counts.entry(word).or_insert(0) += 1;
}

entry 的含义是:

1
2
3
给我这个 key 对应的位置。
如果存在,就操作已有值。
如果不存在,就插入默认值。

这在统计、聚合、缓存初始化里非常常用。

比如分组:

1
2
3
4
5
6
7
8
let mut groups: HashMap<String, Vec<User>> = HashMap::new();

for user in users {
groups
.entry(user.department.clone())
.or_default()
.push(user);
}

如果不想 clone key,就要重新设计数据所有权。不要一边想把 user 移进去,一边又想借用它内部字段当 key。Rust 会逼你把所有权边界想清楚。


七、字符串拼接不要在循环里疯狂 format

不要这样:

1
2
3
4
5
let mut output = String::new();

for item in items {
output = format!("{}{}\n", output, item.name);
}

这会反复创建新字符串。

更好的方式:

1
2
3
4
5
6
let mut output = String::new();

for item in items {
output.push_str(&item.name);
output.push('\n');
}

如果需要格式化数字,可以用 write!

1
2
3
4
5
6
7
use std::fmt::Write;

let mut output = String::with_capacity(1024);

for item in items {
writeln!(&mut output, "{}: {}", item.id, item.name).unwrap();
}

注意这里的 Write 是:

1
std::fmt::Write

不是 std::io::Write

字符串构建属于内存里的格式化,不是文件 I/O。


八、Cow 适合“多数借用,少数修改”

有些函数大多数时候不需要分配新字符串,只有少数情况需要修改。

比如规范化用户名:

1
2
3
4
5
6
7
8
9
fn normalize_name(name: &str) -> String {
let trimmed = name.trim();

if trimmed == name {
name.to_string()
} else {
trimmed.to_string()
}
}

这里就算不需要修改,也会返回新的 String

可以用 Cow

1
2
3
4
5
6
7
8
9
10
11
use std::borrow::Cow;

fn normalize_name(name: &str) -> Cow<'_, str> {
let trimmed = name.trim();

if trimmed == name {
Cow::Borrowed(name)
} else {
Cow::Owned(trimmed.to_string())
}
}

调用方如果只读:

1
2
let name = normalize_name("Alice");
println!("{}", name);

没有修改时就不会分配新字符串。

Cow 适合:

  • 可能返回原始借用
  • 也可能返回修改后的 owned 数据
  • 想避免无意义 clone

但不要滥用。

如果函数总是需要生成新字符串,直接返回 String 更清楚。


九、retain 比手动重建集合更直接

过滤 Vec 时,很多人会这样写:

1
2
3
4
5
6
7
8
9
let mut active_users = Vec::new();

for user in users {
if user.active {
active_users.push(user);
}
}

users = active_users;

如果你已经有一个可变 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
2
3
use std::sync::{Arc, Mutex};

let state = Arc::new(Mutex::new(AppState::default()));

这能工作,但不要把所有状态都塞进一个大 Mutex

锁粒度过大,会让并发变成排队。

我的建议:

  • 只读共享,用 Arc<T>
  • 少量共享可变状态,用 Arc<Mutex<T>>
  • 读多写少,可以看 RwLock
  • 能通过消息传递解决,就不要共享可变状态

Rust 会让并发状态显式化,这是好事。


十一、优化前先确认是不是热路径

这些技巧很有用。

但不要把每一行代码都写成性能谜题。

普通业务代码里,清楚比极致重要。

真正值得优化的是:

  • 循环次数很多的地方
  • 请求主路径
  • 大文件处理
  • 高频字符串拼接
  • 大集合查找和聚合
  • 被 benchmark 证明慢的函数

如果一段代码一天只运行几次,写得清楚就好。

如果一段代码每秒运行几万次,再认真看分配、clone、集合结构。


十二、我的建议

日常写 Rust,先守住这些规则:

  • 按访问模式选集合
  • 知道数量时用 with_capacity
  • 只读参数优先用 &str&[T]&Path
  • 不要用 clone 消灭所有权问题
  • HashMap 更新优先看 entry
  • 循环拼字符串用 push_strwrite!
  • 多数借用、少数修改时考虑 Cow
  • 原地过滤用 retain
  • Arc 解决共享所有权,不是性能魔法
  • 优化前先确认热路径

Rust 性能好,不代表随便写都好。

真正稳定的性能,来自清楚的数据所有权、合适的数据结构,以及少做无意义的分配。


参考资料