Mục lục
- Mục Tiêu Bài Học
ResultLà Idiom Error Handling Của Ruststd::io::Result— Type Alias Của Stdlib- Match Handling — Exhaustive Cả Hai Nhánh
if letPattern Cho One-Arm- Method Phổ Biến: Query Và Convert
- Transformation:
map/map_err/and_then - Convert
Option→Result - Idiom: Function Trả
Result - Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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
matchexhaustive xử lý cả hai nhánhOk/Errvà biết tại sao compiler ép cover cả hai. - Dùng
if let Ok(v) = rhoặcif let Err(e) = rnhư 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_thenmà không cần match thủ công — chuẩn bị nền cho operator?ở bài 143. - Convert
Option<T>sangResult<T, E>bằngok_or/ok_or_elsekhi muốn thêm lý do cho nhánhNone. - 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.
Result Là Idiom Error Handling Của Rust
Rust không có exception và khô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ênResultkhiế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);Resultchỉ 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 trongVec, 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.
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>
Vì 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.
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 "matchnot 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ếncontent;Err(e)bind error vàoe. Cú pháp giống destructure enum thường. - Mỗi nhánh là một biểu thức:
matchtrả 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.
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ánhOk. NhánhErrbị 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&rnếu sau đó còn dùngr.
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.
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 consumeResult. Dùng khi chỉ cần biết có lỗi hay không trong điều kiệnif.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ànhResult<&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.
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ếuErrthì 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ộtAppErrorchung — chính là cách bạn unify error type trước khi propagate qua?.and_then(|t| ...) -> Result: closure trả vềResultmớ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.
Convert Option → Result
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 (allocString, format complex, đọc file).opt.ok_or_else(|| compute_err()): error tính lazy qua closure, chỉ chạy khiNone. Idiom khi error cầnString/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.
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ìStringchung chung, để caller match được từng nhánh khi cần xử lý khác nhau. - Document khi nào trả
Errtrong doc-comment (# Errorssection) — caller đọc là biết ngay danh sách lỗi có thể gặp.
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ảResultvà compiler ép caller phải handle.std::io::Result<T>là type aliasResult<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/Errlà cách handle đầy đủ; compiler ép cover hết, không bỏ sót. if let Ok(v) = r/if let Err(e) = &rlà 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_errkiểm tra trạng thái;ok()/err()convert sangOption(lose info);as_ref()borrow không consume. - Transformation:
map(|t| ...)đổiOk,map_err(|e| ...)đổi error type (gom vềAppError),and_then(|t| ...) -> Resultchain fallible bước. - Convert
Option→Result:opt.ok_or(err)(eager) hoặcopt.ok_or_else(|| compute_err())(lazy) — bổ sung lý do cho nhánhNone. - 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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Vì sao Rust không có exception? Liệt kê 3 lợi ích của
Resultso với exception về mặt type system, cost, và caller awareness. - 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>. - Cho
let r: Result<u32, &str> = Ok(10);. Viết match exhaustive, sau đó viết lại bằngif let Ok(v) = r { ... } else { ... }. Hai cách khác nhau ở điểm gì về mặt exhaustive checking? - Hàm
HashMap::get(&key)trảOption<&V>. Viết functionfn get_setting(cfg: &HashMap<String, String>, key: &str) -> Result<&String, MyError>dùngok_or_elseđể convertNonethànhMyError::MissingKey(key.to_string()). Vì sao nên dùngok_or_elsethay vìok_orở đây? - 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ànhu32. Dùng chainmap_err+and_thenhoặcok_ortuỳ chỗ. Đảm bảo error type cuối làAppErrorcho cả split và parse fail.
Đáp án
- (1) Type system:
Resultlà 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:Resultchỉ 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ênResult+ 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. pub type AppResult<T> = Result<T, AppError>;đặt trongsrc/error.rschẳ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.- Match:
match r { Ok(v) => ..., Err(e) => ... }— compiler ép cover cả hai.if let: chỉ chạy đúng nhánh khớp,elsechạ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 lettiện nhưng nếu enum sau này thêm variant,matchsẽ buộc bạn cập nhật,if letthì không cảnh báo. fn get_setting(cfg: &HashMap<String, String>, key: &str) -> Result<&String, MyError> { cfg.get(key).ok_or_else(|| MyError::MissingKey(key.to_string())) }. Dùngok_or_elsevì việc tạoStringquato_string()có alloc — nếu dùngok_or(MyError::MissingKey(key.to_string()))thìStringsẽ alloc kể cả khiSome, phí vô ích.ok_or_elselazy chỉ build error khiNone.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.
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.
