Result và Option: xử lý lỗi kiểu Rust

1. Tổng quan

Rust không có exception như Java hay C#. Thay vào đó, Rust dùng hai kiểu dữ liệu Option<T> và Result<T, E> để biểu diễn “có thể không có giá trị” và “có thể thất bại”. Bài này giúp bạn hiểu cách dùng hai kiểu này đúng cách, cách lan truyền lỗi gọn gàng bằng toán tử ?, và cách định nghĩa lỗi tùy chỉnh với thiserror. Đọc xong, bạn viết được code Rust xử lý lỗi rõ ràng, không cần try/catch.

2. Vì sao không dùng exception?

Trong Java, một method có thể ném RuntimeException bất cứ lúc nào mà signature không hề báo trước. Bạn phải đọc Javadoc hoặc code để biết nó có thể throw gì.

Rust buộc bạn khai báo khả năng thất bại ngay trong kiểu trả về:

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        return Err("chia cho 0".to_string());
    }
    Ok(a / b)
}

Nhìn signature Result<i32, String> là biết ngay hàm này có thể thất bại. Compiler bắt bạn xử lý cả hai nhánh Ok và Err, không thể âm thầm bỏ qua lỗi như quên catch trong Java.

Lưu ý: Rust vẫn có panic! cho lỗi không thể phục hồi (bug lập trình, bất biến bị vi phạm). Result dành cho lỗi có thể dự đoán và xử lý được.

3. Option: giá trị có thể vắng mặt

Option<T> thay thế cho null. Nó có hai biến thể: Some(T) và None.

fn find_customer(id: u32) -> Option<String> {
    let customers = vec![(1, "Alice"), (2, "Bob")];
    customers.iter()
        .find(|(cid, _)| *cid == id)
        .map(|(_, name)| name.to_string())
}

fn main() {
    match find_customer(2) {
        Some(name) => println!("Tìm thấy: {name}"),
        None => println!("Không có khách hàng này"),
    }
}

find trả về Option<&(u32, &str)>, map biến đổi giá trị bên trong Some nếu có, bỏ qua nếu là None. Kết quả:

Tìm thấy: Bob

3.1. Các phương thức thường dùng

let maybe_age: Option<u32> = Some(25);

let age = maybe_age.unwrap_or(18);          // 25, hoặc 18 nếu None
let doubled = maybe_age.map(|a| a * 2);     // Some(50)
let result: Result<u32, &str> = maybe_age.ok_or("thiếu tuổi"); // Ok(25)
  • unwrap_or(default): lấy giá trị hoặc trả về default, không bao giờ panic.
  • map(f): biến đổi giá trị bên trong nếu có, giữ nguyên None nếu không.
  • ok_or(err): chuyển Option<T> thành Result<T, E> — hữu ích khi bạn cần dùng toán tử ? ở bước sau.

Lưu ý: Tránh gọi .unwrap() trực tiếp trên Option/Result trong code production. Nó panic ngay khi gặp None/Err, tương đương ném NullPointerException không kiểm soát. Chỉ dùng trong test hoặc khi bạn chắc chắn 100% giá trị tồn tại.

4. Result<T, E>: thành công hoặc thất bại

Result<T, E> có hai biến thể: Ok(T) cho thành công, Err(E) cho thất bại.

use std::num::ParseIntError;

fn parse_quantity(input: &str) -> Result<u32, ParseIntError> {
    input.trim().parse::<u32>()
}

fn main() {
    match parse_quantity("  42 ") {
        Ok(qty) => println!("Số lượng: {qty}"),
        Err(e) => println!("Lỗi parse: {e}"),
    }
}

parse::<u32>() trả về Result<u32, ParseIntError>. match buộc bạn xử lý cả hai nhánh — compiler từ chối biên dịch nếu thiếu nhánh nào.

Số lượng: 42

4.1. Toán tử ? để lan truyền lỗi

Viết match ở mọi nơi rất dài dòng. Toán tử ? giúp lan truyền lỗi ngay lập tức nếu gặp Err, tương tự throw tự động:

use std::num::ParseIntError;

struct Order {
    quantity: u32,
    price: u32,
}

fn parse_order(qty_str: &str, price_str: &str) -> Result<Order, ParseIntError> {
    let quantity = qty_str.trim().parse::<u32>()?;
    let price = price_str.trim().parse::<u32>()?;
    Ok(Order { quantity, price })
}

fn main() {
    match parse_order("3", "15000") {
        Ok(order) => println!("Tổng tiền: {}", order.quantity * order.price),
        Err(e) => println!("Dữ liệu không hợp lệ: {e}"),
    }
}

Dòng qty_str.trim().parse::<u32>()? nghĩa là: nếu parse trả Err, hàm parse_order trả về ngay Err đó cho caller, không cần viết match thủ công. Nếu Ok, giá trị bên trong được “mở ra” và gán cho quantity.

Tổng tiền: 45000

Lưu ý: Toán tử ? chỉ dùng được khi kiểu lỗi của hàm hiện tại và hàm được gọi tương thích (hoặc có From implementation để convert). Nếu không, compiler báo lỗi type mismatch ngay lúc build.

5. Kết hợp Option và Result

Hai kiểu này thường đan xen. Ví dụ tìm sản phẩm trong kho rồi kiểm tra tồn kho đủ hay không:

struct Product {
    name: String,
    stock: u32,
}

fn find_product(name: &str, products: &[Product]) -> Option<&Product> {
    products.iter().find(|p| p.name == name)
}

fn check_stock(name: &str, requested: u32, products: &[Product]) -> Result<(), String> {
    let product = find_product(name, products)
        .ok_or_else(|| format!("Không tìm thấy sản phẩm: {name}"))?;

    if product.stock < requested {
        return Err(format!("Chỉ còn {} sản phẩm", product.stock));
    }
    Ok(())
}

