The match Control Flow Construct
Rust có một cấu trúc điều khiển rất mạnh gọi là match cho phép bạn so sánh
một giá trị với một chuỗi các mẫu (pattern) và sau đó thực thi mã dựa trên mẫu
nào khớp. Các pattern có thể được tạo thành từ các giá trị literal, tên biến,
wildcard và nhiều thứ khác; Chương 18 bao gồm tất cả các loại pattern khác nhau
và những gì chúng làm. Sức mạnh của match đến từ sự biểu diễn của các mẫu và
sự chắc chắn của trình biên dịch rằng tất cả các trường hợp có thể được xử lý.
Hãy nghĩ về match giống như một máy sắp xếp tiền xu: các đồng xu sẽ trượt
xuống một đường với các lỗ có kích thước khác nhau dọc theo đường, và mỗi tiền
xu sẽ rơi vào lỗ đầu tiên nó gặp mà nó vừa vặn vào. Tương tự như vậy, các giá
trị đi qua lần lượt các pattern trong match, và ở pattern đầu tiên giá trị
“vừa vặn”, giá trị sẽ rơi vào code liên quan để được sử dụng trong quá trình
thực thi.
Nói về những đồng xu, hãy sử dụng chúng làm ví dụ với match! Chúng ta có thể
viết một hàm mà nhận vào một đồng xu của Hoa Kỳ bất kỳ, tương tự như máy đếm
tiền xu, hàm này sẽ xác định đồng xu đó có mệnh giá là gì và trả về mệnh giá
của đồng xu, với đơn vị là cent, như được hiển thị ở đây trong Listing 6-3.
enum Coin { Penny, Nickel, Dime, Quarter, } fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => 1, Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter => 25, } } fn main() {}
Listing 6-3: Một enum và một biểu thức match nhận các
biến thể của enum như là các pattern của nó
Cùng làm rõ match trong hàm value_in_cents. Đầu tiên, chúng ta sẽ liệt kê
từ khóa (keyword) match theo sau là một biểu thức, trong trường hợp này là
giá trị coin. Điều này có vẻ rất giống với biểu thức được sử dụng với if,
nhưng có một sự khác biệt lớn: với if, biểu thức cần trả về một giá trị
Boolean, nhưng ở đây, nó có thể trả về bất kỳ kiểu dữ liệu nào. Kiểu dữ liệu
của coin trong ví dụ này là enum Coin mà chúng ta đã định nghĩa ở dòng đầu
tiên.
Tiếp theo đó là các nhánh (arm) của match. Mỗi nhánh này đều có hai thành
phần: pattern và code. Thành phần đầu tiên ở đây là pattern có giá trị là
Coin::Penny và sau đó là toán tử => phân tách giữa pattern và code để chạy.
Code trong trường hợp này chỉ là giá trị 1. Mỗi nhánh được phân tách với
nhánh tiếp theo bằng dấu phẩy.
Khi biểu thức match được thực thi, nó sẽ so sánh giá trị kết quả với pattern
của mỗi nhánh, theo thứ tự. Nếu một pattern khớp với giá trị, code liên quan
đến pattern đó sẽ được thực thi. Nếu pattern đó không khớp với giá trị, thực
thi sẽ tiếp tục đến nhánh tiếp theo, giống như trong một máy sắp xếp tiền.
Chúng ta có thể có bao nhiêu nhánh cần thiết: trong Listing 6-3, match của
chúng ta có 4 nhánh.
Đoạn code liên quan với mỗi nhánh là một biểu thức (expression), và giá trị kết
quả của biểu thức trong nhánh được khớp sẽ là giá trị được trả về cho toàn bộ
biểu thức match.
Chúng ta thường không sử dụng dấu ngoặc nhọn nếu nhánh của match có đoạn code
ngắn, như trong Listing 6-3, mỗi nhánh chỉ trả về một giá trị. Nếu bạn muốn
chạy nhiều dòng code trong một nhánh của match, bạn phải sử dụng dấu ngoặc
nhọn, và dấu phẩy sau nhánh là tùy chọn. Ví dụ, đoạn code sau in ra “Lucky
penny!” mỗi khi phương thức được gọi với một Coin::Penny, nhưng vẫn trả về
giá trị cuối cùng của khối, 1:
enum Coin { Penny, Nickel, Dime, Quarter, } fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => { println!("Lucky penny!"); 1 } Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter => 25, } } fn main() {}
Patterns that Bind to Values
Một tính năng hữu ích khác của các nhánh của match là chúng có thể gắn với giá trị khi khớp của pattern. Đây là cách chúng ta có thể trích xuất các giá trị từ các biến thể của enum.
Lấy ví dụ, hãy thay đổi một trong các biến thể của enum để chứa dữ liệu bên
trong nó. Từ năm 1999 đến năm 2008, Hoa Kỳ đúc các đồng 25 cent (Quater) cho 50
tiều bang với các thiết kết mặt bên khác nhau. Các loại xu khác không có thiết
kế riêng theo tiểu bang, vì vậy chỉ có bội đồng 25 cent mới có giá trị bổ sung
này. Chúng ta có thể thêm thông tin này vào enum bằng cách thay đổi biến thể
Quarter để bao gồm một giá trị UsState được lưu trữ bên trong nó, như chúng
ta đã làm ở đây trong Listing 6-4.
#[derive(Debug)] // so we can inspect the state in a minute enum UsState { Alabama, Alaska, // --snip-- } enum Coin { Penny, Nickel, Dime, Quarter(UsState), } fn main() {}
Listing 6-4: Một Coin enum trong đó biến thể Quarter cũng chứa một giá trị UsState
Hãy tưởng tượng rằng một người bạn đang cố gắng thu thập tất cả 50 dạng đồng 25 cent này. Trong khi chúng ta chỉ sắp xếp theo loại đồng, chúng ta cũng sẽ gọi tên của tiểu bang liên quan trên đồng 25 cent để nếu đó là một trong những đồng 25 cent mà người bạn của chúng ta không có, anh ấy có thể thêm vào bộ sưu tập của mình.
Bên trong biểu thức match cho đoạn code này, chúng ta thêm một biến gọi là
state vào pattern khớp với các giá trị của biến thể Coin::Quarter. Khi một
Coin::Quarter khớp, biến state sẽ được gắn với giá trị của tiểu bang của
đồng 25 cent đó. Sau đó, chúng ta có thể sử dụng state trong code strong
nhánh này, như sau:
#[derive(Debug)] enum UsState { Alabama, Alaska, // --snip-- } enum Coin { Penny, Nickel, Dime, Quarter(UsState), } fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => 1, Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter(state) => { println!("State quarter from {:?}!", state); 25 } } } fn main() { value_in_cents(Coin::Quarter(UsState::Alaska)); }
Nếu chung ta gọi hàm value_in_cents(Coin::Quarter(UsState::Alaska)), coin
sẽ là Coin::Quarter(UsState::Alaska). Khi chúng ta so sánh giá trị này với
mỗi nhánh trong biểu thức match, không có nhánh nào khớp cho đến khi chúng ta
đến Coin::Quarter(state). Tại thời điểm đó, biến state sẽ là giá trị
UsState::Alaska. Chúng ta có thể sử dụng biến này trong biểu thức println!,
vì vậy chúng ta có thể lấy ra giá trị của tiểu bang bên trong biến thể
Quarter của Coin enum.
Matching with Option<T>
Trong phần trước, chúng ta muốn lấy ra giá trị T bên trong Some khi sử dụng
Option<T>; chúng ta cũng có thể xử lý Option<T> bằng cách sử dụng match
như chúng ta đã làm với Coin enum! Thay vì so sánh các đồng, chúng ta sẽ so
sánh các biến thể của Option<T>, nhưng cách thức hoạt động của biểu thức
match vẫn giữ nguyên.
Giả sử chúng ta muốn viết một hàm mà nhận vào một Option<i32> và nếu có một
giá trị bên trong, nó sẽ cộng thêm 1 vào giá trị đó. Nếu không có giá trị bên
trong, hàm sẽ trả về giá trị None và không thực hiện bất kỳ thao tác nào.
Hàm này rất dễ để viết, cảm ơn match, và sẽ trông như trong Listing 6-5.
fn main() { fn plus_one(x: Option<i32>) -> Option<i32> { match x { None => None, Some(i) => Some(i + 1), } } let five = Some(5); let six = plus_one(five); let none = plus_one(None); }
Hãy xem xét kỹ hơn về lần đầu tiên chúng ta gọi plus_one. Khi chúng ta gọi
plus_one(five), biến x trong thân hàm plus_one sẽ có giá trị Some(5).
Chúng ta sau đó so sánh nó với mỗi nhánh trong biểu thức match.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Giá trị Some(5) không khớp với mẫu None, vì vậy chúng ta tiếp tục đến nhánh
tiếp theo.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Some(5) có khớp với Some(i) không? Vâng, nó khớp! Chúng ta có cùng một biến
thể. i được gắn với giá trị bên trong Some, vì vậy i có giá trị là 5.
Sau đó, code trong nhánh match được thực thi, vì vậy chúng ta cộng thêm 1 vào
giá trị của i và tạo ra một giá trị Some mới với tổng 6 bên trong.
Giờ hãy xem xét lần gọi thứ hai của plus_one trong Listing 6-5, khi x là
None. Chúng ta vào match và so sánh với nhánh đầu tiên.
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Nó khớp! Không có giá trị để cộng thêm, vì vậy chương trình dừng lại và trả về
giá trị None bên phải của =>. Vì nhánh đầu tiên khớp, nên các nhánh khác
không được so sánh.
Kết hợp match và enums rất hữu ích trong nhiều tình huống. Bạn sẽ thấy
pattern này nhiều trong mã Rust: match với một enum, gắn một biến với dữ liệu
bên trong, và sau đó thực thi code dựa trên nó. Đầu tiên nó có vẻ khó khăn,
nhưng một khi bạn quen với nó, bạn sẽ muốn có nó trong tất cả các ngôn ngữ. Nó
luôn được người dùng ưa thích.
Matches Are Exhaustive
Có một khía cạnh khác của match mà chúng ta cần thảo luận: các pattern thuộc
các nhánh phải bao phủ tất cả các khả năng. Hãy xem xét phiên bản lỗi của hàm
plus_one của chúng ta, nó sẽ không biên dịch được:
fn main() {
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
}
Chúng ta đã không xử lý trường hợp None, vì vậy mã này sẽ gây ra một lỗi. May
mắn thay, đó là một lỗi mà Rust biết cách phát hiện. Nếu chúng ta cố gắng biên
dịch mã này, chúng ta sẽ nhận được lỗi này:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
--> src/main.rs:3:15
|
3 | match x {
| ^ pattern `None` not covered
|
note: `Option<i32>` defined here
--> /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/core/src/option.rs:518:1
|
= note:
/rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/core/src/option.rs:522:5: not covered
= note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
4 ~ Some(i) => Some(i + 1),
5 ~ None => todo!(),
|
For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` due to previous error
Rust biết rằng chúng ta không bao phủ (cover) tất cả các trường hợp có thể và
cũng biết pattern nào chúng ta quên! Các match trong Rust là đầy đủ
(exhaustive): chúng ta phải bao phủ tất cả các trường hợp để mã có thể hợp lệ.
Đặc biệt trong trường hợp của Option<T>, khi Rust ngăn chúng ta quên xử lý
trường hợp None một cách rõ ràng, nó bảo vệ chúng ta khỏi việc giả định rằng
chúng ta có một giá trị khi chúng ta có thể có null, vì vậy lỗi tỷ đô đầu tiên
được thảo luận ở trên là không thể xảy ra.
Catch-all Patterns and the _ Placeholder
Khi dùng với enum, chúng ta cũng có thể thực hiện các hành động đặc biệt cho
một vài giá trị cụ thể, nhưng với tất cả các giá trị khác, chúng ta sẽ thực
hiện một hành động mặc định. Hãy tưởng tượng chúng ta đang triển khai một trò
chơi, nếu bạn tung xúc xắc và nhận được 3, người chơi của bạn sẽ không di
chuyển, mà thay vào đó sẽ nhận được một chiếc mũ đẹp. Nếu bạn tung xúc xắc và
nhận được 7, người chơi của bạn sẽ mất một chiếc mũ đẹp. Với tất cả các giá trị
khác, người chơi của bạn sẽ di chuyển số lượng ô trên bàn cờ tương ứng với giá
trị của xúc xắc. Đây là một match thực hiện logic này, với kết quả của xúc
xắc được cố định thay vì một giá trị ngẫu nhiên, và tất cả các logic khác được
biểu diễn bởi các hàm mà không có thân hàm vì thực sự triển khai chúng nằm
ngoài phạm vi của ví dụ này:
fn main() { let dice_roll = 9; match dice_roll { 3 => add_fancy_hat(), 7 => remove_fancy_hat(), other => move_player(other), } fn add_fancy_hat() {} fn remove_fancy_hat() {} fn move_player(num_spaces: u8) {} }
Với hai nhánh đầu tiên, các pattern là các giá trị chính xác 3 và 7. Với nhánh
cuối cùng bao gồm tất cả các giá trị khác có thể, pattern là biến mà chúng ta
đã chọn để đặt tên là other. Code chạy cho nhánh other sử dụng biến bằng
cách truyền nó cho hàm move_player.
Code này sẽ được biên dịch, ngay cả khi chúng ta không liệt kê tất cả các giá
trị có thể có của một u8, vì nhánh cuối cùng sẽ khớp với tất cả các giá trị
không được liệt kê cụ thể. Pattern này đáp ứng yêu cầu rằng match phải là đầy
đủ. Lưu ý rằng chúng ta phải đặt nhánh cuối cùng ở cuối vì các pattern được
khớp theo thứ tự. Nếu chúng ta đặt nhánh cuối cùng ở trước, các nhánh khác sẽ
không bao giờ chạy, vì vậy Rust sẽ cảnh báo chúng ta nếu chúng ta thêm các
nhánh sau một nhánh cuối cùng!
Rust cũng có một pattern mà chúng ta có thể sử dụng khi chúng ta muốn một
pattern cuối cùng nhưng không muốn sử dụng giá trị trong pattern cuối cùng:
_ là một pattern đặc biệt mà khớp với bất kỳ giá trị nào và không liên kết
với giá trị đó. Điều này cho Rust biết chúng ta không sẽ sử dụng giá trị, vì
vậy Rust sẽ không cảnh báo chúng ta về một biến không được sử dụng.
Cùng thay đổi quy tắc của trò chơi: bây giờ, nếu bạn tung xúc xắc và nhận được
bất kỳ giá trị nào khác ngoài 3 hoặc 7, bạn phải quay lại. Chúng ta không cần
nữa sử dụng giá trị cuối cùng, vì vậy chúng ta có thể thay đổi code của chúng
ta để sử dụng _ thay vì biến có tên là other:
fn main() { let dice_roll = 9; match dice_roll { 3 => add_fancy_hat(), 7 => remove_fancy_hat(), _ => reroll(), } fn add_fancy_hat() {} fn remove_fancy_hat() {} fn reroll() {} }
Ví dụ này cũng đáp ứng yêu cầu đầy đủ vì chúng ta đang tường minh bỏ qua tất cả các giá trị khác trong nhánh cuối cùng; chúng ta không bỏ sót bất kỳ thứ gì.
Cuối cùng, chúng ta sẽ thay đổi quy tắc của trò chơi một lần nữa, sẽ không có
gì khác xảy ra trong lượt của bạn nếu bạn tung xúc xắc và nhận được bất kỳ giá
trị nào khác ngoài 3 hoặc 7. Chúng ta có thể biểu diễn bằng cách sử dụng giá
trị đơn vị (kiểu tuple trống mà chúng ta đã nói ở phần “The Tuple Type”) làm mã cho nhánh _:
fn main() { let dice_roll = 9; match dice_roll { 3 => add_fancy_hat(), 7 => remove_fancy_hat(), _ => (), } fn add_fancy_hat() {} fn remove_fancy_hat() {} }
Ở đây, chúng ta đang nói với Rust rõ ràng rằng chúng ta sẽ không sử dụng bất kỳ giá trị nào khác không khớp với mẫu trong nhánh trước đó, và chúng ta không muốn chạy bất kỳ mã nào trong trường hợp này.
Để có thêm về các pattern và matching, chúng ta sẽ tìm hiểu trong Chapter 18. Bây giờ, chúng ta sẽ tiếp tục với cú pháp
if let, nó có thể hữu ích trong các tình huống mà biểu thức match có vẻ quá
dài.