第十八章 实战:写一个能跑的 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 把数据搬进线程——第十三章的”所有权消灭数据竞争”。

你发现没有——写这个小工具,你几乎没”新学”任何东西,只是把前面十八章的零件做了一次真实的采购与装配。这就是实战章节的意义:它不教你新语法,它让你已有的语法”长出手感”。

八、给想继续打磨的人的几条建议

骨架能跑,但一个”拿得出手”的工具还能加这些,按性价比排:

  1. 递归扫描目录:用 walkdir crate 替代手动处理目录,顺带串起迭代器。
  2. 正则而非 contains:把 pattern 换成 regex crate 的正则,功能立刻专业。但注意 regex 编译有成本,可缓存 Regex 实例——又是一次”零成本之外你仍要聪明的性能决策”。
  3. 并行用 rayon:把 files.par_iter() 一行替代手写 thread+Arc+Mutex,更安全更简洁。手写并发是为了让你看懂原理,生产请用成熟库。
  4. 测试:给 search_lines 写 #[test],纯函数最容易测,回报最高。
  5. clap 处理参数:比手搓 env::args() 健壮,自动生成 --help。

墨叔的临别一句:别小看这个小 grep。你把它从能跑到好用,走的每一步——拆模块、定错误、串迭代器、上并发——都是真实工程里天天要做的决策。工具小,决策的真味一点不少。

【思考题】

  1. 在 search_lines 里,返回的是 Vec<(usize, &'a str)>,那个 &'a str 是对原文本 text 的切片借用。请推演:如果调用者 search_lines 之后把 text 这个 String 给 drop 了,会发生什么?编译器为什么能拦住这种危险?那个生命周期标注 'a 在这里具体约束了谁和谁的关系?

  2. run 函数里用 fs::read_to_string(path)? 读取整个文件。请推一下:如果文件很大(几个 GB),这个设计会暴露什么问题?从”所有权/内存”角度,有没有更省内存的替代写法(提示:想想逐行读取的迭代器)?这种改动会如何影响 search_lines 的签名?

  3. 并行版本里,我们用 Arc<Mutex<Vec>> 让多个线程把结果汇总到一个 Vec。请分析:这个 Mutex 的锁,保护的到底是哪一段竞争?如果去掉 Mutex、让每个线程各自 push 进同一个没保护的 Vec,Rust 编译器会放行吗?为什么这恰恰体现了”所有权消灭数据竞争”?再推一步:如果改用 rayon 的 par_iter().map().collect(),锁还存在吗,为什么?

【参考答案】

  1. search_lines 返回的切片 &'a str 指向 text 内部的字符数据。如果调用者在持有这些切片的同时 drop 了 text,切片就会变成悬垂引用——指向已释放内存。但 Rust 编译器不会放行这种代码:因为返回类型标注了生命周期 'a,它把”返回的切片的有效期”和”输入 text 的借用有效期”绑成了同一个 'a。编译器据此推理——只要调用者还拿着返回的切片,text 的借用就仍然生效,text 就不可能被 drop(drop 需要独占所有权,而此时还存在活跃借用)。所以 'a 约束的是”返回的 &str 切片”与”输入的 &'a str text”之间的存活关系:切片不能比它所借用的 text 活得更久。这正是第四章生命周期的本质——它不是”存活时间”的长短,而是”两个引用之间谁不能比谁先死”的约束。编译器用这个约束,在编译期就消灭了悬垂。

  2. read_to_string 会一次性把整个文件内容读进一个 String,占用等于文件大小的内存。几个 GB 的文件会直接吃光内存或严重拖慢。从所有权/内存角度,更省内存的写法是逐行流式读取:用 BufReader::new(file).lines() 拿到一个按行产出的迭代器,每行读到时才分配那一行的 String,处理完即可释放,内存峰值只有”一行”的量级。改动影响签名:search_lines 当前接收 &str(整段文本),若改为流式,应改成”接收一个行迭代器”或”接收一个 &mut impl BufRead”,在循环里逐行 search 并即时输出/收集,而不是先攒齐所有内容再一次性 search。这也更贴合零成本抽象——只分配真正需要的那一行,不为”整文件拆成行数组”预付内存。代价是失去”一次性拿到全部匹配”的便利,需边读边处理(通常正是我们想要的)。

  3. 这个 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)一次性串成手感——实战不在学新语法,而在把你已有的零件真正装配成一台会转的机器。