【Rust入门篇】5步带你实现 minigrep 小实例

前言

大家好,我是小西。 复杂的程序也是从简单开始的,基础不牢,地动山摇。这是我的Rust 入门篇的第7篇文章。今天我们讲一个小案例, 这也是官方入门教程中的一章,使用rust 实现一个grep工具,grep 是Linux 上的一个文件内容查找工具(g lobally search a r egular e xpression and p rint),通过这次的练习你将会学到以下的知识:大量代码预警|>>|

  • 使用包组织代码
  • 使用Vec动态数组 和 String 类型
  • 错误处理
  • 使用trait 和生命周期
  • 编写单元测试

一、接收命令行参数

先用cargo 创建一个项目, 切换到项目目录,再尝试运行一下

bash 复制代码
cargo new minigrep
cd minigrep
cargo run -- searchstring filename.txt

注意这里的双-- ,代表后面的这两个参数是传给你的程序,而不是cargo 这个命令 我们写修改一下main.rs文件

rust 复制代码
use std::env;
fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}
// output 
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

使用dbg 打印后,我们可以看到 args 是一个数组第一个元素为minigrep程序本身

现在我们去掉dbg!换成两个变量来保存 query 和file_path参数

rust 复制代码
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}

二、读取文件

  1. 先准备一个文件 命名为poem.txt
txt 复制代码
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

接着我们使用标准库中方法来读取文件

rust 复制代码
use std::env;
use std::fs;

fn main() {
    // --snip--
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

这到里我们已经完成了接收参数读取文件两个任务,看起来好像没什么难度,不过main函数中 存在多种任务,而且在缺少参数时,我们也没有做错误处理来提示用户。现在这个程序代码还比较少,我们按一个函数只处理一件事情的逻辑来优化一下

三、重构与拆分

先来分析一下问题

  1. main 函数现在做了两件事件,参数解析和读取文件
  2. 明显参数file_path 和 query 是程序的配置,最好是把配置聚合到一个结构中
  3. 文件读取的报错不够清晰,我们只是用expect()输出了一句话
  4. 错误的处理最好是在一个地方

经过改造后的main.rs 代码如下

rust 复制代码
use std::env;
use std::fs;
use std::process;
use std::error::Error;

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("no enough arguments")
        }
        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}


fn main() {
    let args:Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }

}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)
    .expect("Fail to read file ");

    println!("With text:{contents}");

    Ok(())
}

我们把 query 和 file_path 放到Config 结构体中,并为结构体添加关联方法 build。 因为build 不一定会成功,所以我们返回Result 枚举, 在参数不足时给出错误。 &'static str 是一个带生命同期的类型标注,代表Err变体中的具体类型,unwrap_or_else 用于处理Result,并在失败时调用我们给出 闭包函数,并传递捕获的错误。

接下来,我们开始拆分文件,把main.rs 中只保留run 的调用逻辑
文件:main.rs

rust 复制代码
use std::env;
use std::process;
use minigrep::Config;

fn main() {
    let args:Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }

}

文件: lib.rs

记得给结构体的属性也加上pub, 不需要写任何的导出语句比如 export

rust 复制代码
use std::error::Error;
use std::fs;
pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("no enough arguments")
        }
        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:{contents}");

    Ok(())
}

注意回顾一下这里的代码是不是解决了前面提到的4 个问题

重点看看错误处理:

  1. 20行 Box<dyn Error> 这里是一个 trait object,代表函数会返回实现了Error trait 的动态的类型,这里并不知道具体是什么类型,只是给错误的返回值加了一个限制
  2. 21行中 ? 这个用于抑制Result 上的错误,并把错误返回给函数的调用者,有向外传递错误的作用
  3. 我们于不同的Result采用了不同的处理方式,对于Config:build 返回的Result 我们既关注他的结果,也关注他的错误,所以我们使用.unwrap_or_else 配合闭包函数来处理。对于run() 返回的Result 我们其实只需要处理错误即可,他在正确时只返回一个()空的元组,这时可以使用 if let语法,如果解构出错误消息就打印出来,并使用process::exit(1)退出程序

关于第2点的效果,可以看这个截图:

四、测试驱动开发TDD

先说说测试驱动开发的流程:

  1. 先编写一个会失败单元测试,但你要知道失败的原因
  2. 修改你的代码,让这个测试可以刚好通过
  3. 重构一下代码,让这个测试还是可以正常通过
  4. 重复步骤1

在lib.rs 文件中添加一个search函数 和 单元测试代码,再运行 cargo test

rust 复制代码
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fase, productive."], search(query, contents))
    }
}

现在search 函数中还没有代码,所以测试一定会失败,我们需要完善一下 search 方法,让他返回一个动态str数组 这里要引入两个方法,一是lines() 二是 contains(query), 前者用来给文本分行,后者类似于js 中的includes 方法。新的search 函数是这样的。

rust 复制代码
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

注意这里的 'a 他的使用和泛型有点像,他表示返回值中 str, 和入参中的contents 拥有一样的生命周期,再次运行一下 cargo test 发现测试通过了

记得在 run()函数中调用一下 search(), 并打印一下结果

rust 复制代码
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

再运行 cargo run -- 测试一下,这里我就不贴了,注意测试一下正侧和反侧,怕文章就太长了,接下来我们还要做两个优化

  1. 通过环境变量, 来支持大小写不敏感的搜索
  2. 把错误信息打印到标准和错误流

五、最后的优化

我们先还是按照TDD 的规则写先两个单测函数

rust 复制代码
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

search_case_insensitive 也比较好实现,就是同时转小写再进行搜索,代码如下:

rust 复制代码
pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

大小写敏感和不敏感的函数我们都准备好了,接下来就要修改run函数 ,让他在不同的情况下调用不同的函数 ,考虑到封装性,我们给 Config加一个 ignore_case 的bool字段 代码如下:

rust 复制代码
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

现在需要在 Config::build的方法中,给ignore_case赋上正确的值

rust 复制代码
use std::env;
// --snip--

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

现在你就可以在命令行中测试一下了,针对 windows 和 Mac 有不两同的命令,请自取

bash 复制代码
// Mac or Linux
$ IGNORE_CASE=1 cargo run -- to poem.txt
// Windows PowerShell
PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt
// 移除环境变量
PS> Remove-Item Env:IGNORE_CASE

很多命令行工具,可以支持使用 命令行参数来覆盖环境变量,你可以自己练习一下 到目前为止,程序的功能已经到实现了,还有一个小瑕疵,就是报错的消息没有进入标准和错误流,这个对于打日志还蛮重要的,你可以用下面的命令,来看来错误消息被输出到标准流了

bash 复制代码
$ cargo run -- to poem.txt > output.txt

我们只要在打印错误的地方使用 eprintln!(),就可以了,代码如下

rust 复制代码
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

总结

首先感谢你读到这里, 希望你能通过这个小小的教程学到以下几个知识点,排序:重要的靠前

  1. 使用结构体进行封装,回顾一下 Config 的变化过程
  2. 使用?Result 枚举来处理错误,比如unwrap_or_elseif let 语法
  3. 使用 lib.rsmain.rs 来组织代码,记得 pubmod {}
  4. 编写单元测试的方法 assert_eq!
  5. TDD的基本流程
  6. 生命周期标注'a,算了,估计还理解不了

最后:我是小西,如果觉得写的还不错,可以给我点个赞,也可以关注我的公众号:前端学习Rust 我们下次更新见~

相关推荐
无名之逆10 分钟前
Hyperlane 文件分块上传服务端
服务器·开发语言·前端·网络·http·rust·php
yezipi耶不耶1 小时前
Rust入门之迭代器(Iterators)
开发语言·后端·rust
唐青枫4 小时前
Rust cargo 命令行工具使用教程
rust
x-cmd12 小时前
[250411] Meta 发布 Llama 4 系列 AI 模型 | Rust 1.86 引入重大语言特性
人工智能·rust·llama
pumpkin8451412 小时前
Rust 是如何层层防错的
开发语言·rust
无名之逆13 小时前
[特殊字符] Hyperlane:为现代Web服务打造的高性能Rust文件上传解决方案
服务器·开发语言·前端·网络·后端·http·rust
s91236010119 小时前
Rust Command无法执行*拓展解决办法
开发语言·后端·rust
机构师20 小时前
<rust><iced><GUI>iced中的按钮部件:button
后端·rust
土豆12501 天前
Rust 多线程编程核心精要
rust
卡仔1 天前
Rust闭包中的Fn与FnOnce陷阱:为何多克隆一次Arc能解决问题?
rust