迭代器和闭包在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循环是更好的选择。

rustiteratorclosurefunctional programming