跳转到内容

Rust 练习题

读 Rust 很有用,但真正让知识留下来的是动手写。这一页有 18 道小题,按主题分组,由易到难排列。

每道题都先读题目,自己动手试一试,然后再打开答案。卡住了、看到编译错误都完全没关系,这本来就是学习的一部分。而且 Rust 的错误信息往往就是你能得到的最好提示。

怎么练习:在另一个标签页打开 Rust Playground,或者用 cargo new practice 新建一个项目(见快速上手)。在那里写下你的答案,再和参考答案对比。如果写法不同但输出一样,那多半也没问题。好的写法几乎总是不止一种。

打印 1 到 15 的数字。但遇到 3 的倍数时打印 Fizz,遇到 5 的倍数时打印 Buzz,同时是 3 和 5 的倍数时打印 FizzBuzz。

提示:用 for 循环遍历 1..=15,再配合 %(取余)运算符。记得先检查“同时是两者的倍数”。

查看答案
fn main() {
for n in 1..=15 {
if n % 15 == 0 {
println!("FizzBuzz");
} else if n % 3 == 0 {
println!("Fizz");
} else if n % 5 == 0 {
println!("Buzz");
} else {
println!("{n}");
}
}
}

输出:

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
▶ 动手试一试

顺序很重要。如果先检查 n % 3 == 0,那么 15 会打印 Fizz,永远走不到 FizzBuzz 那个分支。能同时被 3 和 5 整除的数,就是能被 15 整除的数。

写一个函数 reverse(s: &str) -> String,返回倒过来的文本。"stressed" 应该变成 "desserts"。

提示:.chars() 会逐个给出字符,.rev() 把迭代器反过来,.collect() 可以拼出一个 String。

查看答案
fn reverse(s: &str) -> String {
s.chars().rev().collect()
}
fn main() {
println!("{}", reverse("stressed"));
println!("{}", reverse("hello"));
println!("{}", reverse("año"));
}

输出:

desserts
olleh
oña
▶ 动手试一试

为什么用 .chars() 而不是字节?Rust 的 String 是 UTF-8 编码的,像 ñ 这样的字符占两个字节(汉字更是占三个)。直接反转字节会把它拆坏,变成无效的文本。.chars() 按完整的字符来处理。更多内容见字符串。

写 count_vowels(s: &str) -> usize,统计字符串里元音字母(a、e、i、o、u)的个数。大写和小写都算。

提示:.chars().filter(...).count()。像 "aeiouAEIOU" 这样的字符串有一个 .contains(c) 方法,可以用来检查每个字符。

查看答案
fn count_vowels(s: &str) -> usize {
s.chars().filter(|c| "aeiouAEIOU".contains(*c)).count()
}
fn main() {
println!("{}", count_vowels("Hello, World"));
println!("{}", count_vowels("Rust is fun"));
println!("{}", count_vowels("AEIOU and sometimes y"));
println!("{}", count_vowels("rhythm"));
}

输出:

3
3
10
0
▶ 动手试一试

filter 传给闭包的是每个元素的引用(&char),所以要写 *c 才能拿到 char 本身。闭包和迭代器两页会详细讲这种写法。

下面的程序编译不通过。只修改 shout 函数的参数和两处调用,让它打印两次 HELLO!。

fn shout(text: String) {
println!("{}!", text.to_uppercase());
}
fn main() {
let word = String::from("hello");
shout(word);
shout(word);
}
error[E0382]: use of moved value: `word`
--> src/main.rs:8:11
|
6 | let word = String::from("hello");
| ---- move occurs because `word` has type `String`, which does not implement the `Copy` trait
7 | shout(word);
| ---- value moved here
8 | shout(word);
| ^^^^ value used here after move

编译器说:word 的值已经被移动走了,之后又被使用了一次。

提示:shout 只需要看一看这段文本,并不需要拥有它。见所有权。

查看答案
fn shout(text: &str) {
println!("{}!", text.to_uppercase());
}
fn main() {
let word = String::from("hello");
shout(&word);
shout(&word);
}

输出:

HELLO!
HELLO!
▶ 动手试一试

