Rust 課程 – 錯誤處理
主題:自訂錯誤型別(Custom Error Types)
簡介
在 Rust 中,錯誤處理是語言設計的核心之一。與許多傳統語言不同,Rust 採用 Result<T, E> 與 Option<T> 兩個列舉型別,讓錯誤在編譯期就能被顯式捕捉,避免了執行時的不可預期崩潰。雖然標準庫已經提供了許多常見錯誤型別(例如 std::io::Error、std::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::Error 與 Display
要讓自訂錯誤能與其他錯誤互相轉換,我們需要實作 std::error::Error 與 std::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 簡化實作
手動為每個變體寫 Display、From、Error 會很繁瑣。社群提供的 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 + Sync(thiserror 已自動處理) |
最佳實踐總結:
- 定義一個專案層級的錯誤列舉,所有模組的錯誤都向它彙聚。
- 使用
thiserror(或anyhow只在應用層)減少樣板程式碼。 - 提供清晰的
Display,讓使用者能快速了解問題。 - 保留底層錯誤的
source(),方便日誌與除錯。 - 在公共 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-web 或 warp 建立 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)、Error 與 Display 實作、以及 From 轉換,我們可以:
- 提供語意化、易於理解的錯誤訊息,提升使用者體驗。
- 建立錯誤鏈結,讓底層原因不會在傳遞過程中遺失。
- 統一錯誤介面,在大型專案或跨服務溝通時減少耦合度。
- 利用
thiserror、anyhow等社群工具,減少樣板程式碼,專注於業務邏輯。
在實務開發中,建議從專案最外層就設計好一個 全域錯誤列舉,並在每個模組內部使用 From 與 source() 逐層向上彙聚。這樣不僅讓程式碼更具可讀性,也能在除錯、日誌、測試與 API 回傳時保持一致性。
掌握了自訂錯誤型別,你就能在 Rust 生態系統中寫出安全、可維護且具備良好錯誤回報的程式,為你的專案奠定堅實的基礎。祝你在 Rust 的錯誤處理之路上玩得開心,寫出更好的程式!