本文 AI 產出,尚未審核

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::Error trait 的自訂錯誤或是標準庫提供的錯誤型別(如 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 解構成 OkErr,必須 完整列舉 所有可能的變化,編譯器會保證不會遺漏。

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>,呼叫端可以自由選擇 matchunwrap? 來處理錯誤。


範例 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> 讓函式可以返回任意實作了 Error trait 的錯誤型別,簡化錯誤傳遞。
  • ? 會自動把 io::Errorserde_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),
    }
}
  • 透過 From trait? 能自動把不同來源的錯誤轉換成同一個自訂錯誤型別,讓錯誤鏈更乾淨。

範例 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::collectResult 有特殊實作:只要任一元素是 Err,整個收集結果就會是 Err,非常適合一次性驗證多筆資料。

常見陷阱與最佳實踐

陷阱 說明 建議的做法
忘記處理 Result 編譯器會警告未使用的 Result,但有時會用 _ = expr; 隱藏錯誤。 務必使用 match? 或其他方法 明確處理或傳遞錯誤。
過度使用 unwrap 在非測試程式碼中直接 unwrap 會在錯誤時 panic,失去 Rust 的安全保證。 盡量改用 ?unwrap_or_else,只有在「理論上不會失敗」的情況下才使用 unwrap
錯誤類型過於具體 每個函式返回不同的錯誤型別,導致呼叫端必須處理大量分支。 使用 自訂錯誤列舉Box<dyn Error> 統一錯誤介面。
忽略錯誤資訊 只回傳字串或 (),失去錯誤的上下文。 保留原始錯誤(如 io::Error)或使用 thiserroranyhow 加上上下文說明。
match 中忘記 ref/ref mut 解構 Result 後若需要借用而非取得所有權,忘記加 ref 會導致所有權移動錯誤。 根據需求使用 Ok(ref v)Ok(ref mut v)

最佳實踐

  1. 盡可能使用 ?:它讓錯誤傳遞變得簡潔,同時保留錯誤的原始類型。
  2. 建立統一的錯誤型別:在大型專案中,建議使用 thiserror 宏或手動實作 Error trait,讓所有函式回傳同一個錯誤列舉。
  3. 加上錯誤上下文:使用 anyhow::Context 或自行在 Err 中包裝訊息,方便除錯與日誌。
  4. 測試錯誤路徑:寫單元測試時,務必驗證 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 錯誤處理的核心,透過列舉與模式匹配,我們可以:

  • 明確區分成功與失敗,編譯器保證不會遺漏任何錯誤分支。
  • 使用 ?matchunwrap_or 等工具,在不同情境下選擇最合適的錯誤處理方式。
  • 自訂錯誤型別或使用 Box<dyn Error>,讓錯誤資訊保持完整且易於傳遞。
  • 結合迭代器、From 轉換,讓錯誤處理在大型程式碼基底中仍保持簡潔與可讀。

掌握 Result 的使用,不僅能提升程式的 安全性,也能讓錯誤資訊在除錯與日誌中發揮最大價值。未來在開發 CLI 工具、網路服務或嵌入式系統時,請務必將 Result 作為第一選擇,讓你的 Rust 程式碼在 正確性可維護性 上都達到業界水準。祝你寫程式快樂,錯誤處理無憂!