Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Rust 深度教材/第7章

第7章:函数式 Rust —— 闭包、迭代器与命令行实战

9 分钟 · 更新于 2026-09-01

第7章:函数式 Rust —— 闭包、迭代器与命令行实战

本章用命令行工具 linefind 串起闭包与迭代器。它读取文件、按规则筛选行并输出结果;随着重构推进,匹配策略会从固定分支变成可传递的行为,数据处理会从外部可变容器变成惰性流水线。

观察重点 不要只比较“循环写法”和“链式写法”谁更短。真正要观察的是:谁拥有参数,闭包捕获了什么,迭代器何时消费输入,测试能否绕开文件系统和环境变量。


一、把程序拆成数据流与策略

Rust 允许可变状态、循环和显式资源管理,因此这里的“函数式”不是要求纯函数,而是强调两种拆分:

  • 数据流:参数 → 配置 → 文件内容 → 行 → 输出;
  • 策略:一行文本是否匹配、是否忽略大小写、匹配后怎样转换。

linefind 的演进会遵循三个步骤:

  1. 把环境读取与核心搜索逻辑分离;
  2. 把匹配条件抽成闭包或泛型参数;
  3. 用迭代器表达“按需拉取”的处理流水线。

这样做的收益不是炫技,而是缩短状态存活时间、明确所有权流向,并让核心逻辑可以直接接收字符串进行测试。

text
命令行参数 ──> Config ──> 文件文本 ──> lines() ──> filter(策略) ──> collect/输出

左半部分负责副作用,右半部分尽量保持确定性。

二、先把 linefind 拆成清晰的边界

一个可维护的命令行程序,至少应分成两层:

  • main.rs:读取环境、组装配置、决定退出码。
  • lib.rs:保存可测试的业务逻辑。

2.1 配置对象比散落变量更稳定

先定义搜索配置:

rust
#[derive(Debug, PartialEq, Eq)]
pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

这里让 Config 拥有两个 String,而不是借用命令行参数。代价是获得字符串所有权,收益是配置不再依赖外部参数容器的生命周期。 早期实现常先收集成 Vec<String>

rust
let args: Vec<String> = std::env::args().collect();
let query = args[1].clone();
let file_path = args[2].clone();

它能工作,但有两个问题:

  • 下标访问可能越界。
  • 为了从借用的数组中取出所有权,需要 clone

2.2 让 Config 直接消费参数迭代器

std::env::args() 本身就返回迭代器,不必先收集成数组:

rust
impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Self, &'static str> {
        args.next();
        let query = args
            .next()
            .ok_or("missing query string")?;
        let file_path = args
            .next()
            .ok_or("missing file path")?;
        let ignore_case = std::env::var("IGNORE_CASE").is_ok();
        Ok(Self {
            query,
            file_path,
            ignore_case,
        })
    }
}

这段代码已经同时用到了迭代器和错误组合器:

  • next() 每次消费一个参数,返回 Option<String>
  • ok_or(...)Option 转成 Result
  • ? 在缺少参数时提前返回错误。
  • 参数的所有权直接进入 Config,无需 clone

可迁移的判断 ① 如果函数只需要按顺序读取输入,不需要随机访问,就优先接收 impl Iterator<Item = T>,而不是先要求调用者构造 Vec<T>


三、闭包:把搜索规则变成值

具名函数适合稳定、公开的行为;闭包适合局部、需要上下文的行为。

3.1 最小语法

rust
fn add_one_fn(x: i32) -> i32 {
    x + 1
}
let add_one_closure = |x: i32| -> i32 { x + 1 };
let add_one_short = |x| x + 1;
assert_eq!(add_one_fn(1), 2);
assert_eq!(add_one_closure(1), 2);
assert_eq!(add_one_short(1), 2);

闭包参数和返回值通常可以推导,但推导不是泛型:

rust
let identity = |x| x;
let text = identity(String::from("Rust"));
// let number = identity(7); // 已推导为 String,不能再用 i32 调用

