Bỏ qua để đến nội dung

Bài tập Rust có lời giải

Đọc về Rust thì có ích. Nhưng phải viết Rust thì kiến thức mới “ngấm”. Trang này có 18 bài tập nhỏ, sắp xếp từ dễ đến khó hơn và chia theo chủ đề.

Với mỗi bài, hãy đọc đề, tự làm thử trước, rồi mới mở lời giải. Bị kẹt hay gặp lỗi biên dịch là chuyện hoàn toàn bình thường. Đó là một phần của việc học, và thông báo lỗi của Rust thường là gợi ý tốt nhất mà bạn có được.

Cách luyện tập: mở Rust Playground trong một tab khác, hoặc tạo một dự án bằng cargo new practice (xem Bắt đầu). Viết lời giải của bạn ở đó. Sau đó so sánh với lời giải mẫu. Nếu lời giải của bạn khác nhưng in ra cùng một kết quả, thì nhiều khả năng là ổn. Gần như lúc nào cũng có nhiều hơn một cách làm tốt.

Ở nhiều bài, các chuỗi trong code được giữ bằng tiếng Anh: khi chương trình đếm chữ cái, đảo ngược, cắt chuỗi theo byte, đổi chữ hoa/thường hay sắp xếp theo bảng chữ cái, chữ tiếng Việt có dấu sẽ cho kết quả khác.

In các số từ 1 đến 15. Nhưng với bội số của 3 thì in Fizz, bội số của 5 thì in Buzz, và bội số của cả hai thì in FizzBuzz.

Gợi ý: dùng vòng lặp for trên 1..=15 và toán tử % (chia lấy dư). Hãy kiểm tra “bội số của cả hai” trước tiên.

Xem lời giải
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}");
}
}
}

Kết quả:

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
▶ Tự chạy thử

Thứ tự rất quan trọng. Nếu bạn kiểm tra n % 3 == 0 trước, thì 15 sẽ in Fizz và không bao giờ tới được nhánh FizzBuzz. Một số chia hết cho cả 3 và 5 thì chia hết cho 15.

Viết hàm reverse(s: &str) -> String trả về văn bản viết ngược. "stressed" phải trở thành "desserts".

Gợi ý: .chars() cho bạn từng ký tự một, .rev() đảo ngược một iterator, và .collect() có thể tạo ra một String.

Xem lời giải
fn reverse(s: &str) -> String {
s.chars().rev().collect()
}
fn main() {
println!("{}", reverse("stressed"));
println!("{}", reverse("hello"));
println!("{}", reverse("año"));
}

Kết quả:

desserts
olleh
oña
▶ Tự chạy thử

Vì sao dùng .chars() mà không dùng byte? String trong Rust là UTF-8, nên một ký tự như ñ (hay ă, ơ trong tiếng Việt) chiếm hai byte. Đảo ngược các byte sẽ làm vỡ nó thành văn bản không hợp lệ. .chars() làm việc với nguyên các ký tự. Xem thêm ở Chuỗi.

Viết count_vowels(s: &str) -> usize đếm số nguyên âm (a, e, i, o, u) trong một chuỗi. Cả chữ thường lẫn chữ hoa đều được tính.

Gợi ý: .chars().filter(...).count(). Một chuỗi như "aeiouAEIOU" có phương thức .contains(c) để bạn kiểm tra từng ký tự.

Xem lời giải
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"));
}

Kết quả:

3
3
10
0
▶ Tự chạy thử

filter đưa cho closure một tham chiếu tới từng phần tử (&char), nên chúng ta viết *c để lấy chính char đó. Các trang Closure và Iterator giải thích mẫu viết này.

Chương trình này không biên dịch được. Chỉ thay đổi duy nhất tham số của hàm shout và hai lời gọi, để nó in HELLO! hai lần.

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

Trình biên dịch (bằng tiếng Anh) nói rằng word đã bị move ở dòng 7 rồi lại được dùng ở dòng 8. Gợi ý: shout chỉ cần xem văn bản, không cần sở hữu nó. Xem Quyền sở hữu.

