Rust 一行 entry() 干掉五行 if/else

假设有个需求,要统计请求日志里每个 URL 出现了多少次。

听起来非常 easy 对吧,动手写一下:

rust 复制代码
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)。

但如果我们用另一个方法,可以缩成一行:

rust 复制代码
*counts.entry(path).or_insert(0) += 1;

注意,星号解引用的是 or_insert(0) 返回的可变引用。

计数结果一样,省了单独的存在性检查,也省了 unwrap() 和自己维护的判断分支。

问题就这样解决了。不过,在你到处用这个写法之前,我们应该先弄清楚两件事:

  1. Rust 什么时候构造默认值?
  2. 为了查一个已经存在的键,我会不会先为新键分配内存?

先看看这一行到底返回了什么。

or_insert 返回什么

我们先把代码拆成两行看看。

rust 复制代码
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。

rust 复制代码
*counts.entry(path).or_insert(1) += 1;

or_insert(1) 再加一,会导致每个 URL 的第一条请求都被算成两次。

最初的版本和 entry() 之间,还有个挺实用的写法。

rust 复制代码
if let Some(count) = counts.get_mut(path) {
    *count += 1;
} else {
    counts.insert(path, 1);
}

它也省了单独的存在性检查和 unwrap(),读起来简洁优雅。

只是对于这里的计数,entry() 一行就能搞定。

分组不存在呢

除了计数,拿 entry() 做分组也很厉害。

假设我收到了几个服务报的错,想按服务把错误消息收集起来。

rust 复制代码
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() 不会改变原有的生命周期要求。

如果第一次得单独处理

假设有个需求,需要记录每条请求路由最长的响应耗时,我可以先存下第一次测量的耗时,后面遇到更慢的再更新。

rust 复制代码
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 的表达式什么时候执行

你可能没懂什么意思,看一个完整的例子:

rust 复制代码
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()。等方法拿到构造好的字符串,发现用不上,就把它丢掉了。

如果想把这一步推迟到真要用的时候,可以把函数本身传进去。

rust 复制代码
messages.entry("timeout").or_insert_with(build_message);

这样一来,只有条目缺失时,or_insert_with() 才会调用这个函数。构造默认值时如果需要用到局部变量,也可以传闭包。

如果是一个简单的默认值,例如数字零或者一个小的常量,提前求值没什么影响。

但换成一个会分配内存、写日志或做大量工作的函数,差别就出来了。别看方法名里有个 or,但参数并不会像 || 的右操作数那样短路。

键的构造也有成本

不过,我并不会任何时候都用 entry()。

还是统计 URL 出现次数。这次假设表的类型是 HashMap<String, usize>,也就是说,表里用 String 当作键保存 URL。

而每次收到的 path 是一个 &str,比如 "/login"。

问题来了,要调用 entry(),就得先把它变成 String:

rust 复制代码
*counts.entry(path.to_owned()).or_insert(0) += 1;

这里的 path.to_owned() 会创建一个新的 String,把 URL 文本复制进去。

问题是,即使表里已经有 "/login",这一步也会执行。 我们原本只想把次数加一,却还是先创建了一个最后用不上的字符串。

上一节的 or_insert_with() 只能推迟默认值的构造,而这里的字符串在调用 entry() 之前就已经创建好了。

如果统计过程中遇到的大多是重复的 URL,可以先查一下:

rust 复制代码
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,所以可以这么写:

rust 复制代码
*counts.entry(path).or_insert(0) += 1;

错误分组则是这样:

rust 复制代码
grouped.entry(service).or_default().push(message);

总结

遇到"有就更新,没有就插入"的需求,可以先想想 entry()。计数时,用 or_insert(0) 取得可变引用后加一。分组时,用 or_default() 准备好空集合,再放入数据。

不过,代码写短了,也得留意哪些流程会在你意想不到的时候执行。例如 or_insert() 的参数会先求值,构造默认值比较费事时,可以用 or_insert_with() 按需创建。

选哪种写法,要结合手头的数据和实际需求,不能一味追求一种写法吃遍天下。

相关推荐
红尘散仙10 小时前
谁说终端只能敲键盘?我用 Rust 给小说阅读器加上了鼠标
前端·后端·rust
unconditional2 天前
我拆了 sdkm 的回滚设计:每个写操作,都提前写好了翻车预案
rust·开源
数字幽灵2 天前
开源!我用 Rust 手搓了一个 Agent 原生的鸿蒙逆向分析平台
rust
Kapaseker3 天前
Rust 不用写 for 循环?看我怎么写!
rust
奶油话梅糖3 天前
打开项目 0.9 秒、内存只有 IDEA 的 1/12:OpenIDEA 用 Rust 把 IntelliJ IDEA 重造了一遍
java·rust·github·intellij-idea
Vex2une4 天前
Google Picasa 精神续作:Rust 重写的离线本地相册工具‌
rust·图片管理·picasa·马良·本地照片管理·重复图片检测·maliang
unconditional5 天前
我拆了 sdkm 的自定义 SDK 机制:所谓“内置”,就是官方提前帮你填好的表
rust·开源
Ramble_Naylor6 天前
Rust深度解读:Send 与 Sync 的本质与线程安全边界
python·算法·rust
Ramble_Naylor6 天前
Rust深度解读:异步流(Async Streams)与生成器的处理艺术
开发语言·后端·rust