每个闭包表达式还有自己独立的匿名类型。即使两个闭包签名相同,它们的具体类型也不同,因此 API 通常通过 Fn 系列特征描述闭包。

3.2 闭包能捕获环境

linefind 的大小写敏感搜索需要使用外部的 query

rust
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

filter 只把当前行传给闭包,query 并不是参数,却仍可被使用,因为闭包捕获了定义位置的环境。 具名函数不能这样捕获局部变量:

rust
// fn matches(line: &&str) -> bool {
//     line.contains(query) // query 不在函数参数或全局作用域中
// }

3.3 三种 Fn 特征看“如何使用捕获值”

特征调用时对闭包的要求典型行为
Fn只需要共享借用读取捕获值,可重复调用
FnMut需要可变借用修改捕获值,可重复调用
FnOnce允许消费闭包移出捕获值,至少可调用一次
关系可以从强到弱理解:
text
Fn 也满足 FnMut 和 FnOnce 的使用要求
FnMut 也满足 FnOnce 的使用要求
所有闭包至少实现 FnOnce

一个只读取环境的闭包:

rust
let query = String::from("rust");
let predicate = |line: &str| line.contains(&query);
assert!(predicate("rust is fast"));
assert!(predicate("learn rust"));

一个修改环境的闭包:

rust
let mut matched = 0;
let mut predicate = |line: &str| {
    let hit = line.contains("rust");
    if hit {
        matched += 1;
    }
    hit
};
assert!(predicate("rust"));
assert!(!predicate("go"));
assert_eq!(matched, 1);

一个移出捕获值的闭包:

rust
let message = String::from("done");
let consume = move || message;
assert_eq!(consume(), "done");
// consume(); // message 已被移出,只能调用一次

move 不等于 FnOnce move 决定闭包如何捕获变量;FnFnMutFnOnce 取决于闭包如何使用捕获到的变量。一个 move 闭包若只读取内部值,仍可能实现 Fn

3.4 用泛型接收搜索规则

把遍历和匹配规则解耦:

rust
pub fn search_with<'a, F>(contents: &'a str, predicate: F) -> Vec<&'a str>
where
    F: FnMut(&str) -> bool,
{
    contents
        .lines()
        .filter(|line| predicate(line))
        .collect()
}

现在大小写敏感与不敏感搜索只是两种规则:

rust
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    search_with(contents, |line| line.contains(query))
}
pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    search_with(contents, move |line| {
        line.to_lowercase().contains(&query)
    })
}

这里使用 move,让闭包拥有小写后的 query。闭包在整个 search_with 调用期间可以稳定使用它,不再借用外层局部绑定。


四、迭代器:一条由 next 驱动的惰性流水线

4.1 Iterator 的核心只有 next

概念上的定义可以简化为:

rust
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

next 返回:

  • Some(item):还有下一个元素。
  • None:迭代结束。 因为每次调用都会推进内部位置,所以手动调用 next 时,迭代器绑定通常需要 mut
rust
let mut numbers = [10, 20].into_iter();
assert_eq!(numbers.next(), Some(10));
assert_eq!(numbers.next(), Some(20));
assert_eq!(numbers.next(), None);

4.2 Iterator 与 IntoIterator 不要混

  • Iterator:已经是迭代器,可以调用 next
  • IntoIterator:能够被转换成迭代器,因此能放进 forfor item in values 大体等价于:
rust
let mut iter = IntoIterator::into_iter(values);
while let Some(item) = iter.next() {
    println!("{item:?}");
}

4.3 三种入口对应三种所有权语义

调用元素类型对原集合的影响
values.iter()&T不可变借用,集合仍可使用
values.iter_mut()&mut T可变借用,可原地修改
values.into_iter()T消费集合,转移元素所有权
rust
let words = vec![String::from("rust"), String::from("book")];
let lengths: Vec<usize> = words.iter().map(String::len).collect();
assert_eq!(lengths, vec![4, 4]);
println!("{words:?}");
let upper: Vec<String> = words
    .into_iter()
    .map(|word| word.to_uppercase())
    .collect();
assert_eq!(upper, vec!["RUST", "BOOK"]);

