Cấu trúc Điều khiển Luồng match

Rust có một cấu trúc điều khiển luồng cực kỳ mạnh mẽ gọi là match, cho phép bạn so sánh một giá trị với một loạt các mẫu và sau đó thực thi mã dựa trên mẫu nào phù hợp. Các mẫu có thể được tạo thành từ các giá trị văn bản, tên biến, ký tự đại diện, và nhiều thứ khác; Chương 19 bao gồm tất cả các loại mẫu khác nhau và chức năng của chúng. Sức mạnh của match đến từ tính biểu đạt của các mẫu và việc trình biên dịch xác nhận rằng tất cả các trường hợp có thể xảy ra đều được xử lý.

Hãy nghĩ về biểu thức match giống như một máy phân loại tiền xu: các đồng xu trượt xuống một đường ray với các lỗ có kích thước khác nhau dọc theo nó, và mỗi đồng xu rơi qua lỗ đầu tiên mà nó gặp và vừa với nó. Tương tự như vậy, các giá trị đi qua từng mẫu trong một match, và tại mẫu đầu tiên mà giá trị "vừa vặn," giá trị đó rơi vào khối mã liên quan để được sử dụng trong quá trình thực thi.

Nói về tiền 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 nhận một đồng xu không xác định của Hoa Kỳ và, tương tự như máy đếm, xác định loại đồng xu nào và trả về giá trị của nó bằng xu, như 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() {}

Hãy phân tích match trong hàm value_in_cents. Đầu tiên, chúng ta liệt kê từ khóa match và 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 một biểu thức điều kiện được sử dụng với if, nhưng có một sự khác biệt lớn: với if, điều kiện cần phải đánh giá thành một giá trị Boolean, nhưng ở đây nó có thể là bất kỳ kiểu dữ liệu nào. Kiể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 của match. Một nhánh có hai phần: một mẫu và một đoạn mã. Nhánh đầu tiên ở đây có mẫu là giá trị Coin::Penny và sau đó là toán tử => tách biệt mẫu và mã sẽ chạy. Mã trong trường hợp này chỉ đơn giản là giá trị 1. Mỗi nhánh được tách biệt với nhánh tiếp theo bằng một dấu phẩy.

Khi biểu thức match thực thi, nó so sánh giá trị kết quả với mẫu của mỗi nhánh, theo thứ tự. Nếu một mẫu khớp với giá trị, đoạn mã liên kết với mẫu đó sẽ được thực thi. Nếu mẫu đó không khớp với giá trị, việc thực thi tiếp tục đến nhánh tiếp theo, tương tự như trong máy phân loại tiền xu. Chúng ta có thể có nhiều nhánh tùy theo nhu cầu: trong Listing 6-3, match của chúng ta có bốn nhánh.

Đoạn mã liên kết với mỗi nhánh là một biểu thức, và giá trị kết quả của biểu thức trong nhánh khớp 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 mã trong nhánh match ngắn gọn, như trong Listing 6-3 nơi mỗi nhánh chỉ trả về một giá trị. Nếu bạn muốn chạy nhiều dòng mã trong một nhánh match, bạn phải sử dụng dấu ngoặc nhọn, và dấu phẩy sau nhánh sau đó là tùy chọn. Ví dụ, đoạn mã 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() {}

Các Mẫu Gắn với Giá trị

Một tính năng hữu ích khác của nhánh match là chúng có thể gắn với các phần của giá trị khớp với mẫu. Đây là cách chúng ta có thể trích xuất các giá trị từ các biến thể enum.

Ví dụ, hãy thay đổi một trong các biến thể enum của chúng ta để lưu trữ dữ liệu bên trong nó. Từ năm 1999 đến 2008, Hoa Kỳ đã đúc đồng 25 xu với các thiết kế khác nhau cho mỗi tiểu bang trong số 50 tiểu bang trên một mặt. Không có đồng xu nào khác có thiết kế tiểu bang, vì vậy chỉ có đồng quarter 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 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() {}

Hãy tưởng tượng rằng một người bạn đang cố gắng sưu tập tất cả 50 đồng quarter của các tiểu bang. Trong khi chúng ta phân loại tiền lẻ theo loại đồng xu, chúng ta cũng sẽ nói to tên của tiểu bang liên quan đến mỗi đồng quarter để nếu đó là một đồng mà bạn của chúng ta chưa có, họ có thể thêm nó vào bộ sưu tập của mình.

Trong biểu thức match cho đoạn mã này, chúng ta thêm một biến có tên state vào mẫu 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ẽ gắn với giá trị của tiểu bang của đồng quarter đó. Sau đó, chúng ta có thể sử dụng state trong đoạn mã cho nhánh đó, 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 chúng ta gọi value_in_cents(Coin::Quarter(UsState::Alaska)), coin sẽ là Coin::Quarter(UsState::Alaska). Khi chúng ta so sánh giá trị đó với từng nhánh 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 đó, giá trị gắn với state sẽ là UsState::Alaska. Chúng ta có thể sử dụng giá trị gắn đó trong biểu thức println!, từ đó lấy được giá trị tiểu bang bên trong ra khỏi biến thể enum Coin cho Quarter.

Khớp với Option<T>

Trong phần trước, chúng ta muốn lấy giá trị T bên trong ra khỏi trường hợp 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 enum Coin! Thay vì so sánh các đồng xu, chúng ta sẽ so sánh các biến thể của Option<T>, nhưng cách biểu thức match hoạt động vẫn giữ nguyên.

Giả sử chúng ta muốn viết một hàm nhận một Option<i32> và, nếu có giá trị bên trong, 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 cố gắng thực hiện bất kỳ thao tác nào.

