Danh sách bài viết

Bài 141: Result<T,E> — Recoverable Error

Bài 141 của series Rust Cơ Bản — đi sâu vào nhánh đối lập của panic!: recoverable error — những lỗi kỳ vọng có thể xảy ra và caller cần cơ hội xử lý thay vì để process sập. Idiom của Rust cho nhánh này là Result<T, E>. Bài 93 ở Nhóm 13 đã giới thiệu enum Result ở góc nhìn "thêm một enum trong stdlib"; bài này nhìn lại từ góc error handling pattern: vì sao Rust không có exception, vì sao mọi fallible function trả Result, các method query/transform thường gặp, idiom std::io::Result, cách convert Option sang Result, và quy ước viết function trả Result. Đây là nền tảng cho cả nhóm 19 — các bài sau sẽ xây thêm unwrap/expect, operator ?, Box<dyn Error>, custom error, From trait, anyhow/thiserror.

09/06/2026
11 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Giải thích được vì sao Rust chọn Result<T, E> làm idiom error handling thay cho exception (Java/Python) hay error code (C), và cơ chế nào ép caller phải handle.
  • Hiểu pattern std::io::Result<T> — type alias cố định error type cho một module, vì sao stdlib và đa số crate đều dùng pattern này.
  • Dùng match exhaustive xử lý cả hai nhánh Ok/Err và biết tại sao compiler ép cover cả hai.
  • Dùng if let Ok(v) = r hoặc if let Err(e) = r như shortcut khi chỉ quan tâm một nhánh.
  • Phân biệt các method query (is_ok, is_err), convert (ok, err), và borrow (as_ref).
  • Chain transformation qua map, map_err, and_then mà không cần match thủ công — chuẩn bị nền cho operator ? ở bài 143.
  • Convert Option<T> sang Result<T, E> bằng ok_or/ok_or_else khi muốn thêm lý do cho nhánh None.
  • Viết được function fallible đúng idiom: signature trả Result, đẩy quyết định handle cho caller, không tự ý panic giữa chừng.
2

Result Là Idiom Error Handling Của Rust

Rust không có exceptionkhông có error code. Mọi function có thể thất bại theo cách recoverable (chương trình vẫn xử lý tiếp được) đều trả về kiểu Result<T, E> — cùng một enum hai variant Ok(T) / Err(E) trong prelude.

// Ký pháp lặp đi lặp lại khắp stdlib và mọi crate Rust:
fn read_file(path: &str) -> Result<String, std::io::Error> { /* ... */ }
fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> { /* ... */ }
fn open_db(url: &str) -> Result<Connection, DbError> { /* ... */ }

Tại sao Rust chọn cách này thay vì exception?

  • Lỗi nằm trong type system. Compiler biết function có thể fail vì return type nói rõ; không có "hidden control flow" như exception bay xuyên hàng chục frame stack.
  • Caller buộc phải handle. Attribute #[must_use] trên Result khiến compiler cảnh báo nếu bạn vứt giá trị trả về đi mà không pattern-match hoặc dùng combinator. Quên xử lý lỗi là warning ngay tại chỗ.
  • Không có cost ẩn. Không có stack unwinding cho mỗi lỗi (đó là việc của panic! ở bài 140); Result chỉ là một enum bình thường — return về như value, không khác gì Option.
  • Error là một value. Vì Err(e) là giá trị, bạn có thể lưu trong Vec, gửi qua channel, log đi log lại — không như exception phải catch ngay khi bay qua.

Quy tắc nhớ ngắn: panic dùng khi bug; Result dùng khi lỗi kỳ vọng có thể xảy ra trong runtime bình thường. File không tồn tại, network timeout, user nhập sai định dạng — tất cả là Result, không phải panic.

3

std::io::Result — Type Alias Của Stdlib

Khi cả một module chỉ trả về Result với cùng một error type, stdlib và đa số crate dùng pattern type alias để viết gọn. Ví dụ kinh điển là std::io::Result:

