コンテンツにスキップ

Rust練習問題

Rustについて読むのは役に立ちます。でも、身につくのは実際にRustを書いたときです。このページには小さな問題が18問あります。テーマごとに分けて、やさしいものから難しいものへ順に並べました。

それぞれの問題では、まず課題を読んで自分で挑戦してみてください。解答を開くのはそのあとです。行き詰まったり、コンパイラのエラーが出たりしてもまったく問題ありません。それも学習のうちですし、Rustのエラーメッセージはたいてい最高のヒントになります。

練習のしかた:別のタブで Rust Playground を開くか、cargo new practice でプロジェクトを作りましょう(Rustを始めるを参照)。そこで答えを書いて、解答と見比べます。書き方が違っても同じ結果が表示されるなら、たぶん大丈夫です。良いやり方はほとんどの場合ひとつではありません。

1から15までの数を表示します。ただし、3の倍数のときは Fizz、5の倍数のときは Buzz、3と5の両方の倍数のときは FizzBuzz と表示してください。

ヒント:1..=15 を回す for ループと、%(余り)演算子を使います。「両方の倍数」を最初にチェックしましょう。

解答を見る
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() で文字を1つずつ取り出せます。.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なので、ñ のような文字は2バイトを使います(日本語の文字なら3バイトです)。バイト単位で逆にすると、文字が壊れて不正なテキストになってしまいます。.chars() なら文字をまるごと扱えます。くわしくは文字列を見てください。

文字列に含まれる母音(a, e, i, o, u)を数える count_vowels(s: &str) -> usize を書いてください。小文字も大文字も数えます。

ヒント:.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)を渡します。そのため、char そのものを取り出すために *c と書いています。このパターンはクロージャとイテレータのページで説明しています。

このプログラムはコンパイルできません。shout 関数の引数と、2か所の呼び出しだけを変えて、HELLO! が2回表示されるようにしてください。

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 を使っている」と言っています。1回目の shout(word) で値がムーブされ、2回目で使えなくなっているということです。

ヒント: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() も提案してきます。それでも動きますが、意味もなく文字列をまるごとコピーすることになります。ここでは借用で直すのがベストです。

このプログラムもコンパイルできません。リストに Ben を追加したまま、Ana と表示されるようにしてください。

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 がベクタを変更するとき、first はまだ使われています。借用を参照してください。

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

実行結果:

Ana
▶ 実際に試してみよう

なぜRustはここまで厳しいのでしょうか。push で場所が足りなくなると、ベクタはすべての要素をメモリ上の新しい、もっと広い場所へ移すことがあります。そうなると first は古い場所を指したままになり、その場所はもう有効ではありません。Rustのルール(読み手はいくつでも、または書き手は1つだけ。両方を同時には持てない)は、このバグをプログラムの実行前に防いでくれます。push のあとで参照を取れば解決です。

最初の単語(最初のスペースより前の部分)を返す first_word(s: &str) -> &str を書いてください。スペースがなければ文字列全体を返します。新しい String は作らずに、元の文字列のスライスを返してください。

ヒント:s.find(' ') は最初のスペースの位置を Option<usize> で返します。&s[..i] は先頭から i までのスライスです。

解答を見る
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 は入力と同じメモリを指しているので、何もコピーされません。コンパイラは、結果が入力より長く生きられないことを知っています。これがライフタイムの省略の働きです。参照を1つ受け取って1つ返すなら、ライフタイムを自分で書く必要はありません。

もっと短く書くなら s.split_whitespace().next().unwrap_or("") です。こちらは先頭の余分なスペースも読み飛ばします。

