第7章:函数式 Rust —— 闭包、迭代器与命令行实战
约 9 分钟 · 更新于 2026-09-01
第7章:函数式 Rust —— 闭包、迭代器与命令行实战
本章用命令行工具 linefind 串起闭包与迭代器。它读取文件、按规则筛选行并输出结果;随着重构推进,匹配策略会从固定分支变成可传递的行为,数据处理会从外部可变容器变成惰性流水线。
观察重点
不要只比较“循环写法”和“链式写法”谁更短。真正要观察的是:谁拥有参数,闭包捕获了什么,迭代器何时消费输入,测试能否绕开文件系统和环境变量。
一、把程序拆成数据流与策略
Rust 允许可变状态、循环和显式资源管理,因此这里的“函数式”不是要求纯函数,而是强调两种拆分:
- 数据流:参数 → 配置 → 文件内容 → 行 → 输出;
- 策略:一行文本是否匹配、是否忽略大小写、匹配后怎样转换。
linefind 的演进会遵循三个步骤:
- 把环境读取与核心搜索逻辑分离;
- 把匹配条件抽成闭包或泛型参数;
- 用迭代器表达“按需拉取”的处理流水线。
这样做的收益不是炫技,而是缩短状态存活时间、明确所有权流向,并让核心逻辑可以直接接收字符串进行测试。
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 决定闭包如何捕获变量;Fn、FnMut、FnOnce 取决于闭包如何使用捕获到的变量。一个 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:能够被转换成迭代器,因此能放进 for。
for 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]);
常见分类:
| 类别 | 方法 | 返回什么 |
|---|
| 迭代器适配器 | map、filter、take、skip、zip | 新迭代器 |
| 消费者 | collect、sum、count、fold、for_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 写完就会执行
迭代器适配器是惰性的。没有 collect、sum、count、for_each 等消费者,流水线不会被驱动。
误区 4:into_iter 永远返回引用
不是。它通常消费集合并产生拥有所有权的元素;iter 才产生不可变引用,iter_mut 产生可变引用。
误区 5:迭代器一定比循环清晰
当逻辑包含多次提前退出、复杂错误恢复或大量共享副作用时,普通 for、match 可能更直接。目标是让所有权和控制流清晰,而不是消灭循环。
误区 6:为了消灭 clone,不惜增加生命周期复杂度
配置初始化只执行一次时,少量 clone 往往可以接受。只有当迭代器所有权模型同样清晰时,才值得移除它。
七、练习
练习一:接受任意参数迭代器
让 Config::build 接收 impl Iterator<Item = String>,并为缺少查询词、缺少路径和多余参数分别设计结果。测试中使用 vec![...].into_iter(),不要依赖真实命令行。
练习二:注入匹配策略
实现 search_with(contents, predicate),其中 predicate 满足 FnMut(&str) -> bool。分别传入包含匹配、前缀匹配和计数型闭包,观察为什么计数闭包需要 FnMut。
练习三:比较三种遍历入口
对 Vec<String> 分别调用 iter、iter_mut、into_iter。写出迭代项类型,并验证原集合在每种操作后是否还能使用。
练习四:观察惰性
在 map 闭包中打印日志,只创建流水线而不消费;随后分别用 collect、count 和 for_each 启动它。解释为什么适配器本身不执行工作。
练习五:增加上下文输出
给 linefind 增加 --before N,输出匹配行之前的 N 行。先用清晰循环实现,再评估迭代器版本是否真的更易读;允许最终保留循环。
练习六:固定错误边界
让库函数返回结构化错误,让 main 负责把错误写到 stderr 并设置非零退出码。为不存在文件与参数不足编写集成测试。
八、总结
- 闭包把局部策略变成值,并能按借用、可变借用或所有权方式捕获环境。
- Fn、FnMut、FnOnce 描述闭包如何使用捕获值;move 只决定捕获方式,不直接决定可调用次数。
- Iterator 通过 next 按需产出元素,IntoIterator 描述一个值如何转换成迭代器。
- iter、iter_mut 与 into_iter 分别对应共享借用、可变借用和通常意义上的所有权消费。
- 适配器负责描述流水线,消费者负责真正拉取数据;惰性让组合成为可能,也要求开发者清楚执行时机。
- linefind 的关键设计是把环境副作用、配置拥有权、遍历骨架和匹配策略分开。
- 迭代器不是循环的强制替代品;当状态机或上下文窗口更清晰时,直接循环依然是好设计。
九、下一章
下一章进入智能指针:从唯一所有权的 Box<T>,走到共享所有权的 Rc<T>、内部可变性的 RefCell<T>,再处理共享树、循环引用与自引用结构。
- 第8章:智能指针 —— Box、Rc、RefCell 与自引用
- Rust 深度教程
- 第6章:工程骨架
- 第8章:智能指针
- Claude Code Harness 深度教程 —— 连续教程的结构参考