// Trong std::io
pub type Result<T> = std::result::Result<T, std::io::Error>;

// Nhờ alias này, signature ngắn lại:
fn read_to_string(path: &Path) -> io::Result<String>
// thay vì: -> Result<String, std::io::Error>

std::io::Result<T> đã cố định E = std::io::Error, bạn chỉ cần truyền tham số kiểu cho T. Pattern này phổ biến trong stdlib (std::fmt::Result, std::thread::Result) và trong crate ecosystem (serde_json::Result, reqwest::Result, sqlx::Result).

Cách dùng phía caller:

use std::fs;
use std::io;

fn load_config(path: &str) -> io::Result<String> {
    let content = fs::read_to_string(path)?;   // io::Result<String>
    Ok(content)
}

Khi định nghĩa crate riêng, bạn cũng nên tạo alias tương tự:

// src/error.rs trong crate "myapp"
pub type Result<T> = std::result::Result<T, MyAppError>;

// Trong các module khác, dùng signature gọn:
pub fn parse_config(path: &str) -> crate::Result<Config> { /* ... */ }

Lưu ý có thể đụng tên với std::result::Result trong prelude — đặt alias trong module riêng (crate::error::Result) và use rõ ở mỗi file để tránh shadow.

4

Match Handling — Exhaustive Cả Hai Nhánh

Cách handle "đầy đủ và tường minh" nhất là match cover cả hai variant. Compiler ép buộc cover hết — bỏ sót một nhánh là compile error, không phải warning:

use std::fs;

fn main() {
    let result = fs::read_to_string("config.toml");

    match result {
        Ok(content) => {
            println!("đọc được {} bytes", content.len());
            // ... tiếp tục xử lý content
        }
        Err(e) => {
            eprintln!("đọc file fail: {e}");
            std::process::exit(1);
        }
    }
}

Tính chất quan trọng:

  • Exhaustive: thiếu Err(_) là compile error "match not exhaustive: Err(_) not covered". Compiler không cho bạn "quên" nhánh lỗi.
  • Bind giá trị: Ok(content) bind giá trị bên trong vào biến content; Err(e) bind error vào e. Cú pháp giống destructure enum thường.
  • Mỗi nhánh là một biểu thức: match trả về giá trị, nên có thể assign — ví dụ let n = match parse() { Ok(v) => v, Err(_) => 0 };.
  • Có thể nest pattern: Err(e) if e.kind() == ErrorKind::NotFound => ... — match guard kết hợp pattern.

Khi match dài hoặc lặp lại nhiều lần, dùng combinator (mục 7) hoặc operator ? (bài 143). match phù hợp khi hai nhánh có hành vi khác nhau rõ rệt — log + exit, return error code khác nhau, fallback phức tạp.

5

if let Pattern Cho One-Arm

Nhiều khi bạn chỉ quan tâm một nhánh: chỉ làm gì đó khi Ok, hoặc chỉ log khi Err. Dùng match với nhánh rỗng cho phía còn lại là verbose — if let ngắn và rõ ràng hơn:

fn main() {
    let r1: Result<u32, _> = "42".parse::<u32>();

    // Chỉ quan tâm Ok — bỏ qua Err
    if let Ok(n) = r1 {
        println!("parsed: {n}");
    }

    let r2: Result<u32, _> = "abc".parse::<u32>();

    // Chỉ quan tâm Err — log để debug
    if let Err(e) = &r2 {
        eprintln!("parse fail: {e}");
    }
    // r2 vẫn còn ở đây vì bạn đã &r2 (borrow) — có thể dùng tiếp

    // Kết hợp với else — chạy fallback khi không match
    if let Ok(n) = "abc".parse::<u32>() {
        println!("ok: {n}");
    } else {
        println!("dùng default 0");
    }
}