函数参数是 String 时,调用它就会把字符串移动进去,main 之后就不能再用了。改成 &str,函数就只是借用它。编译器还会建议用 word.clone(),这样也行,但它会毫无必要地复制整个字符串。这里借用才是更好的修法。

这个程序也编译不通过。让它打印 Ana,同时还要把 Ben 加进列表。

fn main() {
let mut names = vec![String::from("Ana")];
let first = &names[0];
names.push(String::from("Ben"));
println!("{first}");
}
error[E0502]: cannot borrow `names` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let first = &names[0];
| ----- immutable borrow occurs here
4 | names.push(String::from("Ben"));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here

编译器说:names 已经被不可变地借用了,所以不能再把它可变地借用。

提示:问题出在顺序上。push 修改 vector 的时候,first 还在使用中。见借用。

查看答案
fn main() {
let mut names = vec![String::from("Ana")];
names.push(String::from("Ben"));
let first = &names[0];
println!("{first}");
}

输出:

Ana
▶ 动手试一试

为什么 Rust 在这里这么严格?push 可能需要更多空间,这时 vector 会把所有元素搬到内存里一块更大的新位置。那么 first 就会指向旧位置,而旧位置已经无效了。Rust 的规则(可以有多个读者,或者一个写者,但两者不能同时存在)在程序运行之前就挡住了这个 bug。把引用放到 push 之后再取,问题就解决了。

写 first_word(s: &str) -> &str,返回第一个单词(第一个空格之前的所有内容)。如果没有空格,就返回整个字符串。不要新建 String,而是返回原字符串的一个 slice。

提示:s.find(' ') 返回 Option<usize>,里面是第一个空格的位置。&s[..i] 是从开头到 i 的 slice。

查看答案
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
fn main() {
println!("{:?}", first_word("hello world"));
println!("{:?}", first_word("Rust"));
println!("{:?}", first_word(""));
}

输出:

"hello"
"Rust"
""
▶ 动手试一试

返回的 &str 和输入指向同一块内存,所以什么都没有复制。编译器知道结果不会比输入活得更久。这就是生命周期省略在起作用:只有一个引用进、一个引用出时,你不用自己写生命周期。

更短的写法是 s.split_whitespace().next().unwrap_or(""),它还会跳过开头多余的空格。

写 largest(numbers: &[i32]) -> Option<i32>。它返回最大的数,列表为空时返回 None。这次不要用内置的 .max(),自己写循环。

提示:先把第一个元素当作“目前最大的”。numbers.first() 返回 Option,正好帮你处理了空列表的情况。

查看答案
fn largest(numbers: &[i32]) -> Option<i32> {
let mut biggest = *numbers.first()?;
for &n in numbers {
if n > biggest {
biggest = n;
}
}
Some(biggest)
}
fn main() {
println!("{:?}", largest(&[3, 41, 7, 12]));
println!("{:?}", largest(&[-5, -2, -9]));
println!("{:?}", largest(&[]));
}

输出:

Some(41)
Some(-2)
None
▶ 动手试一试

列表为空时,numbers.first()? 会提前返回 None。这就是函数返回 Option 的原因:“什么都没有时的最大值”没有合理的答案,Rust 要你把这一点明确写出来。在实际代码里,numbers.iter().max() 能做同样的事。

用 0 而不是第一个元素作为初始值,是一个经典 bug:列表 [-5, -2, -9] 会错误地得到 0。

统计一句话里每个单词出现了多少次。忽略大小写和标点,并按字母顺序打印单词。

提示:用 BTreeMap<String, usize>。它用起来像 HashMap,但会让键保持有序。map.entry(word).or_insert(0) 会给你一个计数器,往上加 1 就行。

查看答案
use std::collections::BTreeMap;
fn main() {
let text = "The cat sat on the mat. The end!";
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for word in text.split_whitespace() {
let clean = word
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
if clean.is_empty() {
continue;
}
*counts.entry(clean).or_insert(0) += 1;
}
for (word, count) in &counts {
println!("{word}: {count}");
}
}

输出:

cat: 1
end: 1
mat: 1
on: 1
sat: 1
the: 3
▶ 动手试一试

