Enum và pattern matching trong Rust

1. Tổng quan

Trong nhiều ngôn ngữ, enum chỉ là một danh sách hằng số có tên. Trong Rust, mỗi biến thể (variant) của enum còn có thể mang dữ liệu riêng. Khi kết hợp với match, compiler sẽ buộc bạn xử lý đủ mọi trường hợp.

Bài này đi từ cách định nghĩa enum, qua match, guard, binding @, tới Option, Result, if let, let else và matches!. Đọc xong, bạn có thể mô hình hóa trạng thái nghiệp vụ bằng enum và tránh được các lỗi phổ biến.

Bài dùng Rust 1.65 trở lên (cần cho let else) và không cần thư viện ngoài.

2. Định nghĩa enum có dữ liệu

Giả sử bạn cần mô tả trạng thái của một đơn hàng:

#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
    Pending,
    Paid(u64),
    Shipped { carrier: String, tracking: String },
    Cancelled { reason: String },
}

fn main() {
    let statuses = vec![
        OrderStatus::Pending,
        OrderStatus::Paid(250_000),
        OrderStatus::Shipped {
            carrier: "GHN".to_string(),
            tracking: "GHN123".to_string(),
        },
        OrderStatus::Cancelled { reason: "Hết hàng".to_string() },
    ];

    for s in &statuses {
        println!("{:?}", s);
    }
}

Enum này có ba kiểu variant:

  • Pending là unit variant, không mang dữ liệu.
  • Paid(u64) là tuple variant, mang số tiền đã trả theo vị trí.
  • Shipped { .. } và Cancelled { .. } là struct variant, mang dữ liệu có tên field.

#[derive(Debug, Clone, PartialEq)] cho phép in giá trị bằng {:?}, sao chép và so sánh bằng ==. Phần so sánh sẽ cần khi viết test.

Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "Hết hàng" }

Điểm mấu chốt: một đơn Pending không thể “vô tình” có mã vận đơn. Dữ liệu chỉ tồn tại ở đúng variant cần nó. Với cách dùng struct có nhiều field Option, bạn không có được đảm bảo này.

3. match cơ bản và tính đầy đủ (exhaustiveness)

match so khớp một giá trị với từng pattern theo thứ tự từ trên xuống. Nhánh khớp đầu tiên sẽ được chạy:

fn describe(status: &OrderStatus) -> String {
    match status {
        OrderStatus::Pending => "Đang chờ thanh toán".to_string(),
        OrderStatus::Paid(amount) => format!("Đã thanh toán {amount} VND"),
        OrderStatus::Shipped { carrier, tracking } => {
            format!("Đang giao qua {carrier}, mã {tracking}")
        }
        OrderStatus::Cancelled { reason } => format!("Đã hủy: {reason}"),
    }
}

Một số điểm cần chú ý:

  • status có kiểu &OrderStatus. Nhờ default binding modes, bạn viết pattern như với giá trị thường. Các biến amount, carrier, tracking sẽ là tham chiếu (&u64, &String), nên không có dữ liệu nào bị move.
  • match là một biểu thức. Mọi nhánh phải trả về cùng kiểu, ở đây là String.

Giờ thử xóa nhánh Cancelled. Compiler sẽ từ chối build:

error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
 --> src/main.rs:12:11
   |