Hai biến thể quan trọng:

  • if let Ok(v) = r: shortcut khi chỉ có hành động ở nhánh Ok. Nhánh Err bị bỏ qua âm thầm — dùng khi bạn thực sự không cần biết error là gì (ví dụ best-effort cache write).
  • if let Err(e) = &r: shortcut log error mà vẫn giữ r để xử lý tiếp ở phía dưới (ví dụ propagate hoặc fallback). Nhớ borrow bằng &r nếu sau đó còn dùng r.

if let không phải exhaustive — không có cảnh báo nếu bạn bỏ qua nhánh khác. Đó vừa là sự thuận tiện vừa là nguy cơ: nếu bỏ qua Err mà không log gì, bạn vừa "nuốt" lỗi mà compiler không kêu ca. Quy ước cộng đồng: nếu bỏ qua Err có ý thức, comment lý do.

6

Method Phổ Biến: Query Và Convert

Result có nhiều method tiện ích để hỏi trạng thái mà không cần pattern match đầy đủ:

fn main() {
    let ok:  Result<u32, &str> = Ok(42);
    let err: Result<u32, &str> = Err("bad");

    // Query: chỉ kiểm tra nhánh
    assert!(ok.is_ok());            // true
    assert!(!ok.is_err());          // false
    assert!(err.is_err());          // true

    // Convert sang Option — bỏ thông tin của nhánh kia
    let some: Option<u32>  = ok.ok();    // Some(42)
    let none: Option<u32>  = err.ok();   // None — error bị vứt!
    let e_some: Option<&str> = err.err();// Some("bad")
    let e_none: Option<&str> = ok.err(); // None

    // Borrow: lấy reference vào bên trong, không consume
    let r: Result<u32, &str> = Ok(10);
    let r_ref: Result<&u32, &&str> = r.as_ref();   // r vẫn còn

    println!("{:?} {:?} {:?}", some, none, e_some);
    println!("r vẫn dùng được: {r:?}, ref: {r_ref:?}");
}

Vai trò từng method:

  • is_ok() -> bool / is_err() -> bool: kiểm tra nhánh, không consume Result. Dùng khi chỉ cần biết có lỗi hay không trong điều kiện if.
  • ok() -> Option<T>: Ok(v) => Some(v), Err(_) => None. Lose error — chỉ dùng khi bạn cố ý vứt lý do lỗi đi.
  • err() -> Option<E>: ngược lại — lấy error nếu có. Hữu ích để log mà không quan tâm giá trị.
  • as_ref() -> Result<&T, &E>: chuyển &Result<T, E> thành Result<&T, &E> — pattern match được trên reference mà không consume. Có biến thể as_mut() cho mutable borrow.

Lời khuyên: ok()/err() trông tiện nhưng vứt thông tin — nên đặt câu hỏi "có thật sự không cần lý do lỗi không?" trước khi gọi. Trong production, log error trước rồi mới .ok() nếu phải fallback.

7

Transformation: map / map_err / and_then

Khi cần biến đổi giá trị bên trong Result hay convert error type, dùng combinator thay vì match thủ công:

use std::num::ParseIntError;

#[derive(Debug)]
struct AppError(String);

fn main() {
    // map(f): transform giá trị Ok, giữ nguyên Err
    let r1: Result<u32, &str> = Ok(10);
    let r2 = r1.map(|v| v * 2);            // Ok(20)

    // map_err(f): transform error type
    // ParseIntError -> AppError
    let r3: Result<u32, AppError> = "abc"
        .parse::<u32>()
        .map_err(|e: ParseIntError| AppError(format!("parse: {e}")));
    // Err(AppError("parse: invalid digit found in string"))

    // and_then(f): chain — closure trả về Result, auto-flatten
    fn check_positive(n: i32) -> Result<i32, &'static str> {
        if n > 0 { Ok(n) } else { Err("phải dương") }
    }

    let r4 = "42".parse::<i32>()
        .map_err(|_| "không phải số")
        .and_then(check_positive);          // Ok(42)

    let r5 = "-5".parse::<i32>()
        .map_err(|_| "không phải số")
        .and_then(check_positive);          // Err("phải dương")

    println!("{r2:?} {r3:?} {r4:?} {r5:?}");
}

