本文 AI 產出,尚未審核

Rust 課程 – 錯誤處理

主題:自訂錯誤型別(Custom Error Types)


簡介

在 Rust 中,錯誤處理是語言設計的核心之一。與許多傳統語言不同,Rust 採用 Result<T, E>Option<T> 兩個列舉型別,讓錯誤在編譯期就能被顯式捕捉,避免了執行時的不可預期崩潰。雖然標準庫已經提供了許多常見錯誤型別(例如 std::io::Errorstd::num::ParseIntError),在實際專案中,我們常常需要根據業務需求自行定義錯誤,讓錯誤資訊更具語意、易於傳遞與記錄。

自訂錯誤型別不僅能統一錯誤介面,還能結合 std::error::Error trait 讓錯誤向上轉型(error chaining),在大型系統中提供清晰的錯誤路徑。本文將一步步帶你了解如何在 Rust 中建立、使用與最佳化自訂錯誤型別,並提供實務範例、常見陷阱與最佳實踐,協助你在專案中寫出更安全、更易維護的程式碼。


核心概念

1. 為什麼要自訂錯誤型別?

  • 語意化:自訂錯誤能直接表達「檔案不存在」或「使用者輸入不合法」等業務層面的錯誤,而不是僅僅返回一個通用的 io::Error
  • 錯誤鏈結:透過 source() 方法,我們可以把底層錯誤(如 std::io::Error)包裝在自訂錯誤裡,形成錯誤鏈,方便除錯與日誌記錄。
  • 統一介面:在函式庫或微服務之間傳遞錯誤時,只要遵守同一個錯誤列舉,就能讓呼叫端只關注 Result<T, MyError>,不必了解每個模組的細節。

2. 基本的自訂錯誤列舉

最簡單的自訂錯誤型別就是使用 enum,每個變體代表一種錯誤情境:

/// 代表本專案的所有錯誤
#[derive(Debug)]
pub enum MyError {
    /// 讀檔失敗,內含底層的 `std::io::Error`
    IoError(std::io::Error),

    /// 解析字串成數字失敗,內含底層的 `std::num::ParseIntError`
    ParseError(std::num::ParseIntError),

    /// 業務規則錯誤,例如使用者提供的年齡小於 0
    InvalidAge(i32),

    /// 其他未分類的錯誤
    Other(String),
}

#[derive(Debug)] 讓錯誤可以使用 {:?} 印出,對除錯非常有幫助。

3. 為自訂錯誤實作 std::error::ErrorDisplay

要讓自訂錯誤能與其他錯誤互相轉換,我們需要實作 std::error::Errorstd::fmt::Display

use std::fmt;

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MyError::IoError(e) => write!(f, "I/O 錯誤: {}", e),
            MyError::ParseError(e) => write!(f, "解析錯誤: {}", e),
            MyError::InvalidAge(age) => write!(f, "年齡不合法: {}", age),
            MyError::Other(msg) => write!(f, "其他錯誤: {}", msg),
        }
    }
}

impl std::error::Error for MyError {
    // 讓錯誤鏈結到底層錯誤
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            MyError::IoError(e) => Some(e),
            MyError::ParseError(e) => Some(e),
            _ => None,
        }
    }
}
  • Display 用於人類可讀的錯誤訊息,建議提供足夠資訊讓使用者或開發者快速定位問題。
  • source() 僅在包含底層錯誤時返回 Some,否則回傳 None

4. 使用 From 讓錯誤自動轉換

在實務開發中,我們常常會把底層函式的錯誤直接 ? 傳遞上層。若要讓 ? 能自動把 std::io::Error 轉成 MyError::IoError,只需要為 MyError 實作 From

impl From<std::io::Error> for MyError {
    fn from(err: std::io::Error) -> Self {
        MyError::IoError(err)
    }
}

impl From<std::num::ParseIntError> for MyError {
    fn from(err: std::num::ParseIntError) -> Self {
        MyError::ParseError(err)
    }
}

有了這兩個 From 實作,下面的程式碼就可以直接使用 ?

use std::fs::File;
use std::io::{self, Read};

fn read_number_from_file(path: &str) -> Result<i32, MyError> {
    // 讀檔可能失敗 → 會自動轉成 MyError::IoError
    let mut file = File::open(path)?;
    let mut contents = String::new();
    // 讀取內容可能失敗 → 同上
    file.read_to_string(&mut contents)?;

    // 解析字串成 i32 可能失敗 → 會自動轉成 MyError::ParseError
    let number: i32 = contents.trim().parse()?;
    Ok(number)
}