Xem lời giải
fn shout(text: &str) {
println!("{}!", text.to_uppercase());
}
fn main() {
let word = String::from("hello");
shout(&word);
shout(&word);
}

Kết quả:

HELLO!
HELLO!
▶ Tự chạy thử

Khi một hàm nhận String, gọi nó sẽ move chuỗi vào trong, và main không dùng lại được nữa. Nhận &str nghĩa là hàm chỉ mượn nó. Trình biên dịch cũng gợi ý word.clone(), cách đó cũng chạy, nhưng nó sao chép cả chuỗi mà chẳng để làm gì. Mượn là cách sửa tốt hơn ở đây.

Chương trình này cũng không biên dịch được. Hãy làm cho nó in ra Ana, nhưng vẫn thêm Ben vào danh sách.

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

Trình biên dịch nói rằng không thể mượn khả biến names vì nó đang được mượn bất biến. Gợi ý: vấn đề nằm ở thứ tự. first vẫn đang được dùng khi push thay đổi vector. Xem Mượn.

Xem lời giải
fn main() {
let mut names = vec![String::from("Ana")];
names.push(String::from("Ben"));
let first = &names[0];
println!("{first}");
}

Kết quả:

Ana
▶ Tự chạy thử

Vì sao Rust khắt khe đến vậy ở đây? push có thể cần thêm chỗ, nên vector có thể chuyển mọi phần tử sang một vùng nhớ mới, lớn hơn. Khi đó first sẽ trỏ tới vùng nhớ cũ, vốn không còn hợp lệ. Quy tắc của Rust (nhiều người đọc hoặc một người ghi, không bao giờ cả hai cùng lúc) chặn bug đó trước khi chương trình chạy. Lấy tham chiếu sau lời gọi push là giải quyết xong.

Viết first_word(s: &str) -> &str trả về từ đầu tiên (mọi thứ trước dấu cách đầu tiên). Nếu không có dấu cách, trả về cả chuỗi. Đừng tạo String mới; hãy trả về một slice của chuỗi gốc.

Gợi ý: s.find(' ') trả về Option<usize> chứa vị trí của dấu cách đầu tiên. &s[..i] là một slice từ đầu đến i.

Xem lời giả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(""));
}

Kết quả:

"hello"
"Rust"
""
▶ Tự chạy thử

&str được trả về trỏ vào cùng vùng nhớ với đầu vào, nên không có gì bị sao chép. Trình biên dịch biết kết quả không thể sống lâu hơn đầu vào. Đó là lược bỏ lifetime (lifetime elision) đang làm việc: với một tham chiếu vào và một tham chiếu ra, bạn không phải tự viết lifetime.

Một phiên bản ngắn hơn là s.split_whitespace().next().unwrap_or(""), nó còn bỏ qua các dấu cách thừa ở đầu.

Viết largest(numbers: &[i32]) -> Option<i32>. Hàm trả về số lớn nhất, hoặc None nếu danh sách rỗng. Lần này đừng dùng .max() có sẵn; hãy tự viết vòng lặp.

Gợi ý: bắt đầu với phần tử đầu tiên làm “số lớn nhất tới giờ”. numbers.first() trả về một Option, nên nó lo luôn trường hợp danh sách rỗng giúp bạn.

Xem lời giải
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(&[]));
}

Kết quả:

Some(41)
Some(-2)
None
▶ Tự chạy thử

numbers.first()? trả về None sớm nếu danh sách rỗng. Đó là lý do hàm trả về Option: “số lớn nhất của chẳng có gì” không có câu trả lời hợp lý, và Rust bắt bạn nói rõ điều đó. Trong code thật, numbers.iter().max() làm đúng việc này.

Bắt đầu bằng 0 thay vì phần tử đầu tiên là một bug kinh điển: danh sách [-5, -2, -9] sẽ cho ra 0, một kết quả sai.

Đếm mỗi từ xuất hiện bao nhiêu lần trong một câu. Bỏ qua chữ hoa/thường và dấu câu, và in các từ theo thứ tự bảng chữ cái.

Gợi ý: dùng BTreeMap<String, usize>. Nó hoạt động giống HashMap, nhưng giữ các khóa theo thứ tự. map.entry(word).or_insert(0) cho bạn một bộ đếm để cộng thêm 1.