Hàm này rất dễ viết, nhờ match, và sẽ trông giống như 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 lần thực thi đầu tiên của plus_one chi tiết hơn. Khi chúng ta gọi plus_one(five), biến x trong thân của plus_one sẽ có giá trị Some(5). Sau đó, chúng ta so sánh giá trị đó với từng nhánh 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? Có! Chúng ta có cùng một biến thể. i gắn với giá trị được chứa trong Some, vì vậy i nhận giá trị 5. Sau đó, mã trong nhánh match được thực thi, vì vậy chúng ta cộng 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.

Bây giờ hãy xem xét lần gọi thứ hai của plus_one trong Listing 6-5, trong đó xNone. Chúng ta đi 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 ở phía bên phải của =>. Bởi vì nhánh đầu tiên đã khớp, không có nhánh nào khác được so sánh.

Kết hợp match và enum rất hữu ích trong nhiều tình huống. Bạn sẽ thấy mẫu này rất nhiều trong mã Rust: match đối với một enum, gắn một biến với dữ liệu bên trong, và sau đó thực thi mã dựa trên nó. Ban đầu có thể hơi khó hiểu, nhưng một khi bạn đã quen với nó, bạn sẽ ước rằng mọi ngôn ngữ đều có nó. Nó luôn là một tính năng yêu thích của người dùng.

Các Match Đều Phải Đầy đủ

Có một khía cạnh khác của match mà chúng ta cần thảo luận: các mẫu của nhánh phải bao gồm tất cả các khả năng. Hãy xem xét phiên bản này của hàm plus_one của chúng ta, có một lỗi và 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 lỗi. May mắn thay, đây 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/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
 ::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576:5
  |
  = note: 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` (bin "enums") due to 1 previous error

Rust biết rằng chúng ta không bao gồm mọi trường hợp có thể xảy ra, và thậm chí còn biết mẫu nào chúng ta đã quên! Các match trong Rust là đầy đủ: chúng ta phải liệt kê tất cả các khả năng để mã hợp lệ. Đặc biệt là trong trường hợp Option<T>, khi Rust ngăn chúng ta quên xử lý rõ ràng trường hợp None, 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, từ đó làm cho lỗi hàng tỷ đô la đã thảo luận trước đó trở nên không thể.

Mẫu Bắt tất cả và Placeholder _

Sử dụng enum, chúng ta cũng có thể thực hiện các hành động đặc biệt cho một số giá trị cụ thể, nhưng đối với tất cả các giá trị khác, 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, trong đó, nếu bạn tung được số 3 trên xúc xắc, người chơi của bạn không di chuyển, mà thay vào đó nhận được một chiếc mũ đẹp mới. Nếu bạn tung được số 7, người chơi của bạn mất một chiếc mũ đẹp. Đối với tất cả các giá trị khác, người chơi của bạn di chuyển số ô đó trên bàn chơi. Dưới đây là một match triển khai logic đó, với kết quả của việc tung xúc xắc được cố định thay vì một giá trị ngẫu nhiên, và tất cả logic khác được biểu diễn bằng các hàm không có nội dung vì việc 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) {}
}

Đối với hai nhánh đầu tiên, các mẫu là các giá trị văn bản 37. Đối với nhánh cuối cùng bao gồm mọi giá trị có thể khác, mẫu là biến mà chúng ta đã chọn đặt tên là other. Mã chạy cho nhánh other sử dụng biến bằng cách truyền nó vào hàm move_player.

Mã này biên dịch được, mặc dù chúng ta chưa liệt kê tất cả các giá trị có thể có của một u8, bởi vì mẫu cuối cùng sẽ khớp với tất cả các giá trị không được liệt kê cụ thể. Mẫu bắt tất cả này đáp ứng yêu cầu rằng match phải đầy đủ. Lưu ý rằng chúng ta phải đặt nhánh bắt tất cả cuối cùng vì các mẫu được đánh giá theo thứ tự. Nếu chúng ta đặt nhánh bắt tất cả sớm hơn, 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 nhánh sau một nhánh bắt tất cả!

Rust cũng có một mẫu mà chúng ta có thể sử dụng khi muốn bắt tất cả nhưng không muốn sử dụng giá trị trong mẫu bắt tất cả: _ là một mẫu đặc biệt khớp với bất kỳ giá trị nào và không gắn với giá trị đó. Điều này nói với Rust rằng chúng ta sẽ không 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.

Hãy thay đổi luật của trò chơi: bây giờ, nếu bạn tung bất kỳ số nào khác ngoài 3 hoặc 7, bạn phải tung lại. Chúng ta không còn cần sử dụng giá trị bắt tất cả nữa, vì vậy chúng ta có thể thay đổi mã của mình để sử dụng _ thay vì biến có tên 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 rõ ràng bỏ qua tất cả các giá trị khác trong nhánh cuối cùng; chúng ta không quên bất cứ điều gì.

Cuối cùng, chúng ta sẽ thay đổi luật của trò chơi một lần nữa để không có gì khác xảy ra trong lượt của bạn nếu bạn tung bất kỳ số nào khác ngoài 3 hoặc 7. Chúng ta có thể biểu đạt điều đó bằng cách sử dụng giá trị đơn vị (kiểu tuple rỗng mà chúng ta đã đề cập trong phần "Kiểu Tuple") làm mã đi kèm với 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 một cách 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 một 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òn nhiều điều về mẫu và khớp mà chúng ta sẽ đề cập trong Chương 19. Hiện tại, chúng ta sẽ tiếp tục với cú pháp if let, có thể hữu ích trong các tình huống mà biểu thức match hơi dài dòng.