5. 多層錯誤鏈結(Error Chaining)

有時候我們需要在自訂錯誤中再包裝另一個自訂錯誤,形成 多層錯誤鏈。下面示範一個「網路請求」模組的錯誤,它會把底層的 reqwest::Error 包裝成 NetworkError,再由上層的 MyError 包裝:

#[derive(Debug)]
pub enum NetworkError {
    Http(reqwest::Error),
    Timeout,
    InvalidUrl(String),
}

impl fmt::Display for NetworkError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            NetworkError::Http(e) => write!(f, "HTTP 錯誤: {}", e),
            NetworkError::Timeout => write!(f, "請求逾時"),
            NetworkError::InvalidUrl(url) => write!(f, "無效的 URL: {}", url),
        }
    }
}

impl std::error::Error for NetworkError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            NetworkError::Http(e) => Some(e),
            _ => None,
        }
    }
}

// 讓 NetworkError 能自動轉成 MyError
impl From<NetworkError> for MyError {
    fn from(err: NetworkError) -> Self {
        MyError::Other(err.to_string())
    }
}

// 範例函式
async fn fetch_json(url: &str) -> Result<serde_json::Value, MyError> {
    // 先檢查 URL 合法性
    if !url.starts_with("https://") {
        return Err(MyError::Other(format!("不支援的 URL: {}", url)));
    }

    let resp = reqwest::get(url).await.map_err(NetworkError::Http)?;
    let json = resp.json::<serde_json::Value>().await.map_err(NetworkError::Http)?;
    Ok(json)
}

重點map_err 可以把底層錯誤映射成我們自訂的錯誤型別,配合 From 讓上層 ? 繼續傳遞。

6. 使用 thiserror 簡化實作

手動為每個變體寫 DisplayFromError 會很繁瑣。社群提供的 thiserror crate 能自動產生這些實作,只需要加上屬性標註:

# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;

#[derive(Debug, Error)]
pub enum MyError {
    #[error("I/O 錯誤: {0}")]
    Io(#[from] std::io::Error),

    #[error("解析錯誤: {0}")]
    Parse(#[from] std::num::ParseIntError),

    #[error("年齡不合法: {0}")]
    InvalidAge(i32),

    #[error("其他錯誤: {0}")]
    Other(String),
}
  • #[from] 會自動為對應型別產生 From 實作。
  • #[error("…")] 直接提供 Display 的格式字串,省去手寫 fmt::Display

常見陷阱與最佳實踐

陷阱 可能的結果 建議的做法
忘記實作 From ? 無法自動轉換,必須手動 map_err,程式碼冗長 為所有可能向上拋出的錯誤實作 From,或使用 thiserror#[from]
Display 中直接 {:?} 錯誤訊息過於技術化,使用者不易理解 Display 應提供使用者友好的訊息,Debug 留給開發者除錯
把所有錯誤都塞進 Other(String) 失去錯誤類型資訊,無法做精細的錯誤分支 盡量保留每個錯誤的結構,僅在真的無法分類時使用 Other
忽略 source() 錯誤鏈斷裂,日誌只能看到最外層訊息 為包含底層錯誤的變體實作 source(),讓 Error::chain 正常工作
enum 中放入過多資料 錯誤型別變得龐大,記憶體與傳遞成本提升 僅保留必要資訊,過多資料可放在 Box<dyn Error> 或自訂結構中
在 async 環境忘記 Send + Sync 編譯錯誤或執行時 panic 若錯誤會跨執行緒傳遞,確保它實作 Send + Syncthiserror 已自動處理)

最佳實踐總結