Vai trò ba method:

  • map(|t| ...): Result<T, E>Result<U, E>. Áp closure lên giá trị Ok; nếu Err thì giữ nguyên không gọi closure.
  • map_err(|e| ...): Result<T, E1>Result<T, E2>. Cốt lõi để gom error giữa các crate về một AppError chung — chính là cách bạn unify error type trước khi propagate qua ?.
  • and_then(|t| ...) -> Result: closure trả về Result mới — Rust tự flatten để không bị Result<Result<U, E>, E>. Đây là cách "chain fallible step" trước khi học operator ? ở bài 143.

Đọc chain combinator như một pipeline: "parse → đổi error → validate → transform" — phẳng tuyến tính, không nest, mỗi bước rõ vai trò. Operator ? sẽ làm cùng việc này nhưng cú pháp giống code đồng bộ, gọn hơn nữa.

8

Convert OptionResult

Tình huống rất thường gặp: bạn có một Option<T> từ HashMap::get hay Vec::first, nhưng API của bạn lại cần trả Result để caller biết lý do "không có". Hai method ok_or / ok_or_else trên Option giải quyết:

use std::collections::HashMap;

#[derive(Debug)]
enum ConfigError {
    MissingKey(String),
    IoFail(String),
}

fn get_port(cfg: &HashMap<String, String>) -> Result<&String, ConfigError> {
    // HashMap::get trả Option<&String> — convert sang Result
    cfg.get("port")
        .ok_or(ConfigError::MissingKey("port".to_string()))
}

fn get_host_lazy(cfg: &HashMap<String, String>) -> Result<&String, ConfigError> {
    // ok_or_else: error chỉ build khi None — tránh tốn kém vô ích
    cfg.get("host").ok_or_else(|| {
        ConfigError::MissingKey("host".to_string())
    })
}

fn main() {
    let mut cfg = HashMap::new();
    cfg.insert("port".to_string(), "8080".to_string());

    println!("{:?}", get_port(&cfg));        // Ok("8080")
    println!("{:?}", get_host_lazy(&cfg));   // Err(MissingKey("host"))
}

Phân biệt hai biến thể:

  • opt.ok_or(error): Some(v) => Ok(v), None => Err(error). Error được tính ngay (eager) — không phù hợp nếu việc tạo error tốn kém (alloc String, format complex, đọc file).
  • opt.ok_or_else(|| compute_err()): error tính lazy qua closure, chỉ chạy khi None. Idiom khi error cần String/format! — tiết kiệm allocation ở happy path.

Ngược chiều, Result::ok() đã giới thiệu ở mục 6: vứt error đi để xuống còn Option. Hai chiều convert này cho phép bạn chọn loại nào hợp với từng layer — layer thấp giữ Option (đơn giản), layer cao chuyển sang Result để mang error context tới caller.

9

Idiom: Function Trả Result

Idiom Rust: function fallible khai báo rõ ràng qua return type Result<T, E> — không panic giữa chừng, không silent swallow, không "return giá trị magic" như -1. Caller đọc signature là biết có thể fail, và compiler ép họ handle.

use std::fs;

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(String),
    MissingField(&'static str),
}

#[derive(Debug)]
struct Config {
    port: u16,
    host: String,
}