为什么用 BTreeMap 而不是 HashMap?HashMap 给出元素的顺序是不可预测的,每次运行都可能不一样。如果需要有序的输出,就用 BTreeMap,或者把 HashMap 收集到一个 Vec 里再排序。

trim_matches 去掉了 mat. 里的 . 和 end! 里的 !,to_lowercase 让 The 和 the 变成同一个单词。

这里的句子保留了英文:按空格切分单词、按字母排序、区分大小写,这些都是英文文本才有的情况,中文句子里的词之间并没有空格。

给定 vec![1, 2, 3, 4, 5, 6, 7, 8],构建一个新的 vector,只包含偶数的平方:[4, 16, 36, 64]。然后打印它们的和。用迭代器方法,不要用 for 循环。

提示:把 .iter()、.filter(...)、.map(...) 和 .collect() 串起来。然后用 .iter().sum() 求和。

查看答案
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8];
let squares: Vec<i32> = numbers
.iter()
.filter(|n| *n % 2 == 0)
.map(|n| n * n)
.collect();
let total: i32 = squares.iter().sum();
println!("{:?}", squares);
println!("总和:{total}");
}

输出:

[4, 16, 36, 64]
总和:120
▶ 动手试一试

像读菜谱一样从上往下读这条链:拿到这些数,留下偶数,每个求平方,放进一个 Vec。迭代器是惰性的,在 .collect() 要结果之前,什么都不会真正执行。见迭代器。

写 sum_numbers(input: &str) -> Result<i32, ParseIntError>。它接收像 "1, 2, 3" 这样的文本,解析其中每个数字并返回总和。只要有一段不是数字,就返回错误,而不是让程序崩溃。

提示:按 ',' 切分,对每一段调用 .trim(),再调用 .parse()。用 ? 运算符在出错时提前返回。

查看答案
use std::num::ParseIntError;
fn sum_numbers(input: &str) -> Result<i32, ParseIntError> {
let mut total = 0;
for piece in input.split(',') {
let n: i32 = piece.trim().parse()?;
total += n;
}
Ok(total)
}
fn main() {
for input in ["1, 2, 3", "10,20, 30", "4, five, 6", ""] {
match sum_numbers(input) {
Ok(sum) => println!("{input:?} -> 总和是 {sum}"),
Err(e) => println!("{input:?} -> 错误:{e}"),
}
}
}

输出:

"1, 2, 3" -> 总和是 6
"10,20, 30" -> 总和是 60
"4, five, 6" -> 错误:invalid digit found in string
"" -> 错误:cannot parse integer from empty string
▶ 动手试一试

错误信息本身(invalid digit found in string 表示“字符串里有无效的数字”,cannot parse integer from empty string 表示“无法把空字符串解析成整数”)来自标准库,所以是英文。

? 一个字符做了两件事:如果 parse() 给出 Ok(n),你就拿到 n;如果给出 Err(e),函数立刻返回 Err(e)。这也是为什么这个函数本身也必须返回 Result。

附加题:Result 也能配合 .sum() 使用,所以下面这一行做的是同样的事,并且会在遇到第一个错误时停下:

input.split(',').map(|p| p.trim().parse::<i32>()).sum()

每个用户可能有邮箱,也可能没有。写 email_domain(user: &User) -> Option<&str>,返回 @ 后面的部分。没有邮箱,或者邮箱里没有 @ 时,返回 None。然后按名字查找用户,打印他们的域名。

提示:user.email.as_deref() 把 Option<String> 转成 Option<&str>。email.split_once('@') 返回 Option<(&str, &str)>。? 运算符对 Option 也有效。见 Option。

查看答案
struct User {
name: String,
email: Option<String>,
}
fn email_domain(user: &User) -> Option<&str> {
let email = user.email.as_deref()?;
let (_, domain) = email.split_once('@')?;
Some(domain)
}
fn main() {
let users = vec![
User { name: String::from("Ana"), email: Some(String::from("[email protected]")) },
User { name: String::from("Ben"), email: None },
User { name: String::from("Cleo"), email: Some(String::from("cleo-at-home")) },
];
for name in ["Ana", "Ben", "Cleo", "Dan"] {
let domain = users
.iter()
.find(|u| u.name == name)
.and_then(email_domain)
.unwrap_or("(没有域名)");
println!("{name}: {domain}");
}
}

