本章将介绍 Rust 在实际开发中的使用,并用官方文档上的项目(一个简单版本的 grep 命令)展开讲解,最后将利用之前所学,自己实现一个代码统计的 Rust 项目。
要实现的 grep 命令功能很简单,就是在指定文件中查找指定文字。grep 命令接收一个文件名和字符串作为输入参数,然后读取文件内容,搜索包含指定字符串的行,最终将这些匹配的行打印输出。
下面开始实战演示。
一、接收命令行参数 我们预计使用如下命令来执行该程序:
1 cargo run <string> <filename>
因此我们首先要读取命令行中的参数,我们导入函数 use std::env::args()
,args()
函数返回一个迭代器,迭代器部分的内容将在后面才会介绍。然后使用 collect
方法,将迭代器中的值转化成一个集合,但是该函数不能处理命令行中非 Unicode
的字符(这种情况可以使用 env::args _os()
函数,这种情况下返回的迭代器值的类型是 OsString
,在这里不做介绍)。
1 2 3 4 5 6 7 8 9 10 use std::env;fn main () { let args : Vec <String > = env::args ().collect (); let search_string = &args[1 ]; let filename = &args[2 ]; println! ("{:?}" , args); println! ("Search String {}" , search_string); println! ("In file {}" , filename); }
1 2 3 4 5 6 7 ➜ ~/code/rust/minigrep git:(master) ✗ cargo run string filename Compiling minigrep v0.1.0 (/home/cherry/code/rust/minigrep) Finished dev [unoptimized + debuginfo] target (s) in 0.29 s Running `target/debug/minigrep string filename` ["target/debug/minigrep" , "string" , "filename" ] Search String string In file filename
根据程序执行结果我们能够得知:返回的第一个参数永远都是该程序的二进制文件(对应 args[0]
),从第二个参数开始才是从命令行输入的各种参数(对应 args[1]
…)。
二、读取文件 首先导入模块 use std::fs
,用于处理和文件相关的事务,read_to_string()
用来读取文件中的内容,将其转化成字符串。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 use std::env;use std::fs;fn main () { let args : Vec <String > = env::args ().collect (); let search_string = &args[1 ]; let filename = &args[2 ]; println! ("{:?}" , args); println! ("Search String {}" , search_string); println! ("In file {}" , filename); let content = fs::read_to_string (filename).expect ("该文件不存在" ); println! ("文件内容:\n{}" , content); }
输出结果为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ➜ ~/code/rust/minigrep git:(master) ✗ cargo run string poem Finished dev [unoptimized + debuginfo] target (s) in 0.00 s Running `target/debug/minigrep string poem` ["target/debug/minigrep" , "string" , "poem" ] Search String string In file poem 文件内容: Hold fast to dreams For if dreams die Life is a broken-winged bird That can never fly Hold fast to dreams For when dreams go Life is a barren field Frozen only with snow To see a world in a grain of sand, And a heaven in a wild flower, Hold infinity in the palm of your hand, And eternity in an hour.
当然目前看来所有逻辑都放在了主函数中,并且很多错误情况都没有考虑。一般情况下一个函数只做一件事,如果代码逐渐变多,代码维护将变得越来越困难。代码越少重构越简单,因此下一节将对代码进行重构。
三、重构:改进模块和错误处理 3.1 四个问题提炼 我们仔细观察一下目前的代码,主要有四个方面的问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 use std::env;use std::fs;fn main () { let args : Vec <String > = env::args ().collect (); let search_string = &args[1 ]; let filename = &args[2 ]; println! ("{:?}" , args); println! ("Search String {}" , search_string); println! ("In file {}" , filename); let content = fs::read_to_string (filename).expect ("该文件不存在" ); println! ("文件内容:\n{}" , content); }
主函数负责的功能较多,既要负责命令行参数解析,又要负责读取文件。而程序编写的原则就是一个函数负责一个功能,因此要将主函数拆分;
search_string
、filename
和 content
变量,在程序越来越大之后,变量也会越来越多,将难以追踪每一个变量的实际意义。解决办法是将这些变量放入一个结构体中,从而使其用途更加清晰;
读取文件时,使用 expect
处理错误,但未对其读取错误的原因进行细分,因为文件打不开可能是文件不存在,文件权限不够,文件损坏等原因;
对于命令行参数的错误处理,若输入的参数没有两个,那么程序本身就会报错,并且能够预料到的错误一定是 Out of bound
这类的错误,但是对于使用者来说,可能并不清楚这个所谓的 越界错误 意味着什么,无法清晰解释错误的具体原因。因此最好要将所有错误处理集中到一起,将来开发者要考虑错误处理的时候,就只要处理这一处代码,这样也能保证为用户打印出有意义的错误信息,而不是只有程序员能看懂的 Out of bound
。
3.2 二进制程序关注点分离的指导性原则
将程序拆分为 main.rs
和 lib.rs
,将业务逻辑放入 lib.rs
当命令行解析逻辑较少时,将它放在 main.rs
也行
当命令行解析逻辑变复杂时,需要将它从 main.rs
提取到 lib.rs
经过上述拆分,留在 main
的功能有:
使用参数值调用命令行解析逻辑
进行其它配置
调用 lib.rs
中的 run
函数
处理 run
函数可能出现的错误
因此放在 main.rs
中的代码量应足够小,小到直接阅读代码就可以确保代码的正确性。将业务逻辑放入 lib.rs
中也方便进行功能测试。
针对上面说的四个方面的问题,我们逐一进行解决。
1. 拆分出命令行参数提取功能
1 2 3 4 5 6 7 8 9 10 11 12 13 use std::env;use std::fs;fn main () { ... let (search_string, filename) = parse_config (&args); ... } fn parse_config (args: &[String ]) -> (&str , &str ) { let search_string = &args[1 ]; let filename = &args[2 ]; (search_string, filename) }
我们发现,parse_config
函数目前返回一个元组,但是在主函数中,又将该元组拆分出来,赋值给两个变量,这样感觉有点“脱裤子放屁”的感觉,来回折腾。实际上这种情况就说明程序中这样设计数据结构是不正确的。因此较好的做法就是将返回的元组中的变量放入一个结构体。
2. 创建结构体
1 2 3 4 5 6 7 8 9 10 struct Config { search_string: String , filename: String } fn parse_config (args: &[String ]) -> Config { let search_string = &args[1 ]; let filename = &args[2 ]; Config { search_string, filename } }
这里我们创建一个叫 Config
的结构体,将 search_string
和 filename
两个变量放入结构体。但是上面的代码会报错,这是因为在函数 parse_config
中,args
参数是切片类型,是没有所有权的(它的所有权被 main
函数拥有),而在最后要返回一个 Config
结构体对象,该结构体需要占用所有权,因此会报错。
这里用一个简单的方法来处理,就是创建 args[1]
和 args[2]
的两个副本,尽管这样会损失性能。
1 2 3 4 5 fn parse_config (args: &[String ]) -> Config { let search_string = args[1 ].clone (); let filename = args[2 ].clone (); Config { search_string, filename } }
我们再来看 parse_config
函数,它返回的是一个结构体,实际上是要创建一个新的结构体,因此我们最好再实现该结构体的 new
函数。
1 2 3 4 5 6 7 8 9 10 impl Config { fn new (args: &[String ]) -> Config { let search_string = args[1 ].clone (); let filename = args[2 ].clone (); Config { search_string, filename, } } }
这里就是将刚刚的 parse_config
变成了结构体 Config
的函数。重构后的完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 use std::env;use std::fs;fn main () { let args : Vec <String > = env::args ().collect (); let config = Config::new (&args); let content = fs::read_to_string (config.filename).expect ("该文件不存在" ); println! ("文件内容:\n{}" , content); } struct Config { search_string: String , filename: String , } impl Config { fn new (args: &[String ]) -> Config { let search_string = args[1 ].clone (); let filename = args[2 ].clone (); Config { search_string, filename, } } }
3. 错误处理
我们不输入参数进行运行,不出预料的会产生下面的错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ➜ ~/code/rust/minigrep git:(master) ✗ cargo run warning: field is never read: `search_string` --> src/main.rs:19 :5 | 19 | search_string: String , | ^^^^^^^^^^^^^^^^^^^^^ | = note: `#[warn(dead_code)] ` on by default warning: `minigrep` (bin "minigrep" ) generated 1 warning Finished dev [unoptimized + debuginfo] target (s) in 0.00 s Running `target/debug/minigrep` thread 'main ' panicked at 'index out of bounds: the len is 1 but the index is 1 ', src/main.rs:25 :29 note: run with `RUST_BACKTRACE=1 ` environment variable to display a backtrace
即 越界错误 ,这对于用户来说是无法理解的,我们当然可以在 new
函数中添加这样的判断语句,
1 2 3 if args.len () < 3 { panic! ("输入参数错误,请输入两个参数。" ); }
但是这样仍然会有编译器的其他信息,一般情况下,使用 panic
通常是程序本身的问题,但是像这类输入参数少的问题属于程序使用的问题,因此我们还需要进行改进,可以返回 Result
枚举,代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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! ("参数解析错误:{}" , err); process::exit (1 ); }); let content = fs::read_to_string (config.filename).expect ("该文件不存在" ); println! ("文件内容:\n{}" , content); } struct Config { search_string: String , filename: String , } impl Config { fn new (args: &[String ]) -> Result <Config, &str > { if args.len () < 3 { return Err ("输入参数个数不足,请输入两个参数。" ); } let search_string = args[1 ].clone (); let filename = args[2 ].clone (); Ok (Config { search_string, filename, }) } }
如果参数个数超过 2 个,则返回 Err
的变体,否则返回 Ok
。主函数中,unwrap_or_else
函数的含义是,如果枚举返回的是 Ok
,那么就取出 Ok
变体中的值返回,若枚举返回的是 Err
,那么就调用一个闭包(匿名函数,闭包具体内容将来会介绍),然后使用 process::exit(1)
将程序返回,这样就不会有编译器的其他信息了。
1 2 3 4 5 ➜ ~/code/rust/minigrep git:(master) ✗ cargo run Compiling minigrep v0.1.0 (/home/cherry/code/rust/minigrep) Finished dev [unoptimized + debuginfo] target (s) in 0.33 s Running `target/debug/minigrep` 参数解析错误:输入参数个数不足,请输入两个参数。
4. 功能模块化
一个函数只处理一个功能,因此我们将业务逻辑(即读取文件内容)功能提取到一个新的函数中。
1 2 3 4 fn run (config: Config) { let content = fs::read_to_string (config.filename).expect ("该文件不存在" ); println! ("文件内容:\n{}" , content); }
然后我们进行 run
函数的错误处理。
1 2 3 4 5 fn run (config: Config) -> Result <(), Box <dyn Error>> { let content = fs::read_to_string (config.filename)?; println! ("文件内容:\n{}" , content); Ok (()) }
这里 result<(), Box<dyn Error>>
中第一个参数是空,第二个参数只要理解是一个实现了 Error
这个 trait
的类型,这样函数便可以在不同场景下返回不同的错误类型。
因为 expect
会引起恐慌,因此将其去掉,改成 ?
,?
运算符遇到错误不会恐慌,它会将错误值返回给函数的调用者,如果没有发生错误,那么我们最后返回一个 Ok()
。
这时编译器会在 run(config)
出给予警告:this 'Result' may be an 'Err' variant, which should be handled
,这说明函数返回值是一个 Result
类型,那么就说明可能会产生错误,因此需要对其进行处理。
unwrap
有打开的意思,需要从 Result
中提取数据,但是 run
函数没有返回值,因此也就不需要 unwrap
,可以像下面这样解决这一问题。
1 2 3 4 if let Err (e) = run (config) { println! ("程序运行出错:{}" , e); process::exit (1 ); }
下面我们将业务逻辑迁移到 lib.rs
中。
lib.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 use std::fs;use std::error::Error;pub fn run (config: Config) -> Result <(), Box <dyn Error>> { let content = fs::read_to_string (config.filename)?; println! ("文件内容:\n{}" , content); Ok (()) } pub struct Config { pub search_string: String , pub filename: String , } impl Config { pub fn new (args: &[String ]) -> Result <Config, &str > { if args.len () < 3 { return Err ("输入参数错误,请输入两个参数。" ); } let search_string = args[1 ].clone (); let filename = args[2 ].clone (); Ok (Config { search_string, filename, }) } }
main.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 use minigrep::Config;use std::env;use std::process;fn main () { let args : Vec <String > = env::args ().collect (); let config = Config::new (&args).unwrap_or_else (|err| { println! ("参数解析错误:{}" , err); process::exit (1 ); }); if let Err (e) = minigrep::run (config) { println! ("程序运行出错:{}" , e); process::exit (1 ); } }
要记得所有函数和结构体以及字段前都要加 pub
,这样才能让其他 crate
才能进行调用。这样 lib crate
就有了一套公共的可用于测试的 API。
重构到这里就基本完成了,下面就要来编写测试了。
四、使用 TDD(测试驱动开发)开发库功能 测试驱动开发 TDD (Test-Driven Development)
编写一个会失败的测试,运行该测试,确保它是按照预期的原因失败
编写或修改刚好足够的代码,让新测试通过
重构刚刚添加或修改的代码,确保测试会始终通过
返回步骤1,继续
测试驱动开发能够对代码的设计起到指导和帮助的作用,先编写测试,然后再编写能够通过测试的代码,也能保证开发过程中能够保持测试较高的覆盖率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 fn search <'a >(query: &str , content: &'a str ) -> Vec <&'a str > { let mut vec = Vec ::new (); for lines in content.lines () { if lines.contains (query) { vec.push (lines); } } vec } #[cfg(test)] mod test { #[test] fn one_result () { use super::*; let query = "Lakers" ; let contents = "\ Rust OK, Paul, James, Lakers. What a wonderful day!" ; assert_eq! (vec! ["Paul, James, Lakers." ], search (query, contents)); } }
注意 search
函数中返回的引用的生命周期与 content
有关,而与 query
无关。content.lines()
函数返回一个的迭代器,取出文件中的每一行。这样测试代码就完成了,运行测试也是成功的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ➜ ~/code/rust/minigrep git:(master) ✗ cargo test Compiling minigrep v0.1.0 (/home/cherry/code/rust/minigrep) Finished test [unoptimized + debuginfo] target (s) in 0.36 s Running unittests (target/debug/deps/minigrep-662 cb87b3d895995) running 1 test test test::one_result ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00 s Running unittests (target/debug/deps/minigrep-33 abce92ed029d2f) running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00 s Doc-tests minigrep running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00 s
然后修改 run
函数并运行 cargo run
。
1 2 3 4 5 6 7 8 pub fn run (config: Config) -> Result <(), Box <dyn Error>> { let content = fs::read_to_string (config.filename)?; for line in search (&config.search_string, &content) { println! ("{}" , line); } Ok (()) }
1 2 3 4 5 ➜ ~/code/rust/minigrep git:(master) ✗ cargo run is poem Finished dev [unoptimized + debuginfo] target (s) in 0.00 s Running `target/debug/minigrep is poem` Life is a broken-winged bird Life is a barren field
五、使用环境变量 这一部分使用环境变量来实现配置选项(例如是否忽略大小写等)。
我们首先编写一个测试:
1 2 3 4 5 6 7 8 9 10 #[test] fn case_insensitive () { let query = "LakErS" ; let contents = " Rust OK, Paul, James, Lakers. What a wonderful day! blakers championship" ; assert_eq! (vec! ["Paul, James, Lakers." , "blakers championship" ], search_case_insensitive (query, contents)); }
然后编写 search_case_insensitive
函数:
1 2 3 4 5 6 7 8 9 10 fn search_case_insensitive <'a >(query: &str , content: &'a str ) -> Vec <&'a str > { let mut vec = Vec ::new (); let query = query.to_lowercase (); for lines in content.lines () { if lines.to_lowercase ().contains (&query) { vec.push (lines); } } vec }
其思路就是将查询的字符串和文件中的都转化成小写。
然后我们在 run
函数中加入如下逻辑。
1 2 3 4 5 let result = if config.case_sensitive { search (&config.search_string, &content) } else { search_case_insensitive (&config.search_string, &content) };
结构体的 new
函数也需要修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 pub fn new (args: &[String ]) -> Result <Config, &str > { if args.len () < 3 { return Err ("输入参数错误,请输入两个参数。" ); } let search_string = args[1 ].clone (); let filename = args[2 ].clone (); let case_sensitive = env::var ("CASE_INSENSITIVE" ).is_err (); Ok (Config { search_string, filename, case_sensitive }) }
env::var()
函数返回的是 Result
枚举,若环境中有 CASE_INSENSITIVE
定义或者赋值,那么就会返回 Ok
中的值,我们这里只需要判断是否为 Err
即可。
1 2 3 4 5 6 ➜ ~/code/rust/minigrep git:(master) ✗ CASE_INSENSITIVE=1 cargo run to poem Finished dev [unoptimized + debuginfo] target (s) in 0.00 s Running `target/debug/minigrep to poem` Hold fast to dreams Hold fast to dreams To see a world in a grain of sand,
六、将错误消息写进标准错误而不是标准输出 当前我们都将错误信息输出到终端上,而大多数终端提供两种输出,一个是标准输出(stdout,println!),另一个叫标准错误(stderr,eprintln!)。
我们将打印错误信息的 println!
改成 eprintln!
即可,然后运行 cargo run > output
,错误信息便不会输出到文件中,而是打印在终端了。
七、完整代码 main.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 use minigrep::Config;use std::env;use std::process;fn main () { let args : Vec <String > = env::args ().collect (); let config = Config::new (&args).unwrap_or_else (|err| { println! ("参数解析错误:{}" , err); process::exit (1 ); }); if let Err (e) = minigrep::run (config) { println! ("程序运行出错:{}" , e); process::exit (1 ); } }
lib.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 use std::error::Error;use std::fs;use std::env;pub fn run (config: Config) -> Result <(), Box <dyn Error>> { let content = fs::read_to_string (config.filename)?; let result = if config.case_sensitive { search (&config.search_string, &content) } else { search_case_insensitive (&config.search_string, &content) }; for line in result { println! ("{}" , line); } Ok (()) } pub struct Config { pub search_string: String , pub filename: String , pub case_sensitive: bool } impl Config { pub fn new (args: &[String ]) -> Result <Config, &str > { if args.len () < 3 { return Err ("输入参数错误,请输入两个参数。" ); } let search_string = args[1 ].clone (); let filename = args[2 ].clone (); let case_sensitive = env::var ("CASE_INSENSITIVE" ).is_err (); Ok (Config { search_string, filename, case_sensitive }) } } fn search <'a >(query: &str , content: &'a str ) -> Vec <&'a str > { let mut vec = Vec ::new (); for lines in content.lines () { if lines.contains (query) { vec.push (lines); } } vec } fn search_case_insensitive <'a >(query: &str , content: &'a str ) -> Vec <&'a str > { let mut vec = Vec ::new (); let query = query.to_lowercase (); for lines in content.lines () { if lines.to_lowercase ().contains (&query) { vec.push (lines); } } vec } #[cfg(test)] mod test { use super::*; #[test] fn one_result () { let query = "Lakers" ; let contents = "\ Rust OK, Paul, James, Lakers. What a wonderful day!" ; assert_eq! (vec! ["Paul, James, Lakers." ], search (query, contents)); } #[test] fn case_insensitive () { let query = "LakErS" ; let contents = " Rust OK, Paul, James, Lakers. What a wonderful day! blakers championship" ; assert_eq! ( vec! ["Paul, James, Lakers." , "blakers championship" ], search_case_insensitive (query, contents) ); } }
七、案例:代码统计 7.1 基本功能介绍 代码统计以给定的输入参数作为统计对象(可以是文件或文件夹),根据文件后缀名统计代码所使用的语言(暂定只统计 C、C/C++ 头文件、C++、Java、Python、Rust、汇编语言、makefile 脚本),然后统计每一种代码文件的有效代码行数、注释行和空行。没有后缀名的文件默认不进行统计。
7.2 可拓展功能
丰富统计的语言种类
命令行中利用参数指定要统计的语言,只统计指定的语言
加入多线程提高文件扫描速度
v1.5.2