前言
大家好,我是小西。 复杂的程序也是从简单开始的,基础不牢,地动山摇。这是我的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}");
}
二、读取文件
- 先准备一个文件 命名为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函数中 存在多种任务,而且在缺少参数时,我们也没有做错误处理来提示用户。现在这个程序代码还比较少,我们按一个函数只处理一件事情的逻辑来优化一下
三、重构与拆分
先来分析一下问题
- main 函数现在做了两件事件,参数解析和读取文件
- 明显参数file_path 和 query 是程序的配置,最好是把配置聚合到一个结构中
- 文件读取的报错不够清晰,我们只是用expect()输出了一句话
- 错误的处理最好是在一个地方
经过改造后的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 个问题
重点看看错误处理:
- 20行
Box<dyn Error>
这里是一个 trait object,代表函数会返回实现了Error trait 的动态的类型,这里并不知道具体是什么类型,只是给错误的返回值加了一个限制 - 21行中 ? 这个用于抑制Result 上的错误,并把错误返回给函数的调用者,有向外传递错误的作用
- 我们于不同的Result采用了不同的处理方式,对于Config:build 返回的Result 我们既关注他的结果,也关注他的错误,所以我们使用.unwrap_or_else 配合闭包函数来处理。对于
run()
返回的Result 我们其实只需要处理错误即可,他在正确时只返回一个()空的元组,这时可以使用if let
语法,如果解构出错误消息就打印出来,并使用process::exit(1)
退出程序
关于第2点的效果,可以看这个截图:
四、测试驱动开发TDD
先说说测试驱动开发的流程:
- 先编写一个会失败单元测试,但你要知道失败的原因
- 修改你的代码,让这个测试可以刚好通过
- 重构一下代码,让这个测试还是可以正常通过
- 重复步骤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 --
测试一下,这里我就不贴了,注意测试一下正侧和反侧,怕文章就太长了,接下来我们还要做两个优化
- 通过环境变量, 来支持大小写不敏感的搜索
- 把错误信息打印到标准和错误流
五、最后的优化
我们先还是按照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);
}
}
总结
首先感谢你读到这里, 希望你能通过这个小小的教程学到以下几个知识点,排序:重要的靠前
- 使用结构体进行封装,回顾一下
Config
的变化过程 - 使用
?
和Result
枚举来处理错误,比如unwrap_or_else
和if let
语法 - 使用 lib.rs 和 main.rs 来组织代码,记得
pub
和mod {}
- 编写单元测试的方法
assert_eq!
- TDD的基本流程
- 生命周期标注
'a
,算了,估计还理解不了
最后:我是小西,如果觉得写的还不错,可以给我点个赞,也可以关注我的公众号:前端学习Rust 我们下次更新见~