迭代器和闭包在Rust中
1. 概述
闭包和迭代器是两个工具,帮助你用函数式风格处理Rust中的数据,同时保持手写循环的性能。本文解释了闭包如何从环境中捕获变量,以及三个trait Fn、FnMut、FnOnce。然后本文介绍了Iterator trait、常见的适配器和如何编写自己的迭代器。读完之后,你就能自信地编写filter → map → collect这样的管道。你也会了解为什么编译器有时会拒绝你的代码。
所有示例使用Rust 1.80+(edition 2021),仅需标准库。
2. 基础闭包
2.1. 语法
闭包是一个匿名函数,它可以“看到”周围作用域中的变量:
fn main() {
let discount = 10; // 百分比
let apply_discount = |price: u32| price - price * discount / 100;
println!("{}", apply_discount(250));
println!("{}", apply_discount(1000));
}
225
900
参数位于两个|...|符号之间,闭包体紧跟其后。闭包使用变量discount,虽然这个变量没有被传入。普通的fn函数做不到这一点。
参数类型和返回类型通常会自动推断。如果编译器有足够信息,你可以写|price| ...。但是,类型只会被推断一次。如果你第一次用u32调用闭包,第二次用i64调用就会编译错误。
2.2. 三种捕获变量的方式
闭包按照闭包体所需的“最轻”方式捕获变量。优先顺序是不可变借用、可变借用,最后是获取所有权。
fn main() {
let mut cart = vec!["笔"];
let print_cart = || println!("购物车: {:?}", cart); // borrow &cart
print_cart();
let mut add_item = |item| cart.push(item); // borrow &mut cart
add_item("本子");
add_item("尺子");
println!("最后: {:?}", cart);
}
购物车: ["笔"]
最后: ["笔", "本子", "尺子"]
逐个分析这些闭包:
print_cart只读取cart,所以它持有一个&cart。add_item调用push,所以它需要&mut cart。闭包变量也必须声明为mut,因为调用它会改变内部状态。- 代码能编译是因为非词法生命周期。在第一次调用之后,
print_cart不再被使用,所以不可变借用在可变借用开始前结束。
当闭包需要拥有数据时,你添加关键字move。典型的情况是将闭包传递给另一个线程:
use std::thread;
fn main() {
let customer = String::from("小兰");
let handle = thread::spawn(move || {
println!("正在处理{}的订单", customer);
});
handle.join().unwrap();
}
正在处理小兰的订单
没有move,编译器会报错。新线程的生命周期可能比main更长,所以对customer的引用不安全。有了move,String被完全移入闭包。之后,你就不能在main中使用customer了。
3. 三个trait:Fn、FnMut和FnOnce
每个闭包都会自动实现以下三个trait中的一个或多个。实现哪个trait取决于闭包对捕获的变量做了什么:
| Trait | 闭包对捕获变量做什么 | 能调用多少次 |
|---|---|---|
Fn |
仅读取 | 多次 |
FnMut |
修改 | 多次 |
FnOnce |
消耗(移出) | 一次 |
这三个trait有继承关系:所有Fn都是FnMut,所有FnMut都是FnOnce。编写接收闭包的函数时,你应该选择函数所需的最宽松的 trait。这样调用者有最多的选择。
fn run_twice<F: Fn()>(f: F) {
f();
f();
}
fn run_twice_mut<F: FnMut()>(mut f: F) {
f();
f();
}
fn run_once<F: FnOnce() -> String>(f: F) -> String {
f()
}
fn main() {
let name = String::from("明");
run_twice(|| println!("你好 {name}"));
let mut count = 0;
run_twice_mut(|| count += 1);
println!("count = {count}");
let receipt = run_once(move || name + " - 已支付");
println!("{receipt}");
}
你好 明
你好 明
count = 2
明 - 已支付
三次调用分别对应三个trait:
- 第一个闭包只读取
name,所以它是Fn。 - 第二个闭包增加
count,所以它是FnMut。将它传给run_twice会出错。 - 最后的闭包使用
name + "..."。String上的+运算符获取name的所有权,所以闭包只能调用一次,且只实现FnOnce。
3.1. 从函数返回闭包
每个闭包都有一个自己的、匿名的类型。因此你无法写出那个类型的名字。有两种方式返回闭包:
fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
move |price| price - price * percent / 100
}
fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
if is_vip {
Box::new(|price| price * 80 / 100)
} else {
Box::new(|price| price)
}
}
fn main() {
let summer_sale = make_discount(15);
println!("{}", summer_sale(200));
let rule = pick_rule(true);
println!("{}", rule(500));
}
170
400
- 当函数总是返回一个固定的闭包类型时,使用
impl Fn(...)。这种方式不涉及堆分配,也没有动态调用的开销。 - 当不同的分支返回不同的闭包时,使用
Box<dyn Fn(...)>。pick_rule中的两个闭包有不同的类型,所以必须将它们转换为同一个trait对象。 make_discount中的move是必须的。如果没有它,闭包会借用percent,但percent在函数结束时会消失。
4. Iterator Trait
4.1. 核心:next函数
Rust中所有的迭代器都围绕一个非常小的trait:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ... 许多预定义的方法,如map、filter、sum等 ...
}
next返回Some(元素)直到数据用尽,然后返回None。for循环实际上只是重复调用next的语法糖。
4.2. iter、iter_mut和into_iter
一个集合可以产生三种不同的迭代器:
fn main() {
let mut prices = vec![100, 200, 300];
for p in prices.iter() { // p: &i32
print!("{p} ");
}
println!();
for p in prices.iter_mut() { // p: &mut i32
*p += 10;
}
for p in prices.into_iter() { // p: i32,向量被移动
print!("{p} ");
}
println!();
// println!("{:?}", prices); // 错误:移动后使用值
}
100 200 300
110 210 310
iter()提供不可变引用。之后集合仍然可以使用。iter_mut()提供可变引用以就地修改元素。into_iter()获取所有权。写for p in prices也会隐式调用into_iter()。
5. 适配器和消费者
迭代器上的方法分为两组:
- 适配器(
map、filter、take、zip、enumerate等)将一个迭代器转换为另一个。它们是惰性的:在有人消费之前不会运行任何代码。 - 消费者(
collect、sum、count、fold、for_each、find等)真正调用next并产生结果。
我们使用Order结构体作为示例数据:
#[derive(Debug)]
pub struct Order {
pub id: u32,
pub customer: String,
pub total: u32,
pub paid: bool,
}
pub fn unpaid_total(orders: &[Order]) -> u32 {
orders.iter()
.filter(|o| !o.paid)
.map(|o| o.total)
.sum()
}
pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
orders.iter()
.filter(|o| o.total >= threshold)
.map(|o| o.customer.as_str())
.collect()
}
fn main() {
let orders = vec![
Order { id: 1, customer: "安".into(), total: 150_000, paid: true },
Order { id: 2, customer: "兵".into(), total: 80_000, paid: false },
Order { id: 3, customer: "芝".into(), total: 320_000, paid: false },
];
println!("未收: {}", unpaid_total(&orders));
println!("大客户: {:?}", big_customers(&orders, 100_000));
let summary = orders.iter()
.fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
println!("订单: {summary}");
}
未收: 400000
大客户: ["安", "芝"]
订单: #1 #2 #3
示例中的步骤:
filter(|o| !o.paid)接收一个返回bool的闭包。参数o的类型是&&Order,因为filter传递对元素的引用,而iter()的元素已经是&Order。自动解引用帮助你正常写o.paid。map(|o| o.total)将每个订单转换为金额。sum()把所有的都加起来。collect()将结果收集到一个集合中。目标类型来自函数的返回类型Vec<&str>。fold接收一个初值和一个闭包(acc, 元素) -> 新的acc。这是最通用的消费者。
5.1. 惰性求值
下面的代码展示了适配器只在被消费时才运行:
fn main() {
let ids = vec![1, 2, 3];
let doubled = ids.iter().map(|x| {
println!("map {x}");
x * 2
});
println!("还没有运行任何东西");
let result: Vec<i32> = doubled.collect();
println!("{result:?}");
}
还没有运行任何东西
map 1
map 2
map 3
[2, 4, 6]
得益于惰性求值,filter → map → sum链只遍历一次数据,不会创建中间集合。编译器通常会将这个管道优化为等效于手写循环的机器码。这就是为什么迭代器被称为零成本抽象。
6. 编写自己的迭代器
你只需要实现next。所有其他适配器都会自动提供。下面的例子生成格式为发票-0001的发票号:
pub struct InvoiceNumbers {
current: u32,
end: u32,
}
impl InvoiceNumbers {
pub fn new(start: u32, end: u32) -> Self {
Self { current: start, end }
}
}
impl Iterator for InvoiceNumbers {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
if self.current > self.end {
return None;
}
let number = format!("发票-{:04}", self.current);
self.current += 1;
Some(number)
}
}
fn main() {
let customers = ["安", "兵", "芝"];
for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
println!("{invoice} -> {customer}");
}
}
发票-0007 -> 安
发票-0008 -> 兵
发票-0009 -> 芝
解释:
type Item = String声明迭代器返回的元素类型。next通过&mut self更新状态,当超过end时返回None。zip将两个迭代器配对,当较短的迭代器用尽时停止。因此只有三行会被打印,尽管数字范围可以到100。
7. 编写测试
Rust内置测试框架。你可以在包含unpaid_total和InvoiceNumbers的文件中放置测试模块:
#[cfg(test)]
mod tests {
use super::*;
fn order(id: u32, total: u32, paid: bool) -> Order {
Order { id, customer: format!("客户{id}"), total, paid }
}
#[test]
fn unpaid_total_only_counts_unpaid_orders() {
let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
assert_eq!(unpaid_total(&orders), 120);
}
#[test]
fn unpaid_total_of_empty_slice_is_zero() {
assert_eq!(unpaid_total(&[]), 0);
}
#[test]
fn invoice_numbers_stop_after_end() {
let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
assert_eq!(numbers, vec!["发票-0009", "发票-0010", "发票-0011"]);
}
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
第二个测试检查边界情况。空迭代器上的sum()返回0,不会panic。
8. 常见错误和何时不应使用
注意: 忘记消费者是最常见的错误。代码
orders.iter().map(|o| println!("{}", o.id));不会打印任何内容。编译器警告“迭代器是惰性的,除非被消费否则什么都不做”。当你只需要副作用时,使用for循环或for_each。
你会遇到的其他一些错误:
collect不知道目标类型。 写let v = it.collect();会出错“需要类型注解”。你需要明确写let v: Vec<_> = ...或使用turbofishcollect::<Vec<_>>()。- 使用
into_iter()后想再次使用集合。 如果只需要读取,使用iter()。 - 闭包持有借用太久。 一个捕获
&mut cart的FnMut闭包会阻止你在闭包最后一次使用前读取cart。缩小闭包的作用域或先调用它再读取。 - 在
map内需要?。 闭包内的?运算符只从闭包返回,不从外部函数返回。使用collect::<Result<Vec<_>, _>>()。这种方法在第一个错误处停止并返回Err。
何时不应该强制使用迭代器链:
- 逻辑有很多分支、很多
break/continue点,或者同时更新多个变量。一个清晰的for循环比十行长的fold更容易读。 - 你需要从多个分支返回闭包但担心性能。
Box<dyn Fn>有分配和动态调用的开销。考虑使用enum或泛型代替。
9. 总结
Rust中的闭包以最轻的方式捕获变量:借用、可变借用或move。因此闭包会自动实现Fn、FnMut或FnOnce。编写接收闭包的函数时,选择函数所需的最宽松的trait。
迭代器只基于一个next方法。像map、filter、zip这样的适配器是惰性的,而像collect、sum、fold这样的消费者才真正运行管道。结合这两个工具,你可以编写简洁、内存安全且与手写循环一样快的数据处理代码。你也应该知道什么时候简单的for循环是更好的选择。