输出:

Ana: example.com
Ben: (没有域名)
Cleo: (没有域名)
Dan: (没有域名)
▶ 动手试一试

这里有三种不同的“失败”:Dan 不存在,Ben 没有邮箱,Cleo 的邮箱里没有 @。每一种都是一个 Option 步骤,? 或 .and_then() 会在遇到第一个 None 时停下。不需要 if 检查,也不会有空指针错误。最后的 .unwrap_or(...) 负责给出默认值。

创建一个 Account 结构体,包含户主和余额。给它加上 deposit(amount) 和 withdraw(amount) 两个方法。withdraw 必须返回 Result<u64, String>:成功时返回新余额,钱不够时返回错误信息。余额永远不能低于零。

提示:会修改账户的方法要接收 &mut self。先检查余额,再做减法。见结构体和 Result。

查看答案
struct Account {
owner: String,
balance: u64,
}
impl Account {
fn new(owner: &str) -> Account {
Account { owner: owner.to_string(), balance: 0 }
}
fn deposit(&mut self, amount: u64) {
self.balance += amount;
}
fn withdraw(&mut self, amount: u64) -> Result<u64, String> {
if amount > self.balance {
return Err(format!(
"无法取出 {amount}:余额只有 {}",
self.balance
));
}
self.balance -= amount;
Ok(self.balance)
}
}
fn main() {
let mut account = Account::new("Ana");
account.deposit(100);
for amount in [30, 50, 40] {
match account.withdraw(amount) {
Ok(left) => println!("取出 {amount},还剩 {left}"),
Err(e) => println!("错误:{e}"),
}
}
println!("最后 {} 的余额是 {}", account.owner, account.balance);
}

输出:

取出 30,还剩 70
取出 50,还剩 20
错误:无法取出 40:余额只有 20
最后 Ana 的余额是 20
▶ 动手试一试

用 u64(无符号整数)是一层额外的安全网:它根本存不下负数余额。如果你忘了检查,self.balance -= amount 在 debug 模式下会以 “attempt to subtract with overflow”(减法溢出)panic,而不是悄悄变成负数。

定义一个 trait Shape,包含两个方法:area(&self) -> f64 和 name(&self) -> String。为 Circle(有半径)和 Rectangle(有宽和高)实现它。把两种形状放进同一个 Vec,打印每个形状的名字和面积,保留 2 位小数。

提示:要在一个 Vec 里存放不同的类型,用 Vec<Box<dyn Shape>>。见 trait 和 trait 对象。{:.2} 会打印 2 位小数。

查看答案
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> String;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn name(&self) -> String {
format!("圆(r = {})", self.radius)
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn name(&self) -> String {
format!("长方形({} x {})", self.width, self.height)
}
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 3.0, height: 4.0 }),
Box::new(Circle { radius: 2.5 }),
];
let mut total = 0.0;
for shape in &shapes {
println!("{}:{:.2}", shape.name(), shape.area());
total += shape.area();
}
println!("总面积:{total:.2}");
}

输出:

圆(r = 1):3.14
长方形(3 x 4):12.00
圆(r = 2.5):19.63
总面积:34.78
▶ 动手试一试

循环并不知道、也不关心手里拿的是哪种形状。它只知道每个元素“是一个 Shape”,所以可以调用 area() 和 name()。以后要加一个 Triangle,只需要再写一个 impl,循环一行都不用改。

回到第 7 题,但这次让它适用于任何可以比较的类型:整数、小数、字符和字符串。函数签名是 largest<T: PartialOrd>(items: &[T]) -> Option<&T>。

提示:T: PartialOrd 的意思是“T 支持 < 和 >”。返回一个引用(&T),这样就不需要复制或克隆元素。见泛型。