12 |     match status {
   |           ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered

Đây là giá trị lớn nhất của match. Khi bạn thêm variant mới, ví dụ Refunded, mọi match chưa xử lý nó đều báo lỗi. Bạn không phải dò tay xem chỗ nào cần sửa.

4. Pattern nâng cao: tuple, |, guard, range và @

Pattern không chỉ dùng cho enum. Bạn có thể match một tuple để xét nhiều điều kiện cùng lúc:

enum CustomerTier {
    Regular,
    Silver,
    Gold,
}

fn discount(tier: &CustomerTier, total: u64) -> u64 {
    match (tier, total) {
        (CustomerTier::Gold, _) => total / 10,
        (CustomerTier::Silver, t @ 1_000_000..) => t / 20,
        (CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
        _ => 0,
    }
}

fn main() {
    println!("{}", discount(&CustomerTier::Gold, 300_000));
    println!("{}", discount(&CustomerTier::Silver, 1_200_000));
    println!("{}", discount(&CustomerTier::Regular, 2_500_000));
    println!("{}", discount(&CustomerTier::Regular, 100_000));
}

Từng nhánh hoạt động như sau:

  • _ khớp với mọi giá trị và không bind biến nào.
  • t @ 1_000_000.. kiểm tra total nằm trong range từ 1 triệu trở lên, đồng thời bind giá trị đó vào t.
  • CustomerTier::Silver | CustomerTier::Regular dùng | để gộp nhiều pattern vào một nhánh.
  • if t >= 2_000_000 là match guard. Đây là điều kiện bổ sung, chỉ được xét sau khi pattern đã khớp.
  • _ => 0 là nhánh mặc định. Nhánh này bắt buộc phải có vì u64 có vô số giá trị.
30000
60000
50000
0

Với struct variant, bạn dùng .. để bỏ qua các field không quan tâm:

fn shipping_fee(status: &OrderStatus) -> u64 {
    match status {
        OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
        OrderStatus::Shipped { .. } => 30_000,
        _ => 0,
    }
}

Lưu ý: Compiler không dùng guard khi kiểm tra tính đầy đủ. Nếu bạn chỉ viết nhánh Shipped { .. } if ... mà không có nhánh Shipped không kèm guard, bạn vẫn phải thêm nhánh khác để phủ hết các trường hợp.

5. Option và Result cũng chỉ là enum

Thư viện chuẩn định nghĩa Option<T> (gồm Some(T) và None) và Result<T, E> (gồm Ok(T) và Err(E)). Vì đó là enum, bạn xử lý chúng bằng đúng các công cụ ở trên:

fn find_customer(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("An"),
        2 => Some("Bình"),
        _ => None,
    }
}

fn parse_quantity(input: &str) -> Result<u32, String> {
    match input.trim().parse::<u32>() {
        Ok(0) => Err("Số lượng phải lớn hơn 0".to_string()),
        Ok(n) => Ok(n),
        Err(e) => Err(format!("Không hợp lệ: {e}")),
    }
}

fn main() {
    println!("{:?}", find_customer(2));
    println!("{:?}", find_customer(9));
    println!("{:?}", parse_quantity(" 3 "));
    println!("{:?}", parse_quantity("0"));
    println!("{:?}", parse_quantity("abc"));
}

Ok(0) là pattern lồng nhau. Nó khớp khi kết quả là Ok và giá trị bên trong bằng 0. Vì Ok(0) đứng trước Ok(n), số 0 bị chặn lại trước khi tới nhánh tổng quát.

Some("Bình")
None
Ok(3)
Err("Số lượng phải lớn hơn 0")
Err("Không hợp lệ: invalid digit found in string")

Rust không có null. Mọi giá trị “có thể vắng mặt” đều phải đi qua Option, và compiler buộc bạn xử lý trường hợp None.

6. Cú pháp gọn: if let, let else, while let, matches!

Viết match đầy đủ sẽ dài dòng khi bạn chỉ quan tâm tới một variant. Rust có vài cú pháp ngắn hơn cho trường hợp này.

6.1. if let

fn tracking_code(status: &OrderStatus) -> Option<&str> {
    if let OrderStatus::Shipped { tracking, .. } = status {
        Some(tracking.as_str())
    } else {
        None
    }
}

if let chạy khối lệnh khi pattern khớp. Phần else là tùy chọn. Cú pháp này không bắt bạn xử lý đầy đủ các trường hợp, nên chỉ dùng khi những variant còn lại thật sự không quan trọng.

6.2. let else

let else hợp với kiểu “early return”: lấy dữ liệu nếu khớp, nếu không thì thoát ngay.

fn charge(status: &OrderStatus) -> Result<u64, String> {
    let OrderStatus::Paid(amount) = status else {
        return Err("Đơn chưa thanh toán".to_string());
    };
    Ok(*amount)
}

Sau dòng let, biến amount dùng được ở phần còn lại của hàm mà không cần lồng thêm khối. Khối else bắt buộc phải thoát khỏi luồng hiện tại bằng return, break, continue hoặc panic!.

6.3. while let và matches!

fn main() {
    let mut queue = vec![
        OrderStatus::Paid(100_000),
        OrderStatus::Pending,
        OrderStatus::Paid(400_000),
    ];

    let paid = queue
        .iter()
        .filter(|s| matches!(s, OrderStatus::Paid(_)))
        .count();
    println!("Số đơn đã thanh toán: {paid}");

    while let Some(status) = queue.pop() {
        println!("Xử lý: {status:?}");
    }
}
  • matches!(giá_trị, pattern) trả về bool. Macro này rất tiện trong filter hoặc assert!.
  • while let Some(status) = queue.pop() lặp cho tới khi pop() trả về None.
Số đơn đã thanh toán: 2
Xử lý: Paid(400000)
Xử lý: Pending
Xử lý: Paid(100000)

7. Gắn method cho enum: mô hình hóa chuyển trạng thái

Enum cũng có impl giống struct. Một cách dùng hay là biểu diễn chuyển trạng thái hợp lệ:

impl OrderStatus {
    fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
                carrier: carrier.to_string(),
                tracking: tracking.to_string(),
            }),
            other => Err(format!("Không thể giao đơn ở trạng thái {other:?}")),
        }
    }

    fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
                reason: reason.to_string(),
            }),
            other => Err(format!("Không thể hủy đơn ở trạng thái {other:?}")),
        }
    }

    fn is_final(&self) -> bool {
        matches!(self, OrderStatus::Cancelled { .. })
    }
}