Xem lời giải
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}");
}
}

Kết quả:

cat: 1
end: 1
mat: 1
on: 1
sat: 1
the: 3
▶ Tự chạy thử

Vì sao dùng BTreeMap mà không dùng HashMap? HashMap trả các phần tử ra theo thứ tự không đoán trước được, và thứ tự đó có thể thay đổi giữa các lần chạy. Nếu bạn cần kết quả được sắp xếp, hãy dùng BTreeMap, hoặc gom HashMap vào một Vec rồi sắp xếp nó.

trim_matches bỏ dấu . khỏi mat. và dấu ! khỏi end!, còn to_lowercase khiến The và the trở thành cùng một từ.

Cho vec![1, 2, 3, 4, 5, 6, 7, 8], hãy tạo một vector mới chỉ chứa bình phương của các số chẵn: [4, 16, 36, 64]. Sau đó in tổng của chúng. Dùng các phương thức của iterator, không dùng vòng lặp for.

Gợi ý: nối chuỗi .iter(), .filter(...), .map(...) và .collect(). Sau đó .iter().sum() cộng chúng lại.

Xem lời giải
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!("Tổng: {total}");
}

Kết quả:

[4, 16, 36, 64]
Tổng: 120
▶ Tự chạy thử

Hãy đọc chuỗi phương thức từ trên xuống như một công thức nấu ăn: lấy các số, giữ lại số chẵn, bình phương từng số, cho chúng vào một Vec. Iterator là “lười” (lazy), nên chẳng có gì thực sự chạy cho đến khi .collect() đòi kết quả. Xem Iterator.

Viết sum_numbers(input: &str) -> Result<i32, ParseIntError>. Hàm nhận văn bản như "1, 2, 3", phân tích từng số, và trả về tổng. Nếu có phần nào không phải số, hãy trả về lỗi thay vì làm sập chương trình.

Gợi ý: tách theo ',', .trim() từng phần, rồi .parse(). Dùng toán tử ? để trả về sớm khi có lỗi.

Xem lời giải
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:?} -> tổng là {sum}"),
Err(e) => println!("{input:?} -> lỗi: {e}"),
}
}
}

Kết quả:

"1, 2, 3" -> tổng là 6
"10,20, 30" -> tổng là 60
"4, five, 6" -> lỗi: invalid digit found in string
"" -> lỗi: cannot parse integer from empty string
▶ Tự chạy thử

Các thông báo lỗi “invalid digit found in string” (gặp chữ số không hợp lệ) và “cannot parse integer from empty string” (không phân tích được số nguyên từ chuỗi rỗng) đến từ thư viện chuẩn của Rust, nên chúng luôn bằng tiếng Anh.

? làm hai việc chỉ trong một ký tự: nếu parse() cho Ok(n), bạn nhận được n. Nếu nó cho Err(e), hàm trả về Err(e) ngay lập tức. Đó là lý do hàm cũng phải trả về một Result.

Mở rộng: Result dùng được với .sum(), nên một dòng sau đây làm đúng việc tương tự và dừng ở lỗi đầu tiên:

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

Mỗi người dùng có thể có hoặc không có email. Viết email_domain(user: &User) -> Option<&str> trả về phần đứng sau dấu @. Trả về None nếu không có email, hoặc nếu email không có @. Sau đó tìm người dùng theo tên và in tên miền của họ.

Gợi ý: user.email.as_deref() biến một Option<String> thành Option<&str>. email.split_once('@') trả về Option<(&str, &str)>. Toán tử ? cũng dùng được với Option. Xem Option.

Xem lời giải
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("(không có tên miền)");
println!("{name}: {domain}");
}
}

Kết quả:

Ana: example.com
Ben: (không có tên miền)
Cleo: (không có tên miền)
Dan: (không có tên miền)
▶ Tự chạy thử

Ở đây có ba kiểu “thất bại” khác nhau: Dan không tồn tại, Ben không có email, và email của Cleo không có @. Mỗi cái là một bước Option, và ? hoặc .and_then() dừng lại ở None đầu tiên. Không cần kiểm tra if, không có lỗi con trỏ null. .unwrap_or(...) đưa ra một giá trị mặc định ở tận cuối.