查看答案
fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> {
let mut biggest = items.first()?;
for item in items {
if item > biggest {
biggest = item;
}
}
Some(biggest)
}
fn main() {
println!("{:?}", largest(&[3, 41, 7, 12]));
println!("{:?}", largest(&[2.5, -1.0, 9.75]));
println!("{:?}", largest(&['r', 'u', 's', 't']));
println!("{:?}", largest(&["pear", "apple", "zucchini"]));
let empty: Vec<i32> = Vec::new();
println!("{:?}", largest(&empty));
}

输出:

Some(41)
Some(9.75)
Some('u')
Some("zucchini")
None
▶ 动手试一试

函数体和第 7 题几乎一样,真正的变化只有 <T: PartialOrd> 这一部分。没有这个约束,编译器会拒绝 item > biggest,因为并不是所有类型都能比较。字符串按字母顺序比较,所以 "zucchini"(西葫芦)最大。

注意空 vector 上的 Vec<i32>:没有任何元素时,Rust 猜不出 T 是什么,所以你得告诉它。

一份客人名单里,有些名字写了不止一次。写 find_repeats(words: &[&str]) -> Vec<String>,返回所有出现超过一次的名字,每个只列一次,按字母顺序排列。另外再打印一共有多少位不同的客人。

提示:HashSet 能记住你已经见过哪些值。它的 insert 方法在值已经存在时返回 false。HashSet 没有固定的顺序,所以最后要对结果排序。

查看答案
use std::collections::HashSet;
fn find_repeats(words: &[&str]) -> Vec<String> {
let mut seen = HashSet::new();
let mut repeats = HashSet::new();
for &word in words {
// 如果这个名字已经在集合里,insert 会返回 false
if !seen.insert(word) {
repeats.insert(word);
}
}
let mut result: Vec<String> = repeats.into_iter().map(String::from).collect();
result.sort();
result
}
fn main() {
let guests = ["ana", "ben", "cleo", "ben", "dan", "ana", "ben"];
println!("重复的名字:{:?}", find_repeats(&guests));
let unique: HashSet<&str> = guests.iter().copied().collect();
println!("不同的客人:{} 位", unique.len());
println!("重复的名字:{:?}", find_repeats(&["x", "y", "z"]));
}

输出:

重复的名字:["ana", "ben"]
不同的客人:4 位
重复的名字:[]
▶ 动手试一试

为什么要两个集合?seen 存放目前为止见过的每个名字,repeats 存放第二次遇到的名字。"ben" 出现了三次,但因为 repeats 是一个集合,它只会被存一次。

收集到 HashSet 里也是统计不同值个数最快的办法:重复的值会自动消失。如果没有 result.sort() 这一行,重复名字的顺序在每次运行时都可能不同。

把一组单词从短到长排序。两个单词长度相同时,按 A 到 Z 的顺序排列,并且忽略大小写。然后把你的结果和普通的 .sort() 比较一下。

提示:sort_by 接收一个闭包,闭包比较两个元素并返回一个 Ordering。.then_with(...) 加上第二个比较条件,只在前一个条件打平时才起作用。见排序。

查看答案
fn main() {
let mut words = vec!["pear", "Fig", "banana", "apple", "kiwi", "Cherry", "date"];
let mut plain = words.clone();
plain.sort();
println!("普通排序: {plain:?}");
words.sort_by(|a, b| {
a.len()
.cmp(&b.len())
.then_with(|| a.to_lowercase().cmp(&b.to_lowercase()))
});
println!("按长度排序:{words:?}");
}

输出:

普通排序: ["Cherry", "Fig", "apple", "banana", "date", "kiwi", "pear"]
按长度排序:["Fig", "date", "kiwi", "pear", "apple", "banana", "Cherry"]
▶ 动手试一试

单词本身保留了英文,因为这道题按 len() 排序,而 len() 数的是字节,不是字符。

看看普通排序的结果:"Cherry" 和 "Fig" 排在最前面。字符串是按字符编码比较的,所有大写字母都排在所有小写字母前面。所以我们在打平时比较的是 to_lowercase() 之后的版本。

同样的规则还有更短的写法:words.sort_by_key(|w| (w.len(), w.to_lowercase()))。元组会逐个字段比较,所以先由长度决定,长度相同时再由小写后的单词决定。

为下面这个枚举写 shipping_cost(p: Package) -> String,只用一个 match:

enum Package {
Letter { grams: u32 },
Parcel { kg: u32, express: bool },
Pickup,
}

规则:自提免费。20 克以内的信件 1 美元,更重的信件 3 美元。超过 30 公斤的包裹根本不能寄。加急包裹 10 美元,每公斤再加 2 美元。其他包裹 5 美元,每公斤再加 1 美元。

提示:match 分支可以在模式后面加一个 if,叫做守卫(guard):Package::Letter { grams } if grams <= 20 => ...。分支从上到下依次检查,所以要把“太重”的规则放在加急规则前面。见模式匹配。

查看答案
enum Package {
Letter { grams: u32 },
Parcel { kg: u32, express: bool },
Pickup,
}
fn shipping_cost(p: Package) -> String {
match p {
Package::Pickup => String::from("免费(自提)"),
Package::Letter { grams } if grams <= 20 => String::from("$1(普通信件)"),
Package::Letter { grams } => format!("$3(大信件,{grams} g)"),
Package::Parcel { kg, .. } if kg > 30 => format!("太重了,无法寄送({kg} kg)"),
Package::Parcel { kg, express: true } => format!("${}(加急)", 10 + kg * 2),
Package::Parcel { kg, express: false } => format!("${}(普通包裹)", 5 + kg),
}
}
fn main() {
let packages = vec![
Package::Letter { grams: 15 },
Package::Letter { grams: 80 },
Package::Parcel { kg: 4, express: false },
Package::Parcel { kg: 4, express: true },
Package::Parcel { kg: 35, express: true },
Package::Pickup,
];
for p in packages {
println!("{}", shipping_cost(p));
}
}

输出:

$1(普通信件)
$3(大信件,80 g)
$9(普通包裹)
$18(加急)
太重了,无法寄送(35 kg)
免费(自提)
▶ 动手试一试

35 公斤的加急包裹是最有意思的一个。它能匹配两个分支,但只有第一个生效。如果你把加急分支放在“太重”分支上面,它就会高高兴兴地为一个根本寄不出去的包裹收 80 美元。

注意最后一个分支写的是 express: false,而不是 ..。这不是必须的,但能让这个分支明确说出它处理的是哪种情况。编译器仍然会检查是否覆盖了所有情况。守卫不参与这项检查,所以那个不带守卫的 Package::Letter { grams } 分支必须存在。

一份仓库报告每行一种货物,比如 "apples 12"。有些行是坏的:要么是空行,要么数量不是数字。用迭代器方法(不用 for 循环),只保留能解析并且数量大于零的行。把它们按 name: count 的格式用逗号连起来打印,然后打印总数。

提示:.lines() 把文本切成一行一行。filter_map 只保留闭包返回的 Some 值,所以能一步完成过滤和转换。.ok() 把 Result 转成 Option。见迭代器。

查看答案
fn main() {
let report = "apples 12\nbananas x\npears 0\n\ncherries 40\nplums 7";
let in_stock: Vec<(&str, u32)> = report
.lines()
.filter_map(|line| line.split_once(' '))
.filter_map(|(name, count)| count.parse::<u32>().ok().map(|n| (name, n)))
.filter(|&(_, n)| n > 0)
.collect();
let list: Vec<String> = in_stock
.iter()
.map(|(name, n)| format!("{name}: {n}"))
.collect();
let total: u32 = in_stock.iter().map(|(_, n)| n).sum();
println!("{}", list.join(", "));
println!("总件数:{total}");
}

输出:

apples: 12, cherries: 40, plums: 7
总件数:59
▶ 动手试一试

一次跟踪一行。空行里没有空格,所以 split_once 返回 None,第一个 filter_map 就把它丢掉了。"bananas x" 能正常切分,但 "x" 解析不了,所以第二个 filter_map 把它丢掉。"pears 0" 通过了前两关,然后因为数量是零,被 filter 去掉了。

我们只 collect 一次得到一个 Vec,然后遍历它两次:一次拼文本,一次算总数。迭代器只能用一次,所以把清理好的数据存起来,比把所有内容解析两遍更简单。

如果你做完了全部 18 道题,干得漂亮,这是实实在在的进步。🦀