假设有个需求,要统计请求日志里每个 URL 出现了多少次。
听起来非常 easy 对吧,动手写一下:
use std::collections::HashMap;
fn main() {
let requests = ["/login", "/products", "/login", "/login"];
let mut counts: HashMap<&str, usize> = HashMap::new();
for path in requests {
if counts.contains_key(path) {
let count = counts.get_mut(path).unwrap();
*count += 1;
} else {
counts.insert(path, 1);
}
}
assert_eq!(counts["/login"], 3);
}
代码逻辑很简单。先看 URL 在不在表里。在的话就把次数加一,否则插入一条记录,次数记为一。
不过,你有没有发现,当 URL 已经在表里时,这段代码会把它查两遍:先问这张表“有没有它”(contains_key),等它答“有”,再让它把同一个 URL 找出来(get_mut)。
但如果我们用另一个方法,可以缩成一行:
*counts.entry(path).or_insert(0) += 1;
注意,星号解引用的是 or_insert(0) 返回的可变引用。
计数结果一样,省了单独的存在性检查,也省了 unwrap() 和自己维护的判断分支。
问题就这样解决了。不过,在你到处用这个写法之前,我们应该先弄清楚两件事:
- Rust 什么时候构造默认值?
- 为了查一个已经存在的键,我会不会先为新键分配内存?
先看看这一行到底返回了什么。
or_insert 返回什么
我们先把代码拆成两行看看。
let count = counts.entry(path).or_insert(0);
*count += 1;
第一行的 count 保存的是可变引用,第二行再用星号解引用,修改它指向的值。
entry(path) 代表这个键在表里的条目。条目有两种状态,已存在(occupied)和空缺(vacant)。
or_insert(0) 会往空缺条目里填个零,已有的值则原样保留。
无论是哪种情况,它都会返回指向表内值的可变引用。这里的类型是 &mut usize。
所以第二行 *count += 1 直接改了表里的数字,改完也就不用再插一次了。
第一次遇到 /login,先插入零,再加到一。下一次遇到 /login,就从一加到二。两种情况下都会加一,所以初始值应该是零。
如果你不理解这个用法,写出下面这段代码,就有个很常见的小 bug。
*counts.entry(path).or_insert(1) += 1;
or_insert(1) 再加一,会导致每个 URL 的第一条请求都被算成两次。
最初的版本和 entry() 之间,还有个挺实用的写法。
if let Some(count) = counts.get_mut(path) {
*count += 1;
} else {
counts.insert(path, 1);
}
它也省了单独的存在性检查和 unwrap(),读起来简洁优雅。
只是对于这里的计数,entry() 一行就能搞定。
分组不存在呢
除了计数,拿 entry() 做分组也很厉害。
假设我收到了几个服务报的错,想按服务把错误消息收集起来。
use std::collections::HashMap;
fn main() {
let errors = [
("payments", "timeout"),
("search", "connection refused"),
("payments", "invalid currency"),
];
let mut grouped: HashMap<&str, Vec<&str>> = HashMap::new(); // 标注了类型
for (service, message) in errors {
grouped.entry(service).or_default().push(message);
}
assert_eq!(
grouped["payments"],
vec!["timeout", "invalid currency"]
);
}
条目缺失时,or_default() 会调用值类型的 Default 实现。这里拿到的是一个空 Vec。接着通过返回的可变引用调用 push(),把消息加进去。
注意,这里我们给 HashMap 标注了实际的泛型类型,这很有用!
它告诉 Rust 每组是个 Vec<&str>,这样,当我使用 or_default 的时候,编译器就知道我要构造什么——一个空的 Vec。
按客户归类订单、按扩展名归类文件,或者按字段收集校验消息,都可以这么写。因为分组的代码基本一样。
这些例子用的是固定输入里的字符串切片。如果结果需要获得这些文本的所有权,可以选择 String 这样的拥有所有权的类型。entry() 不会改变原有的生命周期要求。
如果第一次得单独处理
假设有个需求,需要记录每条请求路由最长的响应耗时,我可以先存下第一次测量的耗时,后面遇到更慢的再更新。
use std::collections::HashMap;
fn main() {
let samples = [
("/login", 42_u64),
("/products", 90),
("/login", 65),
("/login", 38),
];
let mut slowest: HashMap<&str, u64> = HashMap::new();
for (path, milliseconds) in samples {
slowest
.entry(path)
.and_modify(|current| {
*current = (*current).max(milliseconds);
})
.or_insert(milliseconds);
}
assert_eq!(slowest["/login"], 65);
}
这两个方法这么用正好拼成需求要的两条分支。entry() 返回的条目要么已存在,要么空缺:
- 空缺,也就是第一次遇到这条路由:
and_modify()会直接跳过,到达or_insert()时存入这次测得的耗时。 - 已存在,之前已经记过:
and_modify()执行闭包,把旧值和新样本里的较大者写回;轮到or_insert()时保留已有值,并返回它的可变引用。
拿 /login 举例子:第一次是空缺,存进 42;第二次遇到,闭包把 42 和 65 取较大者,更新成 65;第三次,38 比 65 小,闭包写完值没变。最后断言的 65 就是这么来的。
正因如此,逻辑分支天然被拆成两半,各由一个方法负责,互不重叠,所以此处也不需要自己写 if/else 了。
or_insert 的表达式什么时候执行
你可能没懂什么意思,看一个完整的例子:
use std::collections::HashMap;
fn build_message() -> String {
println!("Building the default message");
String::from("Please try again later")
}
fn main() {
let mut messages = HashMap::new();
messages.insert("timeout", String::from("Request timed out"));
messages.entry("timeout").or_insert(build_message());
assert_eq!(messages["timeout"], "Request timed out");
}
看完代码,我问个问题,build_message 会执行吗?
答案:会。很奇怪!messages 中的数据存在,但 build_message 中的 println! 依然执行。
这里的原因,是 Rust 会在调用 or_insert() 前先求值 build_message()。等方法拿到构造好的字符串,发现用不上,就把它丢掉了。
如果想把这一步推迟到真要用的时候,可以把函数本身传进去。
messages.entry("timeout").or_insert_with(build_message);
这样一来,只有条目缺失时,or_insert_with() 才会调用这个函数。构造默认值时如果需要用到局部变量,也可以传闭包。
如果是一个简单的默认值,例如数字零或者一个小的常量,提前求值没什么影响。
但换成一个会分配内存、写日志或做大量工作的函数,差别就出来了。别看方法名里有个 or,但参数并不会像 || 的右操作数那样短路。
键的构造也有成本
不过,我并不会任何时候都用 entry()。
还是统计 URL 出现次数。这次假设表的类型是 HashMap<String, usize>,也就是说,表里用 String 当作键保存 URL。
而每次收到的 path 是一个 &str,比如 "/login"。
问题来了,要调用 entry(),就得先把它变成 String:
*counts.entry(path.to_owned()).or_insert(0) += 1;
这里的 path.to_owned() 会创建一个新的 String,把 URL 文本复制进去。
问题是,即使表里已经有 "/login",这一步也会执行。 我们原本只想把次数加一,却还是先创建了一个最后用不上的字符串。
上一节的 or_insert_with() 只能推迟默认值的构造,而这里的字符串在调用 entry() 之前就已经创建好了。
如果统计过程中遇到的大多是重复的 URL,可以先查一下:
if let Some(count) = counts.get_mut(path) {
*count += 1;
} else {
counts.insert(path.to_owned(), 1);
}
get_mut(path) 可以直接用 &str 查找,不需要先创建 String。
这样,表里已经有这个 URL 时,就直接把次数加一。只有第一次遇到它、需要插入记录时,才调用 to_owned()。
当然,这也有取舍。重复的 URL 可以省掉一次字符串创建,新的 URL 则需要先查找,再插入。 哪种更合适,要看重复的 URL 多不多。
如果手头已经有一个可以直接交给表保存的 String,不需要再复制,那就直接用 entry(),没必要为了这点保留分支。
现在,我们看看文章开头的请求计数。那个例子用的是 HashMap<&str, usize>,它的键是 &str,不需要转换成 String,所以可以这么写:
*counts.entry(path).or_insert(0) += 1;
错误分组则是这样:
grouped.entry(service).or_default().push(message);
总结
遇到“有就更新,没有就插入”的需求,可以先想想 entry()。计数时,用 or_insert(0) 取得可变引用后加一。分组时,用 or_default() 准备好空集合,再放入数据。
不过,代码写短了,也得留意哪些流程会在你意想不到的时候执行。例如 or_insert() 的参数会先求值,构造默认值比较费事时,可以用 or_insert_with() 按需创建。
选哪种写法,要结合手头的数据和实际需求,不能一味追求一种写法吃遍天下。