趣味学RUST基础篇(构建一个命令行程序完结)
程序员的“闯关秘籍”:测试驱动开发(TDD)
目前,我们已经开发了“迷你 grep”(一个文本搜索小工具)的程序。但你不想写完代码才发现 bug 满天飞,于是你决定用一种“超前预判”的武功——测试驱动开发(TDD)!
TDD 的口诀是:
**先写“失败”的测试,再写代码让它“复活”!**就像先画好靶子,再射箭,确保每一箭都命中红心。
第一关:画靶子(写一个失败的测试)
你先不急着写搜索功能,而是先写一个“测试关卡”
“如果我搜索关键词
'duct'
,在下面这段文字里……”// src/lib.rs .... #[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));} }
“你必须只返回这一行:
safe, fast, productive.
”
你信心满满地写下这个测试,然后……编译失败了!
为啥?因为 search
这个函数还不存在!你只画了靶子,还没造弓箭呢。
第二关:造一把“空弓”(让代码先能编译)
为了让程序能编译通过,你先造一把“空弓”——一个什么也不干的 search
函数:
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {vec![] //返回一个空列表
}
现在程序能编译了!但测试还是失败,因为返回的是空列表,而不是那句“safe, fast, productive.”。
靶子有了,弓也有了,下一步:射箭!
第三关:射出第一支箭(让测试通过)
你开始写真正的搜索逻辑,分三步走:
- 一行一行读:用
.lines()
把文本拆成一行行。 - 逐行检查:用
.contains(query)
看这行有没有关键词。 - 命中就存:如果有,就把它加入结果列表。
最终代码长这样:
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
}
再次运行测试……叮!通关成功!
running 1 test
test tests::one_result ... oktest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sRunning unittests src/main.rs (target/debug/deps/minigrep-a9fd6e1032830f98)running 0 teststest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sDoc-tests minigreprunning 0 teststest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
测试通过了!你搜索 "duct"
,真的只返回了那一行。
第四关:优化装备(重构)
现在你已经通关了,但你知道——高手不会满足于“能用”的代码。你心想:“这段代码能不能更酷一点?比如用更高级的‘迭代器魔法’?”
于是你开始重构——不改变功能,只让代码更优雅、更高效。
最后:把武器装上(集成到主程序)
现在 search
函数已经通过测试,可以投入使用了!你回到主程序 run
函数调用它,完整代码如下
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(())
}
然后你开始测试真实场景:
(base) kunliu@MacBook-Pro-4 minigrep % cargo run -- frog poem.txtCompiling minigrep v0.1.0 (/Users/kunliu/project/rust-project/minigrep)Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.22sRunning `target/debug/minigrep frog poem.txt`
How public, like a frog
- 搜
"frog"
→ 返回 1 行 - 搜
"body"
→ 返回多行 - 搜
"monomorphization"
(根本不存在的词)→ 一行都不返回
完美!你的“迷你 grep”大功告成!
TDD 的三大好处
- 目标明确:先写测试,就像先画靶子,避免盲目开发。
- 安全感强:每加一行代码,都有测试帮你“兜底”。
- 代码更优:先实现功能,再重构优化,步步为营。
给你的搜索工具加个“魔法开关”:环境变量登场!
你已经做出了一个能搜索文本的“迷你 grep”小工具,但它现在有个小问题:它太“死板”了!比如你搜 "rust"
,它就只认小写的 rust
,像 "Rust"
或 "rUsT"
这种带大写字母的,它就装作没看见。你心想:“要是能有个开关,让我一键开启‘不区分大小写’模式,那该多酷!”但你不想每次搜索都打一堆参数,比如 --ignore-case
,那太麻烦了!
于是你决定用一个更高级的技巧——环境变量(Environment Variable),给你的程序加一个“永久魔法开关”!
第一步:先画个“失败的蓝图”(写测试)
你还是老规矩,先写测试,走 TDD 路线。你新增一个测试,名字叫:“不区分大小写的搜索”。
你设定场景:
“我要搜
'rUsT'
,但在‘不区分大小写’模式下,下面这些句子都应该被找到:”
"Rust:"
"Trust me."
"rUsT is awesome!"
#[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
这个函数还不存在!
第二步:施个“变小写”魔法(实现功能)
你开始写真正的“不区分大小写”搜索函数。
它的原理很简单:把所有东西都变成小写,再比较!
就像你戴上一副“小写眼镜”,不管别人写的是大写还是小写,在你眼里都是小写。
代码长这样:
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
}
**运行测试……叮!魔法生效!**测试通过了!无论是 "Rust"
还是 "rUsT"
,都能被搜到!
第三步:加个“开关”(集成到主程序)
现在魔法有了,但怎么让用户“打开”它呢?
你决定:用环境变量 IGNORE_CASE
来控制!
- 如果用户设置了
IGNORE_CASE=1
,就开启“不区分大小写”模式。 - 如果没设置,就默认“区分大小写”。
怎么实现?
- 加个配置项:在程序的配置里加一个布尔值
ignore_case
。 - 读取环境变量:用 Rust 的
env::var("IGNORE_CASE")
去检查这个变量有没有被设置。 - 自动切换:如果设置了,就调用
search_case_insensitive
;否则用原来的search
。
pub struct Config {query: String,file_path: String,pub ignore_case: bool, // 添加大小写判断
}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(())
}
use std::env;use std::env;
// --snip--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 file_path = args[2].clone();let ignore_case = env::var("IGNORE_CASE").is_ok(); //设置了就返回trueOk(Config {query,file_path,ignore_case,})}
}
is_ok()
是关键!我们不关心值是什么(1
、true
、yes
都行),只关心“有没有设置”。
试试魔法开关!
先试试默认模式(区分大小写):
cargo run -- to poem.txt
只会找到小写的 to
。
再试试开启魔法:
IGNORE_CASE=1 cargo run -- to poem.txt
结果炸了!所有包含 to
的句子,不管大小写,全都被搜出来了:
(base) kunliu@MacBook-Pro-4 minigrep % IGNORE_CASE=1 cargo run -- to poem.txtFinished `dev` profile [unoptimized + debuginfo] target(s) in 0.01sRunning `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!
太棒了!你的程序现在会“看场合”了!
为什么用环境变量?不直接用命令行参数?
- 命令行参数:适合每次都可能不同的选项(比如搜什么词)。
- 环境变量:适合一次性设置,长期生效的偏好(比如“我永远不想区分大小写”)。
就像你设置手机的“暗黑模式”,不用每次打开 App 都点一次。
PowerShell 用户注意!
如果你用的是 Windows 的 PowerShell,设置环境变量的命令稍微不一样:
$Env:IGNORE_CASE=1; cargo run -- to poem.txt
想关掉它?运行:
Remove-Item Env:IGNORE_CASE
程序员的“广播站”:stdout vs stderr
想象你的程序是一个广播电台,它有两种广播频道:
-
频道 A:标准输出(stdout)
播放“正常新闻”——比如搜索结果、计算答案、程序成功运行的消息。 -
频道 B:标准错误(stderr)
播放“紧急插播”——比如“参数错误!”、“文件找不到!”这类出问题的警报。
目前,你的“迷你 grep”程序有个大问题:它把所有内容,不管是新闻还是警报,全都塞进了频道 A!
这就出问题了!
问题来了:警报被“静音”了!
假设你想把搜索结果保存到一个文件里,比如:
cargo run -- to poem.txt > results.txt
这里的 >
就像是说:“把频道 A 的内容录下来,存到 results.txt
里。”
但问题是——你的程序把错误信息也播在频道 A,所以:
如果你输错了参数,比如忘了写文件名……
结果是:错误信息也被录进了results.txt
!
而你在屏幕上啥也看不到,还以为程序“卡死了”或“没反应”。
这就像:
火灾警报响了,但广播员却对着录音机说:“着火了!着火了!”
而你正在录音室外面,根本听不到!
修复方案:把警报移到“紧急频道”
Rust 给我们提供了两个“麦克风”:
println!
→ 对着 频道 A(stdout) 说话(正常输出)eprintln!
→ 对着 频道 B(stderr) 说话(错误信息)
所以,我们只需要把程序里所有“报错”的地方,从 println!
换成 eprintln!
就行了!
比如原来这样:
println!("Problem parsing arguments: not enough arguments");
改成:
eprintln!("Problem parsing arguments: not enough arguments");
//下面是 完整代码
use std::{env, process};
use minigrep::{run, Config};fn main() {let args: Vec<String> = env::args().collect();let config = Config::new(&args).unwrap_or_else(|err|{eprintln!("问题:{}", err);process::exit(1);});if let Err(e) = run(config){eprintln!("应用程序出错 :{e}");process::exit(1);}}
测试一下:电台升级成功!
现在我们再试一次:
cargo run > output.txt
结果是:
- 屏幕上立刻显示:
Problem parsing arguments: not enough arguments
(因为这是stderr
,直接播出来,你立刻就知道出错了!) -
output.txt
里是空的!(因为stdout
没有内容,没录到任何东西)
再试一次正确的命令:
cargo run -- to poem.txt > output.txt
-
屏幕上啥也没有(正常,因为没错误)
-
output.txt
里存下了所有匹配的行:Are you nobody, too?
How dreary to be somebody!
这才是一个合格的命令行程序!
为什么这么重要?
因为用户经常这样做:
我的程序 | grep "关键词" > 结果.txt
如果错误信息混在正常输出里,就会被 grep
处理,甚至被存进文件,导致结果混乱。而用 stderr
,错误信息会“绕开”管道和重定向,直接蹦到用户眼前,提醒他们:“嘿!出问题了!”
println!
和 println!
的区别
宏 | 输出到 | 用途 | 是否会被 > 重定向 |
---|---|---|---|
println! | stdout | 正常输出 | 会被重定向 |
eprintln! | stderr | 错误信息 | 不会被重定向 |
总结:你的程序现在是个“专业电台”了!
- 正常结果 → 走
stdout
→ 可以被保存、被处理。 - 错误信息 → 走
stderr
→ 直接弹出,不会被“淹没”。
这就像:
一个好的服务员,不会把账单和垃圾一起扔进垃圾桶。
同样,一个好的程序,也不会把错误信息和正常输出混在一起。