Tạo struct Account có chủ tài khoản và số dư. Cho nó deposit(amount) (nạp tiền) và withdraw(amount) (rút tiền). withdraw phải trả về Result<u64, String>: số dư mới nếu thành công, hoặc một thông báo lỗi nếu không đủ tiền. Số dư không bao giờ được xuống dưới 0.

Gợi ý: các phương thức thay đổi tài khoản nhận &mut self. Hãy kiểm tra số dư trước khi trừ. Xem Struct và Result.

Xem lời giải
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!(
"không thể rút {amount}: số dư chỉ còn {}",
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!("Đã rút {amount}, còn lại {left}"),
Err(e) => println!("Lỗi: {e}"),
}
}
println!("Cuối cùng {} còn {}", account.owner, account.balance);
}

Kết quả:

Đã rút 30, còn lại 70
Đã rút 50, còn lại 20
Lỗi: không thể rút 40: số dư chỉ còn 20
Cuối cùng Ana còn 20
▶ Tự chạy thử

Dùng u64 (một kiểu số không dấu) là thêm một tấm lưới an toàn: nó hoàn toàn không thể chứa số dư âm. Nếu bạn quên phép kiểm tra, self.balance -= amount sẽ panic với thông báo “attempt to subtract with overflow” (phép trừ bị tràn) ở chế độ debug, thay vì lặng lẽ thành số âm.

Định nghĩa trait Shape với hai phương thức: area(&self) -> f64 và name(&self) -> String. Triển khai nó cho Circle (có bán kính) và Rectangle (có chiều rộng và chiều cao). Cho cả hai vào cùng một Vec và in tên cùng diện tích của từng hình với 2 chữ số thập phân.

Gợi ý: để lưu các kiểu khác nhau trong một Vec, hãy dùng Vec<Box<dyn Shape>>. Xem Trait và Trait object. {:.2} in ra 2 chữ số thập phân.

Xem lời giải
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!("Hình tròn (r = {})", self.radius)
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn name(&self) -> String {
format!("Hình chữ nhật ({} 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!("Tổng diện tích: {total:.2}");
}

Kết quả:

Hình tròn (r = 1): 3.14
Hình chữ nhật (3 x 4): 12.00
Hình tròn (r = 2.5): 19.63
Tổng diện tích: 34.78
▶ Tự chạy thử

Vòng lặp không biết và cũng chẳng quan tâm nó đang cầm hình nào. Nó chỉ biết mỗi phần tử “là một Shape”, nên gọi được area() và name(). Thêm một Triangle về sau chỉ cần viết thêm một impl, còn vòng lặp không đổi chút nào.

Quay lại bài 7, nhưng làm cho nó chạy với mọi kiểu so sánh được: số nguyên, số thực, ký tự và chuỗi. Chữ ký hàm là largest<T: PartialOrd>(items: &[T]) -> Option<&T>.

Gợi ý: T: PartialOrd nghĩa là “T hỗ trợ < và >”. Hãy trả về một tham chiếu (&T) để bạn không phải sao chép hay clone các phần tử. Xem Generic.

Xem lời giải
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));
}

Kết quả:

Some(41)
Some(9.75)
Some('u')
Some("zucchini")
None
▶ Tự chạy thử

Thân hàm gần như giống hệt bài 7. Thay đổi thật sự duy nhất là phần <T: PartialOrd>. Không có ràng buộc đó, trình biên dịch sẽ từ chối item > biggest, vì không phải kiểu nào cũng so sánh được. Chuỗi được so sánh theo thứ tự bảng chữ cái, nên "zucchini" thắng.

Hãy để ý Vec<i32> trên vector rỗng: không có phần tử nào, Rust không đoán được T là gì, nên bạn phải nói cho nó biết.

Một danh sách khách mời có vài cái tên bị ghi nhiều lần. Viết find_repeats(words: &[&str]) -> Vec<String> trả về mọi tên xuất hiện nhiều hơn một lần, mỗi tên chỉ liệt kê một lần, theo thứ tự bảng chữ cái. Đồng thời in ra có bao nhiêu khách khác nhau.