  1. 定義一個專案層級的錯誤列舉,所有模組的錯誤都向它彙聚。
  2. 使用 thiserror(或 anyhow 只在應用層)減少樣板程式碼。
  3. 提供清晰的 Display,讓使用者能快速了解問題。
  4. 保留底層錯誤的 source(),方便日誌與除錯。
  5. 在公共 API 中返回 Result<T, MyError>,避免直接暴露底層錯誤型別。

實際應用場景

1. 檔案與資料庫混合操作

假設我們開發一個 CLI 工具,需要同時讀取 CSV 檔案、寫入 SQLite,錯誤來源可能是檔案 I/O、CSV 解析、資料庫連線或 SQL 執行。以下示範如何用單一錯誤列舉統一管理:

use thiserror::Error;
use rusqlite::{Connection, params};

#[derive(Debug, Error)]
pub enum AppError {
    #[error("I/O 錯誤: {0}")]
    Io(#[from] std::io::Error),

    #[error("CSV 解析錯誤: {0}")]
    Csv(#[from] csv::Error),

    #[error("資料庫錯誤: {0}")]
    Db(#[from] rusqlite::Error),

    #[error("資料驗證失敗: {0}")]
    Validation(String),
}

// 讀取 CSV 並寫入資料庫
fn import_csv_to_db(csv_path: &str, db_path: &str) -> Result<(), AppError> {
    // 1. 開檔
    let file = std::fs::File::open(csv_path)?;
    let mut rdr = csv::Reader::from_reader(file);

    // 2. 建立 DB 連線
    let conn = Connection::open(db_path)?;

    // 3. 逐列寫入
    for result in rdr.records() {
        let record = result?; // 可能是 csv::Error
        // 假設 CSV 有兩欄: name, age
        let name = record.get(0).ok_or_else(|| AppError::Validation("缺少 name 欄位".into()))?;
        let age: i32 = record
            .get(1)
            .ok_or_else(|| AppError::Validation("缺少 age 欄位".into()))?
            .parse()
            .map_err(|_| AppError::Validation("age 必須是整數".into()))?;

        conn.execute(
            "INSERT INTO users (name, age) VALUES (?1, ?2)",
            params![name, age],
        )?;
    }
    Ok(())
}
  • 單一錯誤列舉 讓 CLI 的 main 只需要 match err 一次,就能根據錯誤類型輸出不同的訊息或錯誤碼。

2. Web 服務的錯誤回傳

在使用 actix-webwarp 建立 API 時,我們通常會把業務錯誤轉成 HTTP 狀態碼。以下示範如何在 Result<T, MyError> 基礎上實作 ResponseError

use actix_web::{error::ResponseError, HttpResponse};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("使用者未授權")]
    Unauthorized,

    #[error("資源找不到: {0}")]
    NotFound(String),

    #[error("內部伺服器錯誤")]
    Internal,
}

impl ResponseError for ApiError {
    fn error_response(&self) -> HttpResponse {
        match self {
            ApiError::Unauthorized => HttpResponse::Unauthorized().finish(),
            ApiError::NotFound(msg) => HttpResponse::NotFound().body(msg.clone()),
            ApiError::Internal => HttpResponse::InternalServerError().finish(),
        }
    }
}

// handler 範例
async fn get_user(id: web::Path<u32>) -> Result<HttpResponse, ApiError> {
    // 假設查不到就回傳 NotFound
    let user = find_user(*id).ok_or_else(|| ApiError::NotFound(format!("id={}", id)))?;
    Ok(HttpResponse::Ok().json(user))
}
  • ResponseError 讓錯誤自動映射成 HTTP 回應,保持 API 行為一致。

3. 以 anyhow 捕捉不可預期錯誤

測試或腳本 層面,我們不需要細分每個錯誤,只要快速捕捉並打印堆疊資訊。此時可以使用 anyhow::Error,但仍保留自訂錯誤作為底層:

use anyhow::{Context, Result};

fn run() -> Result<()> {
    // 任何 MyError 都可以自動轉成 anyhow::Error
    read_number_from_file("data.txt")
        .context("讀取 data.txt 時發生錯誤")?; // 加上額外上下文
    Ok(())
}
  • anyhow 只在應用層使用,核心庫仍應保留嚴謹的自訂錯誤。

總結

自訂錯誤型別是 Rust 錯誤處理的關鍵技巧之一,透過 列舉 (enum)ErrorDisplay 實作、以及 From 轉換,我們可以:

  1. 提供語意化、易於理解的錯誤訊息,提升使用者體驗。
  2. 建立錯誤鏈結,讓底層原因不會在傳遞過程中遺失。
  3. 統一錯誤介面,在大型專案或跨服務溝通時減少耦合度。
  4. 利用 thiserroranyhow 等社群工具,減少樣板程式碼,專注於業務邏輯。

在實務開發中,建議從專案最外層就設計好一個 全域錯誤列舉,並在每個模組內部使用 Fromsource() 逐層向上彙聚。這樣不僅讓程式碼更具可讀性,也能在除錯、日誌、測試與 API 回傳時保持一致性。

掌握了自訂錯誤型別,你就能在 Rust 生態系統中寫出安全、可維護且具備良好錯誤回報的程式,為你的專案奠定堅實的基礎。祝你在 Rust 的錯誤處理之路上玩得開心,寫出更好的程式!