第十八章 实战:写一个能跑的 mini-grep
你学了一路所有权、借用、错误处理、迭代器、模块、甚至并发——这些知识点像一堆散落的零件,摆在桌上闪闪发光,可你从没把它们拧成一台能转的机器。会不会有这种空虚:每个概念单独看都懂,一上手写个”正经东西”就不知道从哪下刀?
我是墨叔,机房里泡了二十年。这一章咱们不学新语法,就干一件事:把前面的零件组装成一把小刀——一个命令行版的迷你 grep(按行搜索文件内容)。它小,但五脏俱全:读参数、读文件、逐行匹配、输出结果、优雅地处理错误,还能(可选)并行扫描多个文件。写完它,你前面学的东西就不再是”考点的集合”,而是”手感”。
先给结论:一个能跑的小工具,价值不在于它多复杂,而在于它逼你把”所有权往哪传、错误从哪冒、迭代器怎么串、模块怎么切”这些决策真正做一遍。 这一章的代码是”骨架”,不是”全集”——我保证它自洽、可懂、能编译,但不求覆盖 grep 的全部功能。
一、我们要造什么:需求先钉死
避免一上来就写代码。先把工具的行为说清楚,这本身就是工程素养:
- 用法:
mugrep <模式> <文件1> [文件2 ...] - 行为:对每个文件,逐行读,凡是包含”模式”的行,就打印出来,格式
文件名:行号:内容。 - 错误处理:文件打不开、读写出错,要给用户友好提示,而不是 panic 崩溃。
- 可选增强:多个文件时,用多线程并行扫描,充分利用多核。
你看,这四个点,恰好对应前面几章的主角:参数与字符串——所有权;读文件出错——Result;逐行匹配——迭代器;并行扫描——Arc/Mutex/线程。这不是我硬凑,是一个真实小工具天然会碰到的关切。
二、模块怎么切:为什么不是一坨 main
新手最爱把一切写进 main。能跑,但难读、难测、难复用。墨叔的切法,按”职责”分三个模块,各自管一摊:
error.rs:定义我们自己的错误类型,把”文件找不到""读写出错""参数不对”统一成一种可处理的错误。这对应第七章的Result与错误类型设计。search.rs:纯逻辑——给定一个文本和一个模式,返回哪些行匹配。它不碰 IO、不碰参数,输入是字符串,输出是结果。这种”纯函数化”的拆法,让核心逻辑可以单独测试。main.rs:负责”脏活”——解析命令行参数、打开文件、调用 search、打印、处理并发。它是胶水。
为什么这么切?因为变化的方向不同。IO 和参数解析经常随需求改;核心匹配算法相对稳定。把它们隔开,改一处不会波及另一处。这是模块化的本能,不是炫技。你如果写过别的语言,这里的”心智要重装”在于:Rust 没有”类层级”逼你套框架,模块的边界完全由你按依赖方向画——依赖只能单向(main 依赖 search,search 不反向依赖 main),画错了编译器会用”循环依赖”教训你。
三、错误类型:把混乱收成一个 Result
先写 error.rs。真实程序里错误来源五花八门:std::io::Error、参数解析的错误、甚至是我们自己定义的”没给文件”错误。如果每个函数返回各自的错误类型,main 里就得写一堆 match 去分辨,丑且易漏。
Rust 的惯用法是定义一个统一的错误枚举,并给它实现 From,让 ? 能自动把各种错误转成我们的类型:
// error.rs
use std::fmt;
// 我们自己的错误类型,覆盖三种情况
#[derive(Debug)]
pub enum AppError {
Io(std::io::Error), // 文件读写出错
EmptyArgs, // 命令行参数不够
NoMatch, // 没匹配到(不算致命,但单独标记)
}
// 让 `?` 能直接把 io::Error 转成 AppError::Io
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
// 自定义一个友好的显示
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO 错误:{}", e),
AppError::EmptyArgs => write!(f, "用法:mugrep <模式> <文件...>"),
AppError::NoMatch => write!(f, "没有匹配到任何行"),
}
}
}
// 让它能当 std 错误用(便于将来接任何how/anyhow类库)
impl std::error::Error for AppError {}注意那个 impl From<std::io::Error>——这是关键。有了它,在返回 Result<_, AppError> 的函数里,凡是遇到 io::Error,你写 ? 就能自动转换,不用手动 map_err。这就是第七章讲的”错误类型设计”落地的样子:? 不是魔法,是 From trait 在背后替你搬砖。
四、核心搜索:迭代器串起来的优雅
现在 search.rs,纯逻辑。给定一段文件文本和模式,找出匹配的行及其行号。这里迭代器是主角:
// search.rs
// 给定文本和模式,返回 (行号, 行内容) 的匹配项
// 行号从 1 开始,符合人类习惯
pub fn search_lines<'a>(text: &'a str, pattern: &str) -> Vec<(usize, &'a str)> {
text.lines() // 按行切,返回迭代器,不分配中间集合
.enumerate() // 给每行带上索引 (0, 1, 2...)
.filter(|(_i, line)| line.contains(pattern)) // 只留包含模式的行
.map(|(i, line)| (i + 1, line)) // 行号 +1 变人类习惯
.collect() // 收集成 Vec
}逐行拆给你看:
text.lines()返回一个惰性迭代器,它不会立刻把所有行复制出来,而是你next一次它才切一行。零成本抽象的活教材——你享受”逐行”的便利,却不为”先把整文件拆成行数组”付分配税(直到collect)。enumerate()给每行配上行号。它的输出是(usize, &str),那个&str是对原文本的切片(borrow),没有拷贝字符串内容——这正是所有权/借用在”只读共享”场景的优雅:行内容仍归原text所有,我们只借来看。filter用闭包判断line.contains(pattern)。闭包捕获了pattern(一个&str),这里发生了”闭包借用环境变量”——第十一章的内容活了。- 最后
collect()才真正把结果物化成一个Vec。前面的所有步骤都是”流水线”,collect是那个”按下出水口”的动作。
你看,这一小段把**迭代器(第十一章)、借用(第三章)、闭包(第十一章)、生命周期(第四章,那个 'a 标明返回切片活不过 text)**全串起来了。这就是”零件组装”的爽感:单独看每样都小,组合起来就解决了一个真实子问题。
五、main:把脏活和并发接上
main.rs 负责读参数、读文件、调 search、打印。先写单文件、单线程、清晰的版本,把错误用 Result 一路向上抛:
// main.rs
mod error;
mod search;
use error::AppError;
use std::fs;
fn run(pattern: &str, path: &str) -> Result<usize, AppError> {
// 读整个文件到内存(小文件场景够用)
let content = fs::read_to_string(path)?; // ? 自动把 io::Error 转成 AppError
let matches = search::search_lines(&content, pattern);
for (lineno, line) in matches {
println!("{}:{}:{}", path, lineno, line);
}
Ok(matches.len())
}
fn main() {
// 取命令行参数:第0个是程序名,所以要跳过
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("用法:mugrep <模式> <文件...>");
std::process::exit(1);
}
let pattern = &args[1];
let files = &args[2..];
let mut total = 0;
for file in files {
match run(pattern, file) {
Ok(n) => total += n,
Err(e) => eprintln!("处理 {} 时出错:{}", file, e), // 单个文件出错不中断其余
}
}
println!("共匹配 {} 行", total);
}几个设计取舍点,墨叔点明:
run返回Result<usize, AppError>,把”读文件可能失败”这件事显式暴露在类型里。调用处用match处理:单个文件出错只打印错误、继续处理下一个文件——错误被”局部消化”,而不是让整个程序 panic。这就是第七章说的”panic 留给真正不可恢复的程序员错误,可预期的错误用 Result 交回调用者”。fs::read_to_string用?:因为AppError实现了From<io::Error>,这里一个?就完成了错误类型转换,干净。pattern是&args[1]的借用:我们没clone参数,而是借用了args里的字符串。因为args在整个main存活期都在,借用它安全。这是所有权”能借不克隆”的务实体现。
六、可选并发:多个文件,多线程扫
当文件很多时,一个一个扫是浪费多核。我们加一个并行版本——这里 Arc/Mutex(第十三章)就派上用场了。思路:多个线程各扫一个文件,把各自的匹配结果汇总到一个共享的、线程安全的收集器里。
// main.rs 中新增的并行版本(示意骨架)
use std::sync::{Arc, Mutex};
use std::thread;
fn run_parallel(pattern: &str, files: &[String]) {
// Arc 让多个线程共享同一份 pattern 字符串(只读,安全)
// Mutex 保护"汇总结果"这个可变状态,杜绝数据竞争
let results = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::new();
for file in files {
let results = Arc::clone(&results); // 共享同一把锁的克隆(只增引用计数)
let file = file.clone(); // 文件名移到线程里(所有权转移)
let pat = pattern.to_string(); // 模式也移进去(或改用 Arc<str>)
let h = thread::spawn(move || {
if let Ok(content) = fs::read_to_string(&file) {
let matches = search::search_lines(&content, &pat);
let mut guard = results.lock().unwrap(); // 加锁,独占写入
for (n, line) in matches {
guard.push(format!("{}:{}:{}", file, n, line));
}
}
});
handles.push(h);
}
for h in handles {
let _ = h.join(); // 等所有线程结束
}
// 锁内收集完后,这里统一打印
let guard = results.lock().unwrap();
for line in guard.iter() {
println!("{}", line);
}
}这段把第十三章的并发模型落了地,几个真实取舍:
Arc为什么在这:pattern是只读的、要被多个线程同时读。多个线程”共享只读”是安全的,但线程的闭包要求”拥有”自己用到的数据,于是用Arc做”引用计数的共享所有权”——多个线程各持一份Arc克隆,底层字符串只有一份,引用计数管理生命周期。这比”每个线程 clone 一份 pattern 字符串”省内存。Mutex锁的是什么:是”汇总用的Vec”这个可变共享状态。多个线程都要往里push,若不加锁就是数据竞争——Rust 的类型系统会直接拒绝你”在没锁的情况下”碰它。lock().unwrap()拿到的是一个智能守卫(guard),它实现了DerefMut,离开作用域自动解锁。锁的粒度只在”push 那几行”,尽量小,避免线程互相等。move闭包 + 所有权:每个线程闭包用move把file、pat、以及Arc克隆搬进线程。这是 Rust 并发的硬规则——线程可能比当前栈活得久,所以必须拿到数据的所有权,不能借当前栈上的引用。你前面学 move 时嫌”传参还得还”麻烦,到这儿它直接变成”线程安全的前提”,闭环了。- 为什么是”可选”:单文件时并行纯属 overhead(起线程比读文件还贵),所以真实工具会按文件数/文件大小决定走串行还是并行。这里为教学把它单列出来,让你看到”前面学的并发零件,往真实工具里一插就能用”。
七、这个项目里,前面的知识都在哪
墨叔给你画一张”知识落点”地图,方便你回看:
- 所有权:
pattern借用而非 clone、file用move进线程、闭包捕获pattern——处处是”这块内存归谁、活多久”的决策。 - 错误处理:
AppError统一错误、?自动转换、match局部消化单文件失败——第七章的Result设计。 - 迭代器:
lines().enumerate().filter().map().collect()——第十一章的惰性流水线,零成本。 - 模块:
error/search/main三分天下,依赖单向,各自可测。 - 并发(可选):
Arc共享只读、Mutex保护可变汇总、move把数据搬进线程——第十三章的”所有权消灭数据竞争”。
你发现没有——写这个小工具,你几乎没”新学”任何东西,只是把前面十八章的零件做了一次真实的采购与装配。这就是实战章节的意义:它不教你新语法,它让你已有的语法”长出手感”。
八、给想继续打磨的人的几条建议
骨架能跑,但一个”拿得出手”的工具还能加这些,按性价比排:
- 递归扫描目录:用
walkdircrate 替代手动处理目录,顺带串起迭代器。 - 正则而非
contains:把pattern换成regexcrate 的正则,功能立刻专业。但注意 regex 编译有成本,可缓存Regex实例——又是一次”零成本之外你仍要聪明的性能决策”。 - 并行用
rayon:把files.par_iter()一行替代手写thread+Arc+Mutex,更安全更简洁。手写并发是为了让你看懂原理,生产请用成熟库。 - 测试:给
search_lines写#[test],纯函数最容易测,回报最高。 clap处理参数:比手搓env::args()健壮,自动生成--help。
墨叔的临别一句:别小看这个小 grep。你把它从能跑到好用,走的每一步——拆模块、定错误、串迭代器、上并发——都是真实工程里天天要做的决策。工具小,决策的真味一点不少。
【思考题】
-
在
search_lines里,返回的是Vec<(usize, &'a str)>,那个&'a str是对原文本text的切片借用。请推演:如果调用者search_lines之后把text这个String给drop了,会发生什么?编译器为什么能拦住这种危险?那个生命周期标注'a在这里具体约束了谁和谁的关系? -
run函数里用fs::read_to_string(path)?读取整个文件。请推一下:如果文件很大(几个 GB),这个设计会暴露什么问题?从”所有权/内存”角度,有没有更省内存的替代写法(提示:想想逐行读取的迭代器)?这种改动会如何影响search_lines的签名? -
并行版本里,我们用
Arc<Mutex<Vec>>让多个线程把结果汇总到一个Vec。请分析:这个Mutex的锁,保护的到底是哪一段竞争?如果去掉Mutex、让每个线程各自push进同一个没保护的Vec,Rust 编译器会放行吗?为什么这恰恰体现了”所有权消灭数据竞争”?再推一步:如果改用rayon的par_iter().map().collect(),锁还存在吗,为什么?
【参考答案】
-
search_lines返回的切片&'a str指向text内部的字符数据。如果调用者在持有这些切片的同时drop了text,切片就会变成悬垂引用——指向已释放内存。但 Rust 编译器不会放行这种代码:因为返回类型标注了生命周期'a,它把”返回的切片的有效期”和”输入text的借用有效期”绑成了同一个'a。编译器据此推理——只要调用者还拿着返回的切片,text的借用就仍然生效,text就不可能被 drop(drop 需要独占所有权,而此时还存在活跃借用)。所以'a约束的是”返回的&str切片”与”输入的&'a str text”之间的存活关系:切片不能比它所借用的text活得更久。这正是第四章生命周期的本质——它不是”存活时间”的长短,而是”两个引用之间谁不能比谁先死”的约束。编译器用这个约束,在编译期就消灭了悬垂。 -
read_to_string会一次性把整个文件内容读进一个String,占用等于文件大小的内存。几个 GB 的文件会直接吃光内存或严重拖慢。从所有权/内存角度,更省内存的写法是逐行流式读取:用BufReader::new(file).lines()拿到一个按行产出的迭代器,每行读到时才分配那一行的String,处理完即可释放,内存峰值只有”一行”的量级。改动影响签名:search_lines当前接收&str(整段文本),若改为流式,应改成”接收一个行迭代器”或”接收一个&mut impl BufRead”,在循环里逐行search并即时输出/收集,而不是先攒齐所有内容再一次性search。这也更贴合零成本抽象——只分配真正需要的那一行,不为”整文件拆成行数组”预付内存。代价是失去”一次性拿到全部匹配”的便利,需边读边处理(通常正是我们想要的)。 -
这个
Mutex保护的是”对共享Vec的并发写入”这一竞争:多个线程同时push会破坏Vec内部结构(长度字段、底层缓冲),是经典数据竞争。Arc只解决”多个线程共享同一份数据、管理生命周期”,不解决”同时改”;Mutex才解决”互斥写入”。如果去掉Mutex、让线程直接push同一个裸Vec,Rust 编译器会直接拒绝编译——因为Vec不是Sync的”可变共享”用法,要把它跨线程共享可变,必须经过Mutex(或RwLock)这类同步原语来获得Sync实现。编译器在类型层面就堵死了”裸共享可变”,这正是”所有权消灭数据竞争”的落地:你根本没法写出那种竞争的代码,不是运行时靠运气不出错,而是编译期类型系统不允许。改用rayon的par_iter().map().collect()时,锁没有了——因为rayon的map阶段每个线程处理自己的元素、互不共享可变状态,collect由 rayon 内部用无锁/分治方式安全合并结果,把”共享可变”重构成了”各自计算、最后归并”,从设计上消除了锁的需求。这也是为什么生产更推荐 rayon:它用”无共享”的结构,天然绕开了锁。
小结
一个能跑的 mini-grep,把所有权(借用与 move)、错误处理(Result 与 ?)、迭代器(惰性流水线)、模块化(职责切分)乃至可选并发(Arc/Mutex/move)一次性串成手感——实战不在学新语法,而在把你已有的零件真正装配成一台会转的机器。