Gợi ý: một HashSet nhớ những gì bạn đã gặp. Phương thức insert của nó trả về false khi giá trị đã có sẵn. HashSet không có thứ tự cố định, nên hãy sắp xếp kết quả ở cuối.

Xem lời giải
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 trả về false nếu từ đã có trong set
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!("Bị lặp: {:?}", find_repeats(&guests));
let unique: HashSet<&str> = guests.iter().copied().collect();
println!("Số khách khác nhau: {}", unique.len());
println!("Bị lặp: {:?}", find_repeats(&["x", "y", "z"]));
}

Kết quả:

Bị lặp: ["ana", "ben"]
Số khách khác nhau: 4
Bị lặp: []
▶ Tự chạy thử

Vì sao cần hai set? seen giữ mọi tên đã gặp tới giờ. repeats giữ những tên mà ta gặp lần thứ hai. "ben" xuất hiện ba lần, nhưng vì repeats là một set, nó chỉ được lưu một lần.

Gom vào một HashSet cũng là cách nhanh nhất để đếm các giá trị không trùng: các bản trùng đơn giản là biến mất. Nếu không có dòng result.sort(), thứ tự của các tên bị lặp có thể thay đổi giữa các lần chạy.

Sắp xếp một danh sách từ, từ ngắn nhất đến dài nhất. Khi hai từ dài bằng nhau, xếp chúng theo thứ tự A đến Z, không phân biệt chữ hoa chữ thường. Sau đó so sánh kết quả của bạn với một lần .sort() thông thường.

Gợi ý: sort_by nhận một closure so sánh hai phần tử và trả về một Ordering. .then_with(...) thêm một phép so sánh thứ hai, chỉ có tác dụng khi hai bên hòa nhau. Xem Sắp xếp.

Xem lời giải
fn main() {
let mut words = vec!["pear", "Fig", "banana", "apple", "kiwi", "Cherry", "date"];
let mut plain = words.clone();
plain.sort();
println!("Sắp xếp thường: {plain:?}");
words.sort_by(|a, b| {
a.len()
.cmp(&b.len())
.then_with(|| a.to_lowercase().cmp(&b.to_lowercase()))
});
println!("Theo độ dài: {words:?}");
}

Kết quả:

Sắp xếp thường: ["Cherry", "Fig", "apple", "banana", "date", "kiwi", "pear"]
Theo độ dài: ["Fig", "date", "kiwi", "pear", "apple", "banana", "Cherry"]
▶ Tự chạy thử

Hãy nhìn lần sắp xếp thường: "Cherry" và "Fig" đứng đầu. Chuỗi được so sánh theo mã ký tự, và mọi chữ hoa đều đứng trước mọi chữ thường. Đó là lý do chúng ta so sánh các phiên bản to_lowercase() khi phân định hòa.

Một cách viết ngắn hơn cho cùng quy tắc là words.sort_by_key(|w| (w.len(), w.to_lowercase())). Tuple được so sánh lần lượt từng trường, nên độ dài quyết định trước, còn từ viết thường phân định khi hòa.

Viết shipping_cost(p: Package) -> String cho enum này, chỉ dùng một match:

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

Quy tắc: tự đến lấy thì miễn phí. Thư đến 20 g giá $1, thư nặng hơn giá $3. Bưu kiện trên 30 kg không gửi được. Bưu kiện chuyển phát nhanh giá $10 cộng $2 mỗi kg. Bưu kiện khác giá $5 cộng $1 mỗi kg.

Gợi ý: một nhánh match có thể có if sau mẫu, gọi là guard: Package::Letter { grams } if grams <= 20 => .... Các nhánh được kiểm tra từ trên xuống, nên hãy đặt quy tắc “quá nặng” trước quy tắc chuyển phát nhanh. Xem So khớp mẫu.