(ここでは文字列を英語のままにしています。スライスはバイト位置で切り出すので、1文字が3バイトの日本語だと位置の考え方が変わってしまうからです。)

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 から始めるのは典型的なバグです。[-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
▶ 実際に試してみよう

なぜ HashMap ではなく BTreeMap なのでしょうか。HashMap は要素を予測できない順番で返し、その順番は実行するたびに変わることもあります。並べ替えた結果が必要なら BTreeMap を使うか、HashMap を Vec に集めてからソートしましょう。

trim_matches は mat. から . を、end! から ! を取り除きます。to_lowercase によって The と the が同じ単語になります。

(この例の文章が英語のままなのは、スペースで単語を区切っているからです。日本語の文章は単語の間にスペースを入れないので、この方法では分けられません。)

vec![1, 2, 3, 4, 5, 6, 7, 8] から、偶数だけを2乗した新しいベクタ [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
▶ 実際に試してみよう

このチェーンは、レシピのように上から下へ読んでいきます。数を取り出し、偶数だけを残し、それぞれを2乗して、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
▶ 実際に試してみよう

エラーメッセージの部分はRust自身が出すものなので英語です。invalid digit found in string は「文字列に数字ではない文字があった」、cannot parse integer from empty string は「空の文字列は整数として読み取れない」という意味です。

? はたった1文字で2つの仕事をします。parse() が Ok(n) を返せば n が手に入ります。Err(e) を返せば、関数はその場で Err(e) を返します。だからこの関数も Result を返す必要があるのです。

おまけ: Result は .sum() とも組み合わせられます。次の1行でも同じことができ、最初のエラーで止まります。

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: (ドメインなし)
▶ 実際に試してみよう

ここでは「失敗」のしかたが3通りあります。Danは存在しない、Benにはメールアドレスがない、Cleoのメールアドレスには @ がない。どれも Option の1ステップで、? や .and_then() は最初の None で止まります。if でのチェックも、ヌルポインタのエラーもありません。最後の .unwrap_or(...) がデフォルト値を用意してくれます。

持ち主(owner)と残高(balance)を持つ Account 構造体を作ってください。deposit(amount) と withdraw(amount) を用意します。withdraw は Result<u64, String> を返す必要があります。うまくいったら新しい残高を、お金が足りなければエラーメッセージを返します。残高が0より下になってはいけません。

ヒント:口座を変更するメソッドは &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 が “attempt to subtract with overflow”(引き算でオーバーフローした)というメッセージでパニックします。黙ってマイナスになることはありません。

area(&self) -> f64 と name(&self) -> String の2つのメソッドを持つトレイト Shape を定義してください。それを Circle(半径を持つ)と Rectangle(幅と高さを持つ)に実装します。両方を1つの Vec に入れて、それぞれの名前と面積を小数点以下2桁で表示しましょう。

ヒント:異なる型を1つの Vec に入れるには Vec<Box<dyn Shape>> を使います。トレイトとトレイトオブジェクトを参照してください。{:.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 を1つ書くだけで、ループはまったく変えなくて済みます。

問題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" が最大になります。

空のベクタに Vec<i32> と書いている点にも注目してください。要素が1つもないと、Rustは T が何なのかを推測できないので、教えてあげる必要があります。

セット・並べ替え・パターン・イテレータ

Section titled “セット・並べ替え・パターン・イテレータ”

招待客リストに、何度も書かれている名前があります。2回以上出てくる名前をすべて、1つにつき1回だけ、アルファベット順で返す 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
重複: []
▶ 実際に試してみよう

なぜセットが2つあるのでしょうか。seen には、それまでに出てきた名前がすべて入ります。repeats には、2回目に出会った名前が入ります。"ben" は3回出てきますが、repeats はセットなので、保存されるのは1回だけです。

HashSet に collect するのは、重複しない値を数えるいちばん手っ取り早い方法でもあります。重複は自然に消えてしまうからです。result.sort() の行がないと、重複した名前の順番が実行するたびに変わることがあります。

単語のリストを、短いものから長いものへ並べ替えてください。長さが同じ単語どうしは、大文字・小文字を区別せずに、AからZの順に並べます。そのあと、ふつうの .sort() の結果と見比べてみましょう。

ヒント:sort_by は、2つの要素を比べて Ordering を返すクロージャを受け取ります。.then_with(...) を使うと、同点のときだけ効く2つ目の比較を追加できます。並べ替えと検索を参照してください。

解答を見る
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() はバイト数を返すので、日本語の単語(1文字3バイト)だと、見た目の文字数とは違う順番になってしまうからです。

ふつうのソートの結果を見てください。"Cherry" と "Fig" が先頭に来ています。文字列は文字コードで比べられ、大文字はすべて小文字より前に来るからです。だから、同点のときの比較では to_lowercase() したものどうしを比べています。

同じルールは、words.sort_by_key(|w| (w.len(), w.to_lowercase())) と短く書くこともできます。タプルは要素を1つずつ順に比べるので、まず長さで決まり、同じ長さなら小文字にした単語で決まります。

17. マッチガードで送料を計算する

Section titled “17. マッチガードで送料を計算する”

次の列挙型について、match を1つだけ使って shipping_cost(p: Package) -> String を書いてください。

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

ルールは次のとおりです。店頭受け取りは無料です。手紙は20 gまでなら$1、それより重ければ$3です。30 kgを超える小包はそもそも発送できません。速達の小包は$10に1 kgあたり$2を足した額、それ以外の小包は$5に1 kgあたり$1を足した額です。

ヒント:match のアームでは、パターンのあとに if を書けます。これをガードと呼びます(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 kgの速達の小包です。これは2つのアームにマッチしますが、採用されるのは最初の1つだけです。もし速達のアームを「重すぎる」アームより上に置いたら、発送できない小包に平然と$80を請求してしまいます。

最後のアームが .. ではなく express: false と書いている点にも注目してください。必須ではありませんが、こうするとそのアームが何を扱うのかがはっきりします。すべてのケースが網羅されているかは、コンパイラがちゃんとチェックしてくれます。ただし、ガードはそのチェックの対象になりません。だから、ガードのない Package::Letter { grams } のアームが必要なのです。

18. 在庫レポートをきれいにする

Section titled “18. 在庫レポートをきれいにする”

倉庫のレポートには、"りんご 12" のように1行に1品目が書かれています。ところが、空行だったり、個数が数値になっていなかったりする壊れた行も混じっています。イテレータのメソッドだけを使って(for ループは使わずに)、パースでき、しかも個数が0より大きい行だけを残してください。それらを 名前: 個数 の形でカンマ区切りで表示し、最後に合計を表示します。

ヒント:.lines() でテキストを行に分けられます。filter_map はクロージャが返した Some の値だけを残すので、絞り込みと変換を一度にできます。.ok() は Result を Option に変えます。イテレータを参照してください。

解答を見る
fn main() {
let report = "りんご 12\nバナナ x\n洋なし 0\n\nさくらんぼ 40\nすもも 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}");
}

実行結果:

りんご: 12, さくらんぼ: 40, すもも: 7
合計個数: 59
▶ 実際に試してみよう

1行ずつ追ってみましょう。空行にはスペースがないので、split_once は None を返し、最初の filter_map で取り除かれます。"バナナ x" はうまく分割できますが、"x" がパースできないので、2つ目の filter_map で取り除かれます。"洋なし 0" はどちらも通り抜けますが、個数が0なので filter で取り除かれます。

Vec への collect は1回だけにして、それを2回たどっています。1回目はテキストを作るため、2回目は合計を出すためです。イテレータは一度しか使えないので、すべてを2回パースするより、きれいにしたデータを保存しておくほうが簡単です。

18問すべて解けたなら、お見事です。確かな前進です。🦀