Rust 課程 – 列舉與模式匹配
主題:Result 列舉(錯誤處理)
簡介
在系統程式語言中,錯誤處理往往是最容易忽視、卻最關鍵的部分。Rust 以「不會讓程式在執行時崩潰」為設計目標,提供了 Result<T, E> 這個列舉(enum)來表達「成功」或「失敗」的兩種可能。相較於傳統的例外機制(exception),Result 必須在編譯期被顯式處理,讓開發者在撰寫程式時就能意識到每一次 I/O、網路或資料轉換可能產生的錯誤。
本單元將深入探討 Result 的結構、常見的模式匹配技巧,以及在實務開發中如何以 安全且易讀 的方式使用它。即使你是剛接觸 Rust 的新手,只要跟著本文的步驟操作,就能在自己的專案裡快速導入可靠的錯誤處理機制。
核心概念
1. Result 的定義
enum Result<T, E> {
Ok(T), // 成功,攜帶一個值 T
Err(E), // 失敗,攜帶一個錯誤類型 E
}
T代表「成功時的返回值」型別。E代表「失敗時的錯誤資訊」型別,通常是實作了std::error::Errortrait 的自訂錯誤或是標準庫提供的錯誤型別(如io::Error)。
Result 本身是一個 泛型列舉,因此可以在不同情境下重複使用,而不需要為每一種錯誤寫新的型別。
2. 為什麼要使用 Result 而不是 panic!
panic!會立刻終止執行緒,適合「不可恢復的錯誤」或測試階段的斷言。Result則是 可恢復的錯誤,允許呼叫端決定是傳遞、記錄或是提供備援方案。
⚠️ 在大多數 I/O、網路或資料解析的情況下,應優先使用
Result,除非錯誤真的無法處理。
3. 基本的模式匹配 (match)
fn read_file(path: &str) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}
fn demo() {
match read_file("data.txt") {
Ok(content) => println!("檔案內容:\n{}", content),
Err(e) => eprintln!("讀取失敗:{}", e),
}
}
match會將Result解構成Ok或Err,必須 完整列舉 所有可能的變化,編譯器會保證不會遺漏。
4. 常用的快捷方法
| 方法 | 說明 | 範例 |
|---|---|---|
unwrap() |
取得 Ok 內的值,若是 Err 直接 panic! |
let v = res.unwrap(); |
expect(msg) |
同 unwrap(),但可自訂錯誤訊息 |
let v = res.expect("必須成功取得值"); |
unwrap_or(default) |
若為 Err,回傳預設值 |
let v = res.unwrap_or(0); |
| `unwrap_or_else( | e | ...)` |
? 操作符 |
讓錯誤自動向上層傳遞(最常用) | let s = std::fs::read_to_string(path)?; |
程式碼範例
範例 1:簡易的除法函式,使用 Result 防止除以零
fn divide(a: f64, b: f64) -> Result<f64, &'static str> {
if b == 0.0 {
Err("除數不能為零") // 回傳錯誤訊息
} else {
Ok(a / b) // 成功回傳結果
}
}
fn main() {
// 使用 match 處理結果
match divide(10.0, 0.0) {
Ok(v) => println!("結果 = {}", v),
Err(e) => eprintln!("錯誤:{}", e),
}
// 使用 ? 操作符向上層傳遞錯誤
let r = try_divide(10.0, 2.0).expect("除法不應失敗");
println!("10 / 2 = {}", r);
}
// 使用 ? 的輔助函式
fn try_divide(a: f64, b: f64) -> Result<f64, &'static str> {
let result = divide(a, b)?; // 若 Err,直接返回 Err
Ok(result)
}
重點:
divide回傳Result<f64, &'static str>,呼叫端可以自由選擇match、unwrap或?來處理錯誤。
範例 2:讀取檔案並解析 JSON,結合多層 Result
use std::fs;
use std::io;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Config {
host: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
// 1. 讀檔案 → Result<String, io::Error>
let raw = fs::read_to_string(path)?;
// 2. 解析 JSON → Result<Config, serde_json::Error>
let cfg: Config = serde_json::from_str(&raw)?;
Ok(cfg) // 若前兩步皆成功,回傳 Config
}
fn main() {
match load_config("config.json") {
Ok(cfg) => println!("設定載入成功:{:?}", cfg),
Err(e) => eprintln!("載入設定失敗:{}", e),
}
}
- 這裡使用
Box<dyn std::error::Error>讓函式可以返回任意實作了Errortrait 的錯誤型別,簡化錯誤傳遞。 ?會自動把io::Error與serde_json::Error轉換成同一個Box<dyn Error>。
範例 3:自訂錯誤型別與 From 轉換
use std::fmt;
// 自訂錯誤列舉
#[derive(Debug)]
enum MyError {
Io(std::io::Error),
ParseInt(std::num::ParseIntError),
}
// 為 MyError 實作 Display
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyError::Io(e) => write!(f, "I/O 錯誤: {}", e),
MyError::ParseInt(e) => write!(f, "解析錯誤: {}", e),
}
}
}
// 讓 std::io::Error 自動轉換成 MyError::Io
impl From<std::io::Error> for MyError {
fn from(e: std::io::Error) -> Self {
MyError::Io(e)
}
}
// 讓 ParseIntError 自動轉換成 MyError::ParseInt
impl From<std::num::ParseIntError> for MyError {
fn from(e: std::num::ParseIntError) -> Self {
MyError::ParseInt(e)
}
}
// 使用自訂錯誤的函式
fn read_number(path: &str) -> Result<i32, MyError> {
let s = std::fs::read_to_string(path)?; // 會自動轉成 MyError::Io
let n: i32 = s.trim().parse()?; // 會自動轉成 MyError::ParseInt
Ok(n)
}
fn main() {
match read_number("num.txt") {
Ok(v) => println!("讀到的數字是 {}", v),
Err(e) => eprintln!("發生錯誤:{}", e),
}
}
- 透過
Fromtrait,?能自動把不同來源的錯誤轉換成同一個自訂錯誤型別,讓錯誤鏈更乾淨。
範例 4:Result 與迭代器的結合
fn parse_numbers(lines: Vec<&str>) -> Result<Vec<i32>, std::num::ParseIntError> {
// map 產生 Result<i32, _>,collect 會自動把所有 Ok 收集成 Vec,若有 Err 則直接返回
lines.into_iter().map(|s| s.parse::<i32>()).collect()
}
fn main() {
let data = vec!["10", "20", "三十", "40"];
match parse_numbers(data) {
Ok(nums) => println!("全部解析成功:{:?}", nums),
Err(e) => eprintln!("解析失敗:{}", e),
}
}
Iterator::collect對Result有特殊實作:只要任一元素是Err,整個收集結果就會是Err,非常適合一次性驗證多筆資料。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的做法 |
|---|---|---|
忘記處理 Result |
編譯器會警告未使用的 Result,但有時會用 _ = expr; 隱藏錯誤。 |
務必使用 match、? 或其他方法 明確處理或傳遞錯誤。 |
過度使用 unwrap |
在非測試程式碼中直接 unwrap 會在錯誤時 panic,失去 Rust 的安全保證。 |
盡量改用 ? 或 unwrap_or_else,只有在「理論上不會失敗」的情況下才使用 unwrap。 |
| 錯誤類型過於具體 | 每個函式返回不同的錯誤型別,導致呼叫端必須處理大量分支。 | 使用 自訂錯誤列舉 或 Box<dyn Error> 統一錯誤介面。 |
| 忽略錯誤資訊 | 只回傳字串或 (),失去錯誤的上下文。 |
保留原始錯誤(如 io::Error)或使用 thiserror、anyhow 加上上下文說明。 |
在 match 中忘記 ref/ref mut |
解構 Result 後若需要借用而非取得所有權,忘記加 ref 會導致所有權移動錯誤。 |
根據需求使用 Ok(ref v) 或 Ok(ref mut v)。 |
最佳實踐
- 盡可能使用
?:它讓錯誤傳遞變得簡潔,同時保留錯誤的原始類型。 - 建立統一的錯誤型別:在大型專案中,建議使用
thiserror宏或手動實作Errortrait,讓所有函式回傳同一個錯誤列舉。 - 加上錯誤上下文:使用
anyhow::Context或自行在Err中包裝訊息,方便除錯與日誌。 - 測試錯誤路徑:寫單元測試時,務必驗證
Err分支的行為,確保錯誤不會被意外吞掉。
實際應用場景
| 場景 | 為何使用 Result |
範例簡述 |
|---|---|---|
| 檔案 I/O | 讀寫磁碟可能因權限、磁碟滿等原因失敗 | std::fs::read_to_string(path)? |
| 網路請求 | 連線、超時、解析失敗皆屬可恢復錯誤 | 使用 reqwest::blocking::get(url).await? |
| 資料庫操作 | 交易失敗、連線池耗盡需要回報給上層 | diesel::result::Error 包裝在 Result<T, DbError> |
| CLI 參數解析 | 使用者輸入錯誤或缺少必要參數 | clap::Parser::try_parse_from(args)? |
| 多步驟工作流 | 每一步都可能失敗,必須在失敗時回滾或補償 | 透過 ? 逐層傳遞,最外層統一處理回滾邏輯 |
實務小技巧:在服務端程式中,常見的做法是把最外層的
main定義為fn main() -> Result<(), Box<dyn Error>>,讓整個程式的錯誤都能以?直接傳遞到main,最終交由 runtime 印出錯誤訊息或寫入日誌。
總結
Result<T, E> 是 Rust 錯誤處理的核心,透過列舉與模式匹配,我們可以:
- 明確區分成功與失敗,編譯器保證不會遺漏任何錯誤分支。
- 使用
?、match、unwrap_or等工具,在不同情境下選擇最合適的錯誤處理方式。 - 自訂錯誤型別或使用
Box<dyn Error>,讓錯誤資訊保持完整且易於傳遞。 - 結合迭代器、
From轉換,讓錯誤處理在大型程式碼基底中仍保持簡潔與可讀。
掌握 Result 的使用,不僅能提升程式的 安全性,也能讓錯誤資訊在除錯與日誌中發揮最大價值。未來在開發 CLI 工具、網路服務或嵌入式系統時,請務必將 Result 作為第一選擇,讓你的 Rust 程式碼在 正確性 與 可維護性 上都達到業界水準。祝你寫程式快樂,錯誤處理無憂!