ok_or_else chuyển Option<&Product> thành Result<&Product, String> kèm message lỗi, rồi ? lan truyền nếu là None. Cách viết này tránh được match lồng nhau sâu nhiều cấp.

6. Định nghĩa lỗi tùy chỉnh với thiserror

Dùng String làm kiểu lỗi tiện cho ví dụ nhỏ, nhưng dự án thực tế cần enum lỗi có cấu trúc. Crate thiserror giúp việc này gọn gàng:

<!-- Cargo.toml tương đương, dùng dependencies section -->
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;

#[derive(Error, Debug)]
enum OrderError {
    #[error("sản phẩm không tồn tại: {0}")]
    ProductNotFound(String),

    #[error("không đủ hàng, còn {available} sản phẩm")]
    InsufficientStock { available: u32 },
}

fn validate_order(stock: Option<u32>, requested: u32) -> Result<(), OrderError> {
    let available = stock.ok_or_else(|| OrderError::ProductNotFound("Laptop".into()))?;
    if available < requested {
        return Err(OrderError::InsufficientStock { available });
    }
    Ok(())
}

#[derive(Error, Debug)] tự sinh implementation của trait std::error::Error. #[error("...")] định nghĩa message hiển thị cho từng variant, hỗ trợ placeholder như {0} (positional) hoặc {available} (field name). Kết quả khi in lỗi:

Error: InsufficientStock { available: 2 }

Nếu dùng {} thay vì {:?} khi in, bạn nhận được message thân thiện: không đủ hàng, còn 2 sản phẩm.

7. anyhow cho ứng dụng, thiserror cho thư viện

Khi viết binary/ứng dụng (không phải thư viện cho người khác dùng), bạn thường không cần phân biệt chi tiết loại lỗi — chỉ cần lan truyền và in ra. Crate anyhow phù hợp cho trường hợp này:

[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<String> {
    std::fs::read_to_string(path)
        .with_context(|| format!("không đọc được file cấu hình: {path}"))
}

fn main() -> Result<()> {
    let config = load_config("app.toml")?;
    println!("Config: {config}");
    Ok(())
}

anyhow::Result<T> là alias của Result<T, anyhow::Error>, có thể chứa bất kỳ lỗi nào implement trait Error. with_context gắn thêm thông tin ngữ cảnh vào lỗi gốc, giúp debug dễ hơn mà không cần tự định nghĩa enum.

  • Thư viện (crate người khác sẽ use): dùng thiserror, trả enum lỗi cụ thể để caller match xử lý từng trường hợp.
  • Ứng dụng (binary cuối cùng): dùng anyhow, chỉ cần lan truyền và log, không ai cần match chi tiết.

8. Test hành vi lỗi

Rust có framework test tích hợp sẵn, không cần thêm thư viện ngoài cho test cơ bản.

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

    #[test]
    fn tra_ve_ok_khi_du_hang() {
        let result = validate_order(Some(10), 5);
        assert!(result.is_ok());
    }

    #[test]
    fn tra_ve_err_khi_thieu_hang() {
        let result = validate_order(Some(2), 5);
        assert!(matches!(
            result,
            Err(OrderError::InsufficientStock { available: 2 })
        ));
    }

    #[test]
    fn tra_ve_err_khi_khong_tim_thay_san_pham() {
        let result = validate_order(None, 5);
        assert!(matches!(result, Err(OrderError::ProductNotFound(_))));
    }
}

#[test] đánh dấu hàm test, chạy bằng cargo test. matches! là macro kiểm tra một giá trị có khớp pattern cho trước không — tiện hơn assert_eq! khi so sánh enum có field, vì bạn không cần implement PartialEq cho toàn bộ error type.

running 3 tests
test tests::tra_ve_ok_khi_du_hang ... ok
test tests::tra_ve_err_khi_thieu_hang ... ok
test tests::tra_ve_err_khi_khong_tim_thay_san_pham ... ok

9. Lỗi thường gặp

  • Lạm dụng .unwrap(): code chạy ổn lúc dev, panic bất ngờ ở production khi gặp input lạ. Hãy dùng ?, match, hoặc unwrap_or_else với xử lý rõ ràng.
  • Dùng String làm kiểu lỗi trong thư viện công khai: caller không thể match theo loại lỗi, chỉ so sánh chuỗi — dễ vỡ khi bạn đổi message. Dùng enum với thiserror thay vì String.
  • Trộn anyhow::Error vào API công khai của thư viện: ẩn mất thông tin loại lỗi cụ thể, caller buộc phải downcast thủ công. anyhow chỉ nên dùng ở tầng ứng dụng cuối cùng.
  • Quên #[derive(Debug)]: toán tử ? và nhiều macro test yêu cầu error type implement Debug. Thiếu nó, compiler báo lỗi trait bound không thỏa mãn.

Lưu ý: Nếu bạn đến từ Java và muốn “mô phỏng checked exception”, đừng cố bắt chước try/catch bằng macro. Hãy quen với tư duy: lỗi là giá trị (value), được trả về và truyền đi như bất kỳ dữ liệu nào khác.

10. Kết luận

Option<T> và Result<T, E> biến việc xử lý “vắng mặt” và “thất bại” thành một phần của type system, được compiler kiểm tra tại thời điểm biên dịch thay vì runtime. Toán tử ? giúp code ngắn gọn mà vẫn tường minh. Với dự án nhỏ, String hay anyhow là đủ; với thư viện chia sẻ, hãy đầu tư enum lỗi rõ ràng bằng thiserror để caller xử lý từng trường hợp một cách an toàn.

rusterror-handlingoptionresultthiserror