一、前言 #
嗨,這裡是拍拍君!🦀
寫 Python 的時候,你一定很熟悉這個套路:
try:
result = do_something()
except SomeError as e:
handle_error(e)
簡單直觀對吧?但你有沒有遇過這種窘境 —— 某個函式「可能」回傳 None,但文件沒寫清楚,你在 production 被一個 AttributeError: 'NoneType' object has no attribute 'xxx' 炸到懷疑人生?😤
Rust 的設計哲學完全不同:錯誤不是例外,是回傳值的一部分。編譯器會「強迫」你處理每一個可能的錯誤,漏了就不給你編譯。聽起來很嚴格?其實用習慣之後你會發現,這種設計讓 bug 無所遁形。
今天就讓拍拍君帶你從 Python 的角度,看看 Rust 的 Result 和 Option 到底在做什麼!
二、Python 的錯誤處理回顧 #
先快速複習一下 Python 的錯誤處理機制,後面好做對比。
2.1 try/except:例外捕捉 #
def read_config(path: str) -> dict:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
print(f"找不到設定檔:{path}")
return {}
except json.JSONDecodeError as e:
print(f"JSON 格式錯誤:{e}")
return {}
Python 的例外是「隱式」的 —— 你看函式簽名 -> dict,完全看不出它可能會丟例外。呼叫的人不 try/except?程式直接炸掉。
2.2 Optional 回傳值 #
def find_user(user_id: int) -> User | None:
# 找不到就回 None
return db.get(user_id)
回傳 None 很方便,但呼叫者常常忘記檢查:
user = find_user(42)
print(user.name) # 💥 AttributeError!
Python 3.10+ 有 match 語句,但沒有強制你處理 None 的機制。
2.3 Python 的問題 #
| 問題 | 說明 |
|---|---|
| 例外是隱式的 | 函式簽名看不出會丟什麼例外 |
None 不安全 |
沒人強迫你檢查 |
| 錯誤容易被忽略 | bare except 吞掉所有錯誤 |
| Runtime 才爆炸 | 測試沒覆蓋到就上線炸 |
三、Rust 的 Option:「有或沒有」 #
Rust 沒有 null、沒有 None(Python 那種)。取而代之的是 Option<T>:
enum Option<T> {
Some(T), // 有值
None, // 沒有值
}
3.1 基本用法 #
fn find_user(user_id: u64) -> Option<String> {
let users = vec!["拍拍君", "拍拍醬", "chatPTT"];
if user_id < users.len() as u64 {
Some(users[user_id as usize].to_string())
} else {
None
}
}
fn main() {
let user = find_user(1);
// ❌ 這樣不行!Option<String> 不是 String
// println!("{}", user);
// ✅ 必須明確處理
match user {
Some(name) => println!("找到了:{}", name),
None => println!("查無此人"),
}
}
重點:編譯器不讓你直接用 Option<String> 當 String。你「必須」拆開它,處理 None 的情況。
3.2 Python 對比 #
# Python:None 可以默默溜過去
user = find_user(999)
user.upper() # 💥 Runtime error
# Rust:編譯時期就抓到
# let user: Option<String> = find_user(999);
# user.to_uppercase(); // ❌ 編譯錯誤!
3.3 Option 的實用方法 #
Option 有一堆好用的方法,比 Python 的 if x is not None 優雅多了:
let name: Option<String> = Some("拍拍君".to_string());
// unwrap_or:給預設值
let display = name.unwrap_or("匿名".to_string());
// map:對內部值做轉換
let upper: Option<String> = name.map(|n| n.to_uppercase());
// and_then:串接可能失敗的操作(像 flatmap)
let first_char: Option<char> = name.and_then(|n| n.chars().next());
// is_some / is_none:檢查狀態
if name.is_some() {
println!("有名字!");
}
Python 對應寫法(比較囉嗦):
# unwrap_or
display = name if name is not None else "匿名"
# map
upper = name.upper() if name is not None else None
# and_then
first_char = name[0] if name else None
3.4 if let:簡潔的模式匹配 #
當你只關心 Some 的情況時:
if let Some(name) = find_user(1) {
println!("歡迎,{}!", name);
}
// None 的情況就靜靜跳過
四、Rust 的 Result:「成功或失敗」 #
Option 處理「有或沒有」,Result 處理「成功或失敗」,而且會告訴你為什麼失敗:
enum Result<T, E> {
Ok(T), // 成功,帶著結果
Err(E), // 失敗,帶著錯誤資訊
}
4.1 基本用法 #
use std::fs;
use std::io;
fn read_config(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
fn main() {
match read_config("config.toml") {
Ok(content) => println!("設定內容:{}", content),
Err(e) => eprintln!("讀取失敗:{}", e),
}
}
4.2 跟 Python 的對比 #
# Python:例外是隱式的
def read_config(path: str) -> str:
return open(path).read() # 可能丟 FileNotFoundError,但簽名看不出來
# Rust:錯誤是回傳型別的一部分
# fn read_config(path: &str) -> Result<String, io::Error>
# 一看就知道可能失敗,而且知道錯誤型別
| 特性 | Python try/except | Rust Result |
|---|---|---|
| 錯誤可見性 | 隱式(要看文件) | 顯式(在型別裡) |
| 強制處理 | ❌ 可以不 catch | ✅ 編譯器警告 |
| 錯誤型別 | 任何 Exception | 明確指定 |
| 效能 | 例外有成本 | 零成本抽象 |
4.3 unwrap 和 expect:快速但危險 #
有時候你「確定」不會出錯,或者只是在寫 prototype:
// unwrap:成功就拆開,失敗就 panic
let content = fs::read_to_string("config.toml").unwrap();
// expect:跟 unwrap 一樣,但可以自訂 panic 訊息
let content = fs::read_to_string("config.toml")
.expect("讀取 config.toml 失敗");
⚠️ 注意:unwrap() 在 production 程式碼裡是 code smell!就像 Python 裡的 bare except: pass 一樣危險(只是方向相反 —— 一個是無腦忽略錯誤,一個是無腦炸掉程式)。
五、? 運算子:Rust 的殺手級功能 #
這是 Rust 錯誤處理最優雅的部分 —— ? 運算子。它的作用是:如果是 Err,立刻回傳錯誤;如果是 Ok,拆開值繼續用。
5.1 沒有 ? 的寫法(很囉嗦) #
fn process_config(path: &str) -> Result<Config, io::Error> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return Err(e),
};
let config = match parse_toml(&content) {
Ok(c) => c,
Err(e) => return Err(e),
};
Ok(config)
}
5.2 用 ? 改寫(超乾淨) #
fn process_config(path: &str) -> Result<Config, io::Error> {
let content = fs::read_to_string(path)?; // 失敗就自動 return Err
let config = parse_toml(&content)?;
Ok(config)
}
甚至可以 chain 起來:
fn process_config(path: &str) -> Result<Config, io::Error> {
let config = parse_toml(&fs::read_to_string(path)?)?;
Ok(config)
}
5.3 Python 沒有對應的東西 #
Python 最接近的寫法大概是:
def process_config(path: str) -> Config:
content = open(path).read() # 可能爆
config = parse_toml(content) # 可能爆
return config # 呼叫者不知道會爆
Python 的問題是:錯誤會自動往上傳播,但呼叫者不知道。Rust 的 ? 也會傳播錯誤,但它是明確的 —— 回傳型別已經告訴你了。
六、自訂錯誤型別 #
在實際專案中,你通常需要把多種錯誤統一起來。
6.1 用 enum 定義錯誤 #
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
IoError(io::Error),
ParseError(ParseIntError),
NotFound(String),
}
// 實作 From trait,讓 ? 運算子自動轉換
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::IoError(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::ParseError(e)
}
}
6.2 使用自訂錯誤 #
fn load_port(path: &str) -> Result<u16, AppError> {
let content = fs::read_to_string(path)?; // io::Error → AppError
let port: u16 = content.trim().parse()?; // ParseIntError → AppError
if port == 0 {
return Err(AppError::NotFound("Port 不能是 0".to_string()));
}
Ok(port)
}
6.3 Python 的對比 #
class AppError(Exception):
pass
class NotFoundError(AppError):
pass
def load_port(path: str) -> int:
content = open(path).read() # 可能丟 IOError
port = int(content.strip()) # 可能丟 ValueError
if port == 0:
raise NotFoundError("Port 不能是 0")
return port
Python 的自訂例外也很簡單,但缺點還是一樣:看簽名不知道會丟什麼。
6.4 thiserror:自訂錯誤的快捷鍵 #
手動實作 From 太煩了?用 thiserror crate:
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("IO 錯誤:{0}")]
IoError(#[from] io::Error),
#[error("解析錯誤:{0}")]
ParseError(#[from] ParseIntError),
#[error("找不到:{0}")]
NotFound(String),
}
一個 derive macro 搞定,超省事!
七、anyhow:快速原型的好朋友 #
如果你不想定義自訂錯誤(例如寫 script 或 prototype),anyhow crate 是你的好朋友:
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> {
let content = fs::read_to_string(path)
.context(format!("無法讀取設定檔:{}", path))?;
let config: Config = toml::from_str(&content)
.context("TOML 解析失敗")?;
Ok(config)
}
anyhow::Result<T> 等於 Result<T, anyhow::Error>,可以裝任何錯誤。.context() 則幫你加上人類看得懂的錯誤訊息。
選擇指南:
| 場景 | 推薦 |
|---|---|
| 應用程式 / CLI | anyhow(方便) |
| 函式庫 | thiserror(讓使用者能 match 錯誤) |
| 學習 / 小範例 | 直接用 Result<T, io::Error> |
八、實戰範例:讀取 CSV 並統計 #
來個完整一點的例子,把今天學的全部用上:
Rust 版本 #
use std::fs;
use thiserror::Error;
#[derive(Error, Debug)]
enum CsvError {
#[error("讀檔失敗:{0}")]
ReadError(#[from] std::io::Error),
#[error("第 {line} 行格式錯誤:{msg}")]
ParseError { line: usize, msg: String },
}
fn sum_column(path: &str, col: usize) -> Result<f64, CsvError> {
let content = fs::read_to_string(path)?;
let mut total = 0.0;
for (i, line) in content.lines().enumerate().skip(1) {
let fields: Vec<&str> = line.split(',').collect();
let value: f64 = fields
.get(col)
.ok_or(CsvError::ParseError {
line: i + 1,
msg: format!("欄位 {} 不存在", col),
})?
.trim()
.parse()
.map_err(|_| CsvError::ParseError {
line: i + 1,
msg: format!("無法解析為數字:'{}'", fields[col].trim()),
})?;
total += value;
}
Ok(total)
}
fn main() {
match sum_column("data.csv", 2) {
Ok(total) => println!("總和:{:.2}", total),
Err(e) => eprintln!("錯誤:{}", e),
}
}
對應的 Python 版本 #
def sum_column(path: str, col: int) -> float:
total = 0.0
with open(path) as f:
for i, line in enumerate(f):
if i == 0:
continue # skip header
fields = line.strip().split(",")
try:
total += float(fields[col])
except (IndexError, ValueError) as e:
raise ValueError(f"第 {i+1} 行格式錯誤:{e}")
return total
try:
total = sum_column("data.csv", 2)
print(f"總和:{total:.2f}")
except (FileNotFoundError, ValueError) as e:
print(f"錯誤:{e}")
Python 版短了一些,但看 sum_column 的簽名 -> float,你完全不知道它可能丟 FileNotFoundError 或 ValueError。Rust 版的 Result<f64, CsvError> 就一目了然。
結語 #
來整理一下今天學到的:
| 概念 | Python | Rust |
|---|---|---|
| 空值 | None |
Option<T> |
| 錯誤處理 | try/except |
Result<T, E> |
| 錯誤傳播 | 自動(隱式) | ? 運算子(顯式) |
| 強制處理 | ❌ | ✅ 編譯器保證 |
| 快速拆封 | 直接用(可能爆) | unwrap() / expect() |
| 自訂錯誤 | 繼承 Exception |
thiserror / 手動 |
| 快速原型 | 不處理(讓它爆) | anyhow |
Rust 的錯誤處理一開始可能覺得囉嗦,但它帶來的好處是巨大的:
- 🔒 編譯時安全:不可能忘記處理錯誤
- 📖 自文件化:型別就是文件
- ⚡ 零成本:沒有例外的 runtime 開銷
- 🔗 可組合:
?+Fromtrait 讓錯誤處理非常流暢
拍拍君的建議:剛開始寫 Rust 時,先用 anyhow 打天下,等專案長大了再用 thiserror 定義精確的錯誤型別。就像 Python 裡你也不會一開始就設計完整的 Exception 階層一樣 😉
下一篇我們會聊 Docker Compose 的多容器編排,敬請期待!🐳
延伸閱讀 #
- Rust Book: Error Handling — 官方教學
- thiserror crate — 自訂錯誤的好幫手
- anyhow crate — 快速原型必備
- Rust 入門:struct 與 enum — 用 Python class 的思維來理解 — 本系列上一篇