4.4 适配器惰性,消费者负责启动

下面的 map 不会自行运行:

rust
let numbers = vec![1, 2, 3];
let pipeline = numbers.iter().map(|n| n + 1);

只有消费者拉取结果时,链条才真正工作:

rust
let output: Vec<i32> = pipeline.collect();
assert_eq!(output, vec![2, 3, 4]);

常见分类:

类别方法返回什么
迭代器适配器mapfiltertakeskipzip新迭代器
消费者collectsumcountfoldfor_each最终值或副作用
linefind 的搜索链可以逐步读成自然语言:
rust
contents
    .lines()                              // 产生每一行
    .filter(|line| line.contains(query))  // 只保留命中行
    .collect()                            // 收集成 Vec<&str>

4.5 零成本抽象不等于“不用思考”

Rust 迭代器通常能被优化成接近手写循环的机器码。它避免数组越界检查重复出现,也便于编译器内联闭包。 但仍要关注业务成本:

  • line.to_lowercase() 会为每一行分配新字符串。
  • collect() 会分配结果容器。
  • 链条过长且混入复杂副作用时,可读性可能低于普通循环。

可迁移的判断 ② 先用迭代器表达正确的数据流,再针对已测量出的热点优化分配和算法;不要仅凭“链式调用看起来高级”判断性能。


五、完整实战:可运行、可测试的 linefind

项目结构:

text
linefind/
├── Cargo.toml
└── src/
    ├── lib.rs
    └── main.rs

5.1 Cargo.toml

toml
[package]
name = "linefind"
version = "0.1.0"
edition = "2021"
[dependencies]

5.2 src/lib.rs

rust
use std::env;
use std::error::Error;
use std::fs;
#[derive(Debug, PartialEq, Eq)]
pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}
impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Self, &'static str> {
        args.next();
        let query = args
            .next()
            .ok_or("missing query string")?;
        let file_path = args
            .next()
            .ok_or("missing file path")?;
        let ignore_case = env::var("IGNORE_CASE").is_ok();
        Ok(Self {
            query,
            file_path,
            ignore_case,
        })
    }
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(&config.file_path)?;
    let lines = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };
    for line in lines {
        println!("{line}");
    }
    Ok(())
}
pub fn search_with<'a, F>(
    contents: &'a str,
    predicate: F,
) -> Vec<&'a str>
where
    F: FnMut(&str) -> bool,
{
    contents
        .lines()
        .filter(|line| predicate(line))
        .collect()
}
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    search_with(contents, |line| line.contains(query))
}
pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    search_with(contents, move |line| {
        line.to_lowercase().contains(&query)
    })
}
#[cfg(test)]
mod tests {
    use super::*;
    const CONTENTS: &str = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
    #[test]
    fn parses_owned_arguments() {
        let args = vec![
            "linefind".to_string(),
            "duct".to_string(),
            "poem.txt".to_string(),
        ];
        let config = Config::build(args.into_iter()).unwrap();
        assert_eq!(config.query, "duct");
        assert_eq!(config.file_path, "poem.txt");
    }
    #[test]
    fn case_sensitive_search() {
        assert_eq!(
            search("duct", CONTENTS),
            vec!["safe, fast, productive."]
        );
    }
    #[test]
    fn case_insensitive_search() {
        assert_eq!(
            search_case_insensitive("rUsT", CONTENTS),
            vec!["Rust:", "Trust me."]
        );
    }
    #[test]
    fn custom_predicate_search() {
        let short_lines = search_with(CONTENTS, |line| line.len() <= 6);
        assert_eq!(short_lines, vec!["Rust:"]);
    }
}

5.3 src/main.rs

rust
use std::env;
use std::process;
use linefind::Config;
fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|error| {
        eprintln!("argument error: {error}");
        process::exit(2);
    });
    if let Err(error) = linefind::run(config) {
        eprintln!("application error: {error}");
        process::exit(1);
    }
}

5.4 运行

bash
cargo test
cargo run -- duct poem.txt
IGNORE_CASE=1 cargo run -- rust poem.txt