// Signature kể rõ "có thể fail, vì ba lý do"
fn parse_config(path: &str) -> Result<Config, ConfigError> {
    let raw = fs::read_to_string(path)
        .map_err(ConfigError::Io)?;          // (preview ?)

    // Parse rất đơn giản — chỉ minh hoạ idiom
    let mut port: Option<u16> = None;
    let mut host: Option<String> = None;
    for line in raw.lines() {
        let (k, v) = line.split_once('=')
            .ok_or_else(|| ConfigError::Parse(line.to_string()))?;
        match k.trim() {
            "port" => port = Some(v.trim().parse()
                .map_err(|_| ConfigError::Parse(v.to_string()))?),
            "host" => host = Some(v.trim().to_string()),
            _ => {} // bỏ qua key lạ
        }
    }

    Ok(Config {
        port: port.ok_or(ConfigError::MissingField("port"))?,
        host: host.ok_or(ConfigError::MissingField("host"))?,
    })
}

fn main() {
    match parse_config("app.conf") {
        Ok(cfg) => println!("config OK: {cfg:?}"),
        Err(ConfigError::Io(e))   => eprintln!("đọc file fail: {e}"),
        Err(ConfigError::Parse(s))=> eprintln!("dòng sai cú pháp: {s}"),
        Err(ConfigError::MissingField(f)) => eprintln!("thiếu field: {f}"),
    }
}