Xem lời giải
enum Package {
Letter { grams: u32 },
Parcel { kg: u32, express: bool },
Pickup,
}
fn shipping_cost(p: Package) -> String {
match p {
Package::Pickup => String::from("miễn phí (tự đến lấy)"),
Package::Letter { grams } if grams <= 20 => String::from("$1 (thư thường)"),
Package::Letter { grams } => format!("$3 (thư lớn, {grams} g)"),
Package::Parcel { kg, .. } if kg > 30 => format!("quá nặng, không gửi được ({kg} kg)"),
Package::Parcel { kg, express: true } => format!("${} (chuyển phát nhanh)", 10 + kg * 2),
Package::Parcel { kg, express: false } => format!("${} (bưu kiện thường)", 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));
}
}

Kết quả:

$1 (thư thường)
$3 (thư lớn, 80 g)
$9 (bưu kiện thường)
$18 (chuyển phát nhanh)
quá nặng, không gửi được (35 kg)
miễn phí (tự đến lấy)
▶ Tự chạy thử

Bưu kiện chuyển phát nhanh 35 kg là trường hợp thú vị. Nó khớp với hai nhánh, nhưng chỉ nhánh đầu tiên thắng. Nếu bạn đặt nhánh chuyển phát nhanh lên trên nhánh “quá nặng”, chương trình sẽ vui vẻ tính $80 cho một bưu kiện mà bạn không gửi được.

Hãy để ý nhánh cuối ghi express: false thay vì ... Điều này không bắt buộc, nhưng nó khiến nhánh nói rõ chính xác nó xử lý trường hợp nào. Trình biên dịch vẫn kiểm tra rằng mọi trường hợp đều được xử lý. Guard không được tính vào phép kiểm tra đó, vì vậy nhánh Package::Letter { grams } không có guard bắt buộc phải có mặt.

Một báo cáo kho hàng có mỗi mặt hàng một dòng, như "apples 12". Vài dòng bị hỏng: dòng trống, hoặc số lượng không phải là số. Dùng các phương thức của iterator (không dùng vòng lặp for), chỉ giữ lại những dòng phân tích được và có số lượng lớn hơn 0. In chúng ra dưới dạng name: count, cách nhau bằng dấu phẩy, rồi in tổng.

Gợi ý: .lines() tách văn bản thành các dòng. filter_map chỉ giữ lại các giá trị Some mà closure của nó trả về, nên nó vừa lọc vừa chuyển đổi chỉ trong một bước. .ok() biến một Result thành Option. Xem Iterator.

Xem lời giải
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!("Tổng số mặt hàng: {total}");
}

Kết quả:

apples: 12, cherries: 40, plums: 7
Tổng số mặt hàng: 59
▶ Tự chạy thử

Hãy theo dõi từng dòng một. Dòng trống không có dấu cách, nên split_once cho None và filter_map đầu tiên bỏ nó đi. "bananas x" tách được, nhưng "x" không phân tích thành số được, nên filter_map thứ hai bỏ nó đi. "pears 0" vượt qua cả hai, rồi filter loại nó vì số lượng bằng 0.

Chúng ta collect vào một Vec một lần, rồi duyệt nó hai lần: một lần để tạo văn bản, một lần để tính tổng. Một iterator chỉ dùng được một lần, nên lưu lại dữ liệu đã làm sạch đơn giản hơn là phân tích mọi thứ hai lần.

Nếu bạn đã làm xong cả 18 bài, xin chúc mừng. Đó là một bước tiến thật sự. 🦀

  • Rustlings (tiếng Anh): bộ bài tập chính thức gồm các chương trình nhỏ bị lỗi để bạn sửa từng cái một trong terminal. Một bước tiếp theo tuyệt vời sau trang này.
  • Exercism Rust track (tiếng Anh): khoảng 100 bài tập có test, và được các mentor là người thật nhận xét lời giải miễn phí.
  • Rust by Example (tiếng Anh): các ví dụ ngắn, chạy được cho từng chủ đề. Rất tiện khi bạn bị kẹt ở cú pháp trong một bài tập.
  • Iterator trait — tài liệu std (tiếng Anh): mọi phương thức của iterator (filter_map, fold, zip và nhiều nữa), mỗi cái có một ví dụ nhỏ.
  • HashSet — tài liệu std (tiếng Anh): mọi phương thức của set, kể cả union, intersection và difference.