这个版本的收益不只是少写代码:参数所有权、文件 IO、遍历骨架、匹配策略和单元测试已经各自形成边界。函数式工具在这里服务的是关注点分离,不是代码炫技。


六、常见误区

误区 1:闭包类型推导就是泛型

不是。同一个闭包第一次被推导为某种参数类型后,后续调用必须继续使用该类型。需要泛型行为时,把泛型放在函数或特征约束上。

误区 2:加了 move 就只能调用一次

是否只能调用一次,取决于闭包有没有把捕获值移出去。move || println!("{value}") 仍可能重复调用。

误区 3:map 写完就会执行

迭代器适配器是惰性的。没有 collectsumcountfor_each 等消费者,流水线不会被驱动。

误区 4:into_iter 永远返回引用

不是。它通常消费集合并产生拥有所有权的元素;iter 才产生不可变引用,iter_mut 产生可变引用。

误区 5:迭代器一定比循环清晰

当逻辑包含多次提前退出、复杂错误恢复或大量共享副作用时,普通 formatch 可能更直接。目标是让所有权和控制流清晰,而不是消灭循环。

误区 6:为了消灭 clone,不惜增加生命周期复杂度

配置初始化只执行一次时,少量 clone 往往可以接受。只有当迭代器所有权模型同样清晰时,才值得移除它。


七、练习

练习一:接受任意参数迭代器

Config::build 接收 impl Iterator<Item = String>,并为缺少查询词、缺少路径和多余参数分别设计结果。测试中使用 vec![...].into_iter(),不要依赖真实命令行。

练习二:注入匹配策略

实现 search_with(contents, predicate),其中 predicate 满足 FnMut(&str) -> bool。分别传入包含匹配、前缀匹配和计数型闭包,观察为什么计数闭包需要 FnMut

练习三:比较三种遍历入口

Vec<String> 分别调用 iteriter_mutinto_iter。写出迭代项类型,并验证原集合在每种操作后是否还能使用。

练习四:观察惰性

map 闭包中打印日志,只创建流水线而不消费;随后分别用 collectcountfor_each 启动它。解释为什么适配器本身不执行工作。

练习五:增加上下文输出

linefind 增加 --before N,输出匹配行之前的 N 行。先用清晰循环实现,再评估迭代器版本是否真的更易读;允许最终保留循环。

练习六:固定错误边界

让库函数返回结构化错误,让 main 负责把错误写到 stderr 并设置非零退出码。为不存在文件与参数不足编写集成测试。

八、总结

  • 闭包把局部策略变成值,并能按借用、可变借用或所有权方式捕获环境。
  • FnFnMutFnOnce 描述闭包如何使用捕获值;move 只决定捕获方式,不直接决定可调用次数。
  • Iterator 通过 next 按需产出元素,IntoIterator 描述一个值如何转换成迭代器。
  • iteriter_mutinto_iter 分别对应共享借用、可变借用和通常意义上的所有权消费。
  • 适配器负责描述流水线,消费者负责真正拉取数据;惰性让组合成为可能,也要求开发者清楚执行时机。
  • linefind 的关键设计是把环境副作用、配置拥有权、遍历骨架和匹配策略分开。
  • 迭代器不是循环的强制替代品;当状态机或上下文窗口更清晰时,直接循环依然是好设计。

九、下一章

下一章进入智能指针:从唯一所有权的 Box<T>,走到共享所有权的 Rc<T>、内部可变性的 RefCell<T>,再处理共享树、循环引用与自引用结构。

  • 第8章:智能指针 —— Box、Rc、RefCell 与自引用

  • Rust 深度教程
  • 第6章:工程骨架
  • 第8章:智能指针
  • Claude Code Harness 深度教程 —— 连续教程的结构参考

本章目录
一、把程序拆成数据流与策略二、先把 linefind 拆成清晰的边界三、闭包:把搜索规则变成值四、迭代器:一条由 next 驱动的惰性流水线五、完整实战:可运行、可测试的 linefind六、常见误区七、练习八、总结九、下一章Related Documents
苏ICP备2025204887号-2