Nguyên tắc viết function fallible đúng idiom:

  • Return type nói thật: nếu có thể fail, ghi Result<T, E>. Đừng "giấu" lỗi sau giá trị magic hay panic.
  • Không panic giữa hàm trừ khi đụng bug invariant (không phải lỗi runtime kỳ vọng).
  • Caller decide: function của bạn chỉ mô tả lỗi qua E; caller mới biết "log rồi continue" hay "exit" hay "retry" — đừng giành quyết định đó.
  • Error type có cấu trúc: dùng enum (như ConfigError) thay vì String chung chung, để caller match được từng nhánh khi cần xử lý khác nhau.
  • Document khi nào trả Err trong doc-comment (# Errors section) — caller đọc là biết ngay danh sách lỗi có thể gặp.
10

Tổng Kết

  • Result<T, E> là idiom error handling tiêu chuẩn của Rust — thay cho exception (Java/Python) và error code (C). Mọi fallible function trả Result và compiler ép caller phải handle.
  • std::io::Result<T> là type alias Result<T, std::io::Error> — pattern stdlib và crate ecosystem hay dùng để cố định error type cho từng module.
  • Match exhaustive cả hai nhánh Ok/Err là cách handle đầy đủ; compiler ép cover hết, không bỏ sót.
  • if let Ok(v) = r / if let Err(e) = &r là shortcut khi chỉ care một nhánh — gọn nhưng không exhaustive nên dễ "nuốt" lỗi.
  • Method query/convert: is_ok/is_err kiểm tra trạng thái; ok()/err() convert sang Option (lose info); as_ref() borrow không consume.
  • Transformation: map(|t| ...) đổi Ok, map_err(|e| ...) đổi error type (gom về AppError), and_then(|t| ...) -> Result chain fallible bước.
  • Convert OptionResult: opt.ok_or(err) (eager) hoặc opt.ok_or_else(|| compute_err()) (lazy) — bổ sung lý do cho nhánh None.
  • Function fallible đúng idiom: signature fn parse_config(path: &str) -> Result<Config, ConfigError>; không panic giữa hàm; error type là enum có cấu trúc; document khi nào trả Err.

Bài 141 đặt nền cho cả Nhóm 19 Error Handling. Các bài kế tiếp sẽ xây thêm: operator ? propagate (143), Box<dyn Error> universal (144), custom error type (145), From trait conversion (146), và crate anyhow/thiserror ở cuối nhóm.

11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Vì sao Rust không có exception? Liệt kê 3 lợi ích của Result so với exception về mặt type system, cost, và caller awareness.
  2. Viết một type alias pub type AppResult<T> = Result<T, AppError>; trong module riêng. Đổi signature 3 function fictive (load_user, load_config, save_session) để dùng alias đó. So sánh độ dài với version đầy đủ Result<T, AppError>.
  3. Cho let r: Result<u32, &str> = Ok(10);. Viết match exhaustive, sau đó viết lại bằng if let Ok(v) = r { ... } else { ... }. Hai cách khác nhau ở điểm gì về mặt exhaustive checking?
  4. Hàm HashMap::get(&key) trả Option<&V>. Viết function fn get_setting(cfg: &HashMap<String, String>, key: &str) -> Result<&String, MyError> dùng ok_or_else để convert None thành MyError::MissingKey(key.to_string()). Vì sao nên dùng ok_or_else thay vì ok_or ở đây?
  5. Viết function parse_user(s: &str) -> Result<(String, u32), AppError> cho input dạng "alice:30": split tại :, parse phần sau thành u32. Dùng chain map_err + and_then hoặc ok_or tuỳ chỗ. Đảm bảo error type cuối là AppError cho cả split và parse fail.
Đáp án
  1. (1) Type system: Result là một kiểu trong signature — compiler biết function có thể fail và ép handle, exception là "hidden control flow" không xuất hiện trên signature ở đa số ngôn ngữ. (2) Cost: Result chỉ là enum return value, không có stack unwinding mỗi lỗi; exception thường có cost lớn khi bay xuyên frame. (3) Caller awareness: #[must_use] trên Result + bắt buộc match exhaustive khiến caller nhận thấy ngay; exception có thể bị quên catch và crash bất ngờ ở runtime.
  2. pub type AppResult<T> = Result<T, AppError>; đặt trong src/error.rs chẳng hạn. Sau đó: fn load_user(id: u64) -> AppResult<User>, fn load_config(path: &str) -> AppResult<Config>, fn save_session(s: &Session) -> AppResult<()>. Mỗi signature ngắn đi 9-10 ký tự, lặp đi lặp lại khắp crate là gọn rõ rệt.
  3. Match: match r { Ok(v) => ..., Err(e) => ... } — compiler ép cover cả hai. if let: chỉ chạy đúng nhánh khớp, else chạy phần còn lại — không bị compile error nếu thiếu nhánh, mất tính exhaustive checking. Hệ quả: if let tiện nhưng nếu enum sau này thêm variant, match sẽ buộc bạn cập nhật, if let thì không cảnh báo.
  4. fn get_setting(cfg: &HashMap<String, String>, key: &str) -> Result<&String, MyError> { cfg.get(key).ok_or_else(|| MyError::MissingKey(key.to_string())) }. Dùng ok_or_else vì việc tạo String qua to_string() có alloc — nếu dùng ok_or(MyError::MissingKey(key.to_string())) thì String sẽ alloc kể cả khi Some, phí vô ích. ok_or_else lazy chỉ build error khi None.
  5. fn parse_user(s: &str) -> Result<(String, u32), AppError> { let (name, age_str) = s.split_once(':').ok_or(AppError::BadFormat(s.to_string()))?; let age: u32 = age_str.parse().map_err(|e| AppError::ParseAge(format!("{e}")))?; Ok((name.to_string(), age)) }. Hoặc viết bằng chain combinator nguyên: s.split_once(':').ok_or(AppError::BadFormat(s.to_string())).and_then(|(n, a)| a.parse::<u32>().map_err(|e| AppError::ParseAge(format!("{e}"))).map(|age| (n.to_string(), age))). Operator ? (bài 143) sẽ làm version đầu tiên gọn hơn nữa.
12

Bài Tiếp Theo

Bài 142: unwrap() vs expect() — Khi Nào Dùng — đi vào chi tiết hai method "rút giá trị nhanh và nguy hiểm": cả hai panic nếu gặp Err, nhưng expect("msg") kèm message để dễ trace; khi nào dùng được trong prototype/test, khi nào tuyệt đối không dùng trong production hot path, và idiom đặt message dạng "should <invariant>" để document vì sao kỳ vọng Ok.