Một số điểm cần chú ý:

  • ship và cancel nhận self theo giá trị, nên trạng thái cũ bị tiêu thụ. Bạn không thể tiếp tục dùng biến trạng thái cũ sau khi chuyển.
  • Nhánh other => ... bind toàn bộ giá trị còn lại vào biến other để đưa vào thông báo lỗi.
  • is_final chỉ đọc, nên nhận &self.

8. Viết test cho hành vi chính

Rust có sẵn framework test. Bạn đặt test trong module #[cfg(test)] ở cùng file:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paid_order_can_be_shipped() {
        let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");

        assert_eq!(
            result,
            Ok(OrderStatus::Shipped {
                carrier: "GHN".to_string(),
                tracking: "GHN123".to_string(),
            })
        );
    }

    #[test]
    fn pending_order_cannot_be_shipped() {
        let result = OrderStatus::Pending.ship("GHN", "X");

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Pending"));
    }

    #[test]
    fn cancelled_order_is_final() {
        let status = OrderStatus::Pending.cancel("Khách đổi ý").unwrap();

        assert!(status.is_final());
        assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "Khách đổi ý"));
    }
}

assert_eq! dùng được vì enum đã derive PartialEq và Debug. Khi test fail, Rust in ra cả giá trị mong đợi lẫn giá trị thực tế. Trong test cuối, ref reason bind field theo tham chiếu, nhờ vậy status không bị move vào trong matches!.

cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

9. Lỗi hay gặp và khi nào không nên dùng

Lạm dụng _ làm mất tính đầy đủ. Nếu bạn viết _ => ... cho một enum do chính bạn quản lý, sau này thêm variant mới thì compiler sẽ im lặng. Variant mới rơi thẳng vào nhánh mặc định, và đó thường không phải điều bạn muốn. Với enum nghiệp vụ, hãy liệt kê tường minh từng variant.

Move dữ liệu ra khỏi tham chiếu. Viết match *status { OrderStatus::Cancelled { reason } => ... } sẽ gây lỗi:

error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference

Có ba cách sửa: match trực tiếp trên status (tham chiếu), dùng ref reason, hoặc clone dữ liệu nếu bạn thật sự cần sở hữu nó.

Đặt nhánh tổng quát trước nhánh cụ thể. Nếu Ok(n) đứng trước Ok(0), nhánh Ok(0) sẽ không bao giờ được chạy. Compiler chỉ cảnh báo unreachable pattern chứ không báo lỗi, nên bạn dễ bỏ sót.

Variant quá lớn. Kích thước một enum bằng kích thước variant lớn nhất cộng thêm phần tag. Nếu một variant chứa mảng lớn, mọi giá trị của enum đều tốn từng ấy bộ nhớ. Hãy bọc variant đó bằng Box<...>. Lint clippy::large_enum_variant sẽ nhắc bạn việc này.

Lưu ý: Nếu bạn viết thư viện và muốn thêm variant sau này mà không phá vỡ code của người dùng, hãy đánh dấu enum bằng #[non_exhaustive]. Khi đó, code ở crate khác bắt buộc phải có nhánh _.

Khi nào không nên dùng enum? Enum phù hợp khi tập trường hợp là đóng và do bạn kiểm soát, ví dụ trạng thái đơn hàng hay loại lỗi. Nếu tập kiểu là mở, chẳng hạn người dùng thư viện cần tự thêm phương thức thanh toán mới, hãy dùng trait (Box<dyn PaymentMethod> hoặc generic). Thêm variant đòi hỏi sửa enum gốc, còn trait cho phép mở rộng từ bên ngoài.

10. Kết luận

Enum trong Rust cho phép mỗi trường hợp mang đúng dữ liệu của nó. match thì buộc bạn xử lý đầy đủ mọi trường hợp ngay lúc compile. Bạn đã xem qua:

  • Unit, tuple và struct variant.
  • match với binding, |, guard, range, @ và ...
  • Option và Result như những enum bình thường.
  • Các cú pháp gọn if let, let else, while let, matches!.
  • Cách dùng method trên enum để mô hình hóa chuyển trạng thái, kèm test.

Quy tắc dễ nhớ nhất: với enum do bạn sở hữu, hãy liệt kê đủ các variant thay vì dùng _. Khi đó, mỗi lần thêm variant mới, compiler sẽ chỉ ra mọi chỗ bạn cần sửa.

rustenumpattern matchingmatchoption