Rust 錯誤處理:使用 ? 運算子
簡介
在 Rust 中,錯誤處理被設計成 安全且表達力十足 的機制。與許多語言使用例外 (exception) 不同,Rust 透過 Result<T, E> 型別將錯誤資訊明確地傳遞給呼叫端。雖然 Result 本身已經相當好用,但在日常開發中,我們常會看到大量的 match 或 if let 包裝程式碼,讓函式的主流程被錯誤檢查的樣板淹沒。
? 運算子正是為了解決這個問題而誕生的:它讓 錯誤的傳遞變得像寫普通程式碼一樣簡潔,同時保留了 Rust 編譯器在編譯期提供的嚴格檢查。掌握 ? 的使用方式,能讓你的程式碼更易讀、更易維護,也更符合 Rust 的設計哲學。
以下本文將從概念說明、實作範例、常見陷阱與最佳實踐,直到實務應用場景,完整介紹 ? 運算子的使用方式,適合剛踏入 Rust 的新手以及已有基礎的中階開發者參考。
核心概念
1. Result<T, E> 與 ? 的基本關係
Result<T, E> 是 Rust 標準庫中用來表示可能失敗的運算。
Ok(T)表示成功,攜帶一個值T。Err(E)表示失敗,攜帶錯誤資訊E。
? 運算子只能用在返回型別為 Result(或 Option)的函式內部。它的行為可概括為:
let v = expr?;
- 如果
expr回傳Ok(v),則v直接被解構,程式繼續往下執行。 - 如果
expr回傳Err(e),則 立即返回Err(e)給呼叫端,等同於return Err(e);。
重點:
?只會「提早返回」錯誤,不會改變錯誤的類型或內容。
2. 必須符合的函式簽名
使用 ? 的函式必須回傳 Result<_, E>(或 Option<_>),且錯誤型別 E 必須實作 From 轉換,以便將子函式的錯誤自動轉型。最常見的情況是:
fn foo() -> Result<i32, std::io::Error> {
// ...
}
如果子函式回傳 Result<i32, std::num::ParseIntError>,則需要 From<ParseIntError> for std::io::Error(或自行定義錯誤型別)才能直接使用 ?。
3. ? 在 Option 上的使用
Option<T> 也支援 ?,行為類似:
let v = opt?;
Some(v)→ 繼續執行。None→ 立即返回None。
這讓在需要同時處理 Result 與 Option 時,語意保持一致。
程式碼範例
以下示範 5 個實用範例,從最簡單的檔案讀取到自訂錯誤型別的傳遞,幫助你快速上手 ?。
範例 1:最簡單的檔案讀取
use std::fs::File;
use std::io::{self, Read};
fn read_to_string(path: &str) -> Result<String, io::Error> {
// `File::open` 回傳 Result,若失敗直接返回 Err
let mut file = File::open(path)?;
let mut contents = String::new();
// `read_to_string` 也是 Result,錯誤同樣會被傳遞
file.read_to_string(&mut contents)?;
Ok(contents)
}
說明:
?讓我們不需要寫兩層match,程式流程看起來就像「順序執行」的普通程式。
範例 2:結合 Option 與 Result
use std::num::ParseIntError;
fn parse_first_line(path: &str) -> Result<i32, ParseIntError> {
let content = std::fs::read_to_string(path).expect("檔案不存在");
// 取得第一行,若沒有則回傳 None → 直接返回 Err
let first_line = content.lines().next()?; // <-- `Option` 上的 ?
// 解析成 i32,若解析失敗則回傳 Err
let number: i32 = first_line.trim().parse()?;
Ok(number)
}
重點:
lines().next()?會在找不到任何行時回傳None,此時函式會自動變成Result<_, _>,因此必須確保函式的返回型別能接受None→ 這裡利用Option::ok_or轉成Result,或直接在?前使用ok_or_else。
範例 3:自訂錯誤型別與 From 轉換
use std::fmt;
use std::io;
use std::num::ParseIntError;
// 1. 定義自己的錯誤型別
#[derive(Debug)]
enum MyError {
Io(io::Error),
Parse(ParseIntError),
}
// 2. 為每個子錯誤實作 From,以支援 `?` 的自動轉換
impl From<io::Error> for MyError {
fn from(err: io::Error) -> MyError {
MyError::Io(err)
}
}
impl From<ParseIntError> for MyError {
fn from(err: ParseIntError) -> MyError {
MyError::Parse(err)
}
}
// 3. 為錯誤實作 Display(可選,但建議)
impl fmt::Display for MyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MyError::Io(e) => write!(f, "IO 錯誤: {}", e),
MyError::Parse(e) => write!(f, "解析錯誤: {}", e),
}
}
}
// 4. 使用 `?`,錯誤會自動轉成 MyError
fn read_number(path: &str) -> Result<i32, MyError> {
let txt = std::fs::read_to_string(path)?; // io::Error → MyError::Io
let num: i32 = txt.trim().parse()?; // ParseIntError → MyError::Parse
Ok(num)
}
說明:只要為子錯誤實作
From,?就能自動把錯誤「升級」成自訂錯誤型別,讓錯誤資訊更具語意。
範例 4:在 async 函式中使用 ?
use tokio::fs::File;
use tokio::io::{self, AsyncReadExt};
async fn async_read(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path).await?; // 注意 `.await?`
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
Ok(buf)
}
要點:在
async環境下,?必須寫在.await?之後,因為await先把Future轉成Result,再由?處理錯誤。
範例 5:使用 ? 搭配 map_err 自訂錯誤訊息
use std::fs;
use std::io;
fn read_config(path: &str) -> Result<String, String> {
// `map_err` 把 io::Error 轉成字串,之後的 `?` 會回傳 String
let content = fs::read_to_string(path).map_err(|e| format!("讀取失敗: {}", e))?;
Ok(content)
}
說明:有時候不想建立完整的錯誤型別,只想直接把錯誤訊息轉成字串,
map_err搭配?就是快速解法。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
忘記在函式簽名加 Result |
? 只能在返回 Result 或 Option 的函式內使用,否則編譯錯誤。 |
確認函式返回型別,例如 fn foo() -> Result<_, _>。 |
| 錯誤型別不匹配 | 子函式的錯誤類型與父函式的錯誤型別不相容,? 無法自動轉換。 |
為子錯誤實作 From,或使用 map_err 手動轉換。 |
在 Option 上使用 ?,但返回 Result |
Option 的 None 會被視為 Err(()),導致型別不符。 |
使用 ok_or / ok_or_else 把 Option 轉成 Result 再 ?。 |
在 async 函式忘記 .await? |
直接寫 future? 會編譯失敗,因為 Future 不是 Result。 |
必須先 .await,再使用 ?:future.await?。 |
過度使用 ? 隱藏錯誤上下文 |
只傳遞原始錯誤,可能缺少呼叫層的資訊。 | 結合 anyhow::Context 或自訂 map_err 添加額外訊息。 |
最佳實踐
- 盡量在函式最外層使用
?,讓錯誤傳遞保持線性,避免在中間層做過多match。 - 為自訂錯誤實作
std::error::Error,配合thiserror或anyhow,可以讓錯誤堆疊更易讀。 - 在公共 API 中保留錯誤類型,而在應用層使用
anyhow::Result包裝,以減少型別爆炸。 - 使用
#[must_use]標記返回Result的函式,提醒呼叫者不要忽略錯誤。 - 在測試中驗證錯誤路徑,確保
?真正會在失敗時提前返回。
實際應用場景
1. 命令列工具的參數解析與檔案 I/O
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let path = args.get(1).ok_or("缺少檔案路徑參數")?;
let content = std::fs::read_to_string(path)?;
println!("檔案內容:\n{}", content);
Ok(())
}
?讓每一步的錯誤(缺少參數、檔案不存在、讀取失敗)都能直接回傳給main,main再統一打印錯誤訊息。
2. 網路服務的非同步請求
use reqwest::Client;
async fn fetch_json(url: &str) -> Result<serde_json::Value, reqwest::Error> {
let client = Client::new();
let resp = client.get(url).send().await?.json().await?;
Ok(resp)
}
?在非同步流程中保持直線化,讓錯誤傳遞不會因Future的層層嵌套而變得難以追蹤。
3. 複雜資料轉換管線
fn process_data(csv_path: &str) -> Result<Vec<MyStruct>, Box<dyn std::error::Error>> {
let txt = std::fs::read_to_string(csv_path)?;
let mut records = Vec::new();
for line in txt.lines() {
let fields: Vec<&str> = line.split(',').collect();
let id: u32 = fields.get(0).ok_or("缺少 ID")?.trim().parse()?;
let name = fields.get(1).ok_or("缺少名稱")?.trim().to_string();
records.push(MyStruct { id, name });
}
Ok(records)
}
- 透過
?把 CSV 解析、欄位缺失、字串轉整數等錯誤全部統一向上層傳遞,程式碼保持簡潔且易於維護。
總結
? 運算子是 Rust 錯誤處理的核心利器,它把 錯誤的傳遞 變成 像寫普通程式碼一樣自然 的流程。只要遵守以下三個原則,就能在大多數情況下安全且高效地使用 ?:
- 函式返回
Result(或Option),且錯誤型別支援From轉換。 - 在需要的地方使用
map_err、ok_or等輔助方法,確保錯誤上下文完整。 - 配合自訂錯誤型別或
anyhow/thiserror等庫,讓錯誤資訊在跨層傳遞時保持可讀性。
掌握 ? 後,你會發現原本充斥 match 的錯誤檢查瞬間變得乾淨、直觀,讓程式碼更聚焦於「業務邏輯」本身。無論是同步的檔案 I/O、非同步的網路請求,或是資料轉換的管線作業,? 都能提供一致且安全的錯誤處理方式。
現在就把這些概念套用到自己的專案中,讓 Rust 的錯誤處理真正發揮「安全、表達力、易維護」的優勢吧!