一、接收命令行参数
创建minigrep项目
cargo new minigrep
实现这一工具的首要任务是让minigrep接收两个命令行参数:文件名和用于搜索的字符串。因此,我们希望通过如下方式:
cargo run searchstring example-filename.txt
1、读取参数值
为了使minigrep可以读取传递给它的命令行参数值,我们需要使用Rust标准库提供的std::env::args
函数。这个函数会返回一个传递给minigrep的命令行参数迭代器。
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("{:?}", args);
}
首先,使用use
语句将std::env
模块引入当前作用域,以便我们调用其中的args函数。
注意,std::env::args
函数被嵌套用于两层模块内。当所需函数被嵌套于不止一层模块时,我们通常之将其父模块引入作用域,而不将函数本身引入,这便于我们使用std::env
模块中的其他函数,另外放防止直接调用args
函数的做法引起歧义。
注意,std::env::args函数会因为命令行参数中包含了非法的Unicode字符而发生panic。如果你确定需要在程序中接收包含非法Unicode字符的参数,那么请使用std::env::args_os函数,这个函数会返回一个产生OsString值的迭代器。
我们在main
函数的第一行调用了env::args
并立刻使用了collect
函数将迭代器转换成一个包含所有迭代器产生值的动态数组。由于collect
函数可以被用来创建多种不同的结合,所以我们显式地标注了args
的类型来获得一个包含字符串的动态数组。因为Rust无法推断出想要的具体集合类型,所以我们需要为collect
函数进行手动标注。
2、将参数值存入变量
将动态数组打印出来表明当前程序能够获取命令行参数指定的值。
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("{:?}", query);
println!("{:?}", filename);
}
二、读取文件
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let query = &args[1];
let filename = &args[2];
println!("Searching for {}", query);
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong reading the file.");
println!("With text:\n{}", contents);
}
新增use std::fs
模块,用来处理与文件相关的事务。
随后,我们在main中新增了一条语句:其中fs::read_to_string
函数接收filename作为参数,它会打开对应文件并使用Result<String>
类型返回文件的内容。
最后,通过临时println!
语句,在读取文件后打印出contents
变量中的值。
三、重构以改进模块化与错误处理
1、二进制项目的关注点分离
将二进制程序进行关注点分离的指导性原则:
- 将程序拆分为main.rs和lib.rs,并将实际的业务逻辑放入lib.rs。
- 当命令行解析逻辑相对简单时,将它留在main.rs中也无妨。
- 当命令行解析逻辑开始变得复杂时,同样需要将它从main.rs提取至lib.rs中。
经过这样拆分之后,保留在main函数中的功能应当只有: - 调用命令行解析的代码处理参数值;
- 准备所有其他的配置;
- 调用lib.rs中的run函数;
- 处理run函数可能出现的错误;
这种模式关注点分离思想的体现:main.rs负责运行程序,而lib.rs则负责处理所有真正的业务逻辑。
1.提取解析参数的代码
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
let (query, filename) = parse_config(&args);
println!("Searching for {}", query);
println!("In file {}", filename);
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let filename = &args[2];
(query, filename)
}
这段段代码依然将所有的命令行参数收集到了一个动态数组中,但不同于在main函数中将索引为1的参数赋值给query、将索引为2的参数赋值给filename,这里直接将整个动态数组都传递给了parse_config函数。接着,再由parse_config函数中的逻辑来决定将哪个参数赋值给哪个变量,并将结果传给main函数。
2.组合配置值
目前parse_config
含税返回了一个元组,但我们在使用时又立即将元组拆分为了独立的变量,这种说明当前程序中建立的抽象也许是不对的;另外parse_config
名称中的config部分,它暗示我们返回的两个值是彼此相关的,并且都是配置值的一部分,单纯地将这两个值放置在元组中并不足以表达出这些意义。
注意:在使用复杂类型更合适时偏偏使用基本类型,是一种叫做基本类型偏执的反模式。
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong reading the file.");
println!("With text:\n{}", contents);
}
struct Config {
query: String,
filename: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config { query, filename }
}
这个代码新增了query
和filename
字段地结构体Config
。函数parse_config的签名意味着它现在会发返回一个Config类型的值。在parse_config的函数体内,我们之前返回的是指向args中的String值的字符切片,但是现在我们定义的Config却包含了拥有自身所有权的String值。这是因为main函数中的args变量是程序参数值的所有者,而parse_config函数只是借用了这个值,如果Config试图在运行过程中夺取args中某个值的所有权,那么就会违反Rust的借用规则。
3.为Config创建一个构造器
parse_config函数的功能正是创建一个新的Config实例,把parse_config从一个普通函数改写成一个与Config结构体相关联的new函数。
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong reading the file.");
println!("With text:\n{}", contents);
}
struct Config {
query: String,
filename: String,
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let filename = args[2].clone();
Config { query, filename }
}
}
这段代码将main函数中调用parse_config
的地方改为了调用Config::new
函数的名字则被改写为了new
,并关联到了Config
的impl
块中。
2、修复错误处理逻辑
1.改进错误提示信息
当动态数组args
的元素不足3个时使用索引1和索引2来访问其中的值,就会导致代码产生panic
。
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong reading the file.");
println!("With text:\n{}", contents);
}
struct Config {
query: String,
filename: String,
}
impl Config {
fn new(args: &[String]) -> Config {
//新增代码
if args.len() < 3 {
panic!("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Config { query, filename }
}
}
在本例中,我们检查的不再是数值的范围而是args
的长度是否小于3,从而使函数剩余可以在满足该条件的基础上继续运行。假设args
中的元素数量不足3,那么条件为真,则会运行panic!
立刻终止程序。
2.从new中返回Result而不是调用panic!
我们可以返回一个Result值,它会在成功的情况下包含Config实例,并在失败的情况下携带具体的问题描述。使用这种方法可以避免调用panic!
时在错误提示信息前后产生一些内容信息。
//这段代码无法编译成功
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong reading the file.");
println!("With text:\n{}", contents);
}
struct Config {
query: String,
filename: String,
}
impl Config {
//代码修改位置
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
现在new
函数会返回Result,它在运行成功时带有一个Config
实例,而在运行失败时带有一个&'static str
。Config::new
在运行失败时返回的Err值使main函数可以对Result值做进一步处理,以便它能够在出错时更加干净地退出进程。
3.调用Config::new并处理错误
为了处理错误情形并打印出用户友好的信息,我们需要修改main函数来处理Config::new
返回的Result值。另外,我们还需要取代之前由panic!
实现的推出命令行工具并返回一个非0错误代码的功能。程序在退出时向调用者(父进程)返回非0的状态码是一种惯用的信号,它便是当前程序的退出是由某种错误状态导致的。
use std::env;
use std::fs;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong reading the file.");
println!("With text:\n{}", contents);
}
struct Config {
query: String,
filename: String,
}
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
unwrap_or_else
方法,它被定义于标准库的Result<T, E>
中,使用unwrap_or_else
方法可以让我们执行一些自定义的且不会产生panic!
的错误处理策略。当Result的值是Ok时,这个方法的行为于unwrap相同:它会返回Ok中的值,但是,当值为Err时,这个方法则会调用闭包(closure)中编写的代码,也就是我们定义出来并通过参数传入unwrap_or_else
的这个匿名函数。
新增的use语句被用来将标准库中的process
引入作用域。只会在错误情形下闭包代码仅有两行:打印err的值并接着调用process::exit
函数。调用 process::exit
函数会立刻终止程序运行,并将我们指定的错误代码返回给调用者。
3、从main中分离逻辑
我们会把main函数中除配置解析和错误处理之外的所有逻辑都提取到单独的run函数中。一旦完成这项工作,main函数本身就会精简得足以通过阅读来检查正确性,而其他几乎所有得逻辑则能够通过测试代码进行检验。
use std::env;
use std::fs;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
run(config);
}
fn run(config: Config){
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong reading the file.");
println!("With text:\n{}", contents);
}
struct Config {
query: String,
filename: String,
}
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
这个run
函数包含了main函数中从读取文件处开始的所有逻辑,它会接收一个Config实例作为参数。
1.从run函数中返回错误
通过将程序逻辑全部提取到run函数中,我们就可以改进错误处理。run
函数应当在发生错误时返回Result<T, E>
,而不是调用expect引发panic。
use std::env;
use std::fs;
use std::process;
use std::error::Error; //1
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
run(config);
}
fn run(config: Config) -> Result<(), Box<dyn Error>> { //2
let contents = fs::read_to_string(config.filename)?; //3
println!("With text:\n{}", contents);
Ok(()) //4
}
struct Config {
query: String,
filename: String,
}
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
这段代码有三处改动。首先,我们将run
函数的返回值修改为了Result<(), Box<dyn Error>>
。之前这个函数的返回值是空元组()
;现在它在被保留Ok时,作为返回值使用。
而对于错误时,我们使用trait对象Box<dyn Error
。该函数会返回一个实现了Error trait的类型,但我们不需要指定具体的类型。这意味着我们可以在不同错误场景下返回不同的错误类型,语句中的dyn
关键字表达为“动态(dynamic)”的含义。
其次,我们使用?
运算符取代了expect
,不同于panic!
宏对错误的处理方式,?
运算符可以将错误值返回给函数的调用者来进行处理。
最后,修改后的run函数会在运行成功时返回Ok。由于函数签名中制定了运行成功时的数据类型是(),所以我们需要把空元组的值包裹在Ok变体中。
虽然我们编译并运行成功,但是输出了告警信息:
Rust告诉我们代码中忽略了对Result值得处理。一个函数返回Result值,表明它在运行时可能发生了错误。
2.在main中处理run函数返回的错误
use std::env;
use std::fs;
use std::process;
use std::error::Error;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
//修改代码
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.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
struct Config {
query: String,
filename: String,
}
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
我们使用了if let而不是unwrap_or_else
来检查run的返回值,并在返回Err值得情况下调用了process::exit(1)。
和Config::new
返回一个Config实例不同,run函数并不会返回一个需要进行unwrap得值。
4、将代码分离为独立得代码包
我们需要拆分文件并将部分代码移入src/lib.rs
,这使我们可以正常进行测试并减少src/main.rs
中负责的功能。我们将所有非main
函数的代码从src/main.rs
转移至src/lib.rs
。它们包括:
- run函数的定影
- 相关的use语句
- Config的定义
- Config::new函数的定义
src/lib.rs
use std::fs;
use std::error::Error;
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
src/main.rs
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
四、使用测试驱动开发来编写库功能
1、编写一个会失败的测试
移除src/main.rs
和src/lib.rs
中的那些用来检查程序行为的println!
语句,并在src/lib.rs
中添加一个附带测试函数的tests模块。这个测试函数指定了我们期望search
函数所拥有的行为:它会接收一个查询字符串和一段用于查询的文本,病返回文本中包含查询字符串的所有行。
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(
vec!["safe, fast, productive."],
search(query, contents));
}
}
这个测试要求搜索字符串"duct"。因为在被搜索的3行文本只有第二行包含"duct",所以我们断言search函数的返回值指挥包含这一行。现在无法运行并观察失败的结果,因为它调用的search
函数没有编写。为了让测试能够正常编译和运行,我们会添加一个空动态数组的search
函数定义。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
注意,search
函数的签名中需要一个显式生命周期'a
,它被用来和contents参数于返回值一起使用。也就是我们告诉Rust,search
函数返回的数据将与contents
参数中的数据有同样的生命周期。这非常重要!\只有当前篇引用的数据有效时,引用本身才是有效的。如果编译器误认为我们在获取query
的字符串切片而不是contents的字符串切片,那么就无法进行争取的安全检查。
2、编写可以通过测试的代码
目前测试之所以失败是因为我们总是返回一个空动态数组。我们将按照以下步骤修复search
函数:
- 遍历内容的每一行;
- 检查当前行是否包含搜索字符串;
- 如果包含,则将其添加道返回值列表中;
- 如果不包含,则忽略;
- 返回匹配到的结果列表;
1.使用lines方法逐行遍历文本
Rust有一个可以逐行遍历字符串的方法,被命名为lines。请注意,当前代码暂时还不能通过编译。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
}
}
lines方法会返回一个迭代器。
2.在每一行中搜索字符串
字符类型有一个名为contains
的使用方法可以帮助们检查当前行是否包含搜索的字符串。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
if line.contains(query) {
}
}
}
3.存储匹配的行
最后,我们需要将包含目标字符串的行存储起来。为此,我们可以在for循环之间创建一个可变的动态数组,并在循环过程中用push方法将line变量存入其中,在for循环结束之后,我们直接放回这个动态数组。
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
}
4.在run函数中调用search函数
我们需要向search
函数中传入config.query
的值,以及run
函数从文件中读取的contents文本。
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
for line in search(&config.query, &contents) {
println!("{}", line)
}
Ok(())
}
这段代码再次使用了for循环去获取并打印search返回值中的每一行。
五、处理环境变量
增加一项额外的功能来完善minigrep
,用户可以通过设置环境变量来进行不区分大小写的搜索。
1、为不分区大小写的search函数编写一个会失败的测试
我们计划新增一个新的search_case_insensitive
函数。我们为计划添加的不区分大小写的函数编写一个暂时失败的测试。
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duck 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)
);
}
}
2、实现search_case_insensitive函数
search_case_insensitive
函数的实现和之前的search
函数几乎一样,唯一的区别在于我们将query和每一行的line都转换成了小写,这样一来,无论输入的参数是大写还是小写,当我们在检查某行文本中是否包含目标字母串时,它们都会拥有相同的大小写模式。
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let query = query.to_lowercase(); //1
let mut restults = Vec::new();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
restults.push(line);
}
}
results
}
首先,我们将query字符串转为小写,并把结果存储到同名变量中。需要注意的是,query是一个拥有数据所有权的String,而不再是一个字符串切片。因为调用to_lowercase
函数必定会创建新的数据,而不可能去引用现有数据。
当我们将新的query作为参数传递给contains时必须添加一个&符号,因为函数contains的签名只会接收一个字符串切片作为参数。
接着,在每次检查行文本是否包含query前,我们同样使用to_lowercase
将line转换为小写字符串。
现在,我们需要在run
函数中调用新的search_case_insensitive
函数,首先我们将为Config
结构体增加一个新的配置选项,以切换大小写的搜索和不区分大小写的搜索。
pub struct Config {
pub query: String,
pub filename: String,
pub case_sensitive: bool,
}
注意,我们增加的这个字段case_sensitive
是一个布尔类型。接下来,我们要在run函数中根据这个字段的值来决定调用search
函数还是search_case_insensitive
函数。
根据config.case_sensitive
的值决定调用search
函数还是search_case_insensitive
函数:
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};
for line in results {
println!("{}", line)
}
Ok(())
}
最后,我们还需要检查当前设置的环境变量。因为用于处理环境变量的相关函数被防止在标注库的env模块中,所以我们需要在src/lib.rs
的起始处添加use std::env;
语句来将该模块引入当前作用域。
use std::fs;
use std::error::Error;
use std::env; //新增代码
//...省略
//修改代码如下:
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filename, case_sensitive })
}
}
//...省略
这段代码创建了一个新的变量case_sensitive
。为了给它赋值,我们调用env::var
函数,并将环境变量CASE_INSENSITIVE
的名称作为参数传递给该函数。env::var
函数会返回一个Result作为结果,只有在环境变量被设置时,该结果才会使包含环境变量值的Ok变体,而在环境变量未被设置时,该结果则会是一个Err变体。
六、将错误提示信息打印到标准错误而不是标准输出
大多数终端都提供两种输出:用于输出一般信息的标准输出(stdout),以及用于输出错误提示信息的标准错误(stderr)。这种区分可以使用户将正常输出重定向到文件的同时仍然将错误提示信息打印到屏幕上。
println!
宏只能用来打印到标准输出。
1、确认错误被写到了哪里
我们可以将标准输出重定向到一个文件,并故意触发错误来观察这一现象。由于我们没有重定向标准错误,所以打印到标准错误上的那些内容仍然会输出到屏幕上。
这里的>
语法告知终端将标准输出中的内容写入outpu.txt
文件而不是打印在屏幕上,而错误信息则写入outpu.txt
文件。
2、将错误提示信息打印到标准错误
我们可以使用一个由标准库提供的eprintln!
宏来向标准错误打印信息。在重构之前,我们已经把所有打印错误提示信息的代码都放到了main函数中,因此我们只需要将打印错误提示信息的两处println!
改为eprintln!
即可。
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&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);
}
}
在println!
修改为eprintln!
之后,我们再次运行程序:
现在,我们可以看到 错误提示信息被打印到屏幕上,而output.txt
中没有任何内容,这才是符合我们期望的命令行程序的行为。
我们在使用正常的参数运行程序,依然将标准输出重定向到文件:
我们可以看到终端上没有打印任何信息,而output.txt
中则包含了正确的输出结果: