快轉到主要內容
  1. 教學文章/

Rust 錯誤處理:Result/Option vs Python try/except

·7 分鐘· loading · loading · ·
Rust Python Error-Handling Result Option
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Rust 入門 - 本文屬於一個選集。
§ 4: 本文

一、前言
#

嗨,這裡是拍拍君!🦀

寫 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 的 ResultOption 到底在做什麼!

二、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,你完全不知道它可能丟 FileNotFoundErrorValueError。Rust 版的 Result<f64, CsvError> 就一目了然。

結語
#

來整理一下今天學到的:

概念 Python Rust
空值 None Option<T>
錯誤處理 try/except Result<T, E>
錯誤傳播 自動(隱式) ? 運算子(顯式)
強制處理 ✅ 編譯器保證
快速拆封 直接用(可能爆) unwrap() / expect()
自訂錯誤 繼承 Exception thiserror / 手動
快速原型 不處理(讓它爆) anyhow

Rust 的錯誤處理一開始可能覺得囉嗦,但它帶來的好處是巨大的:

  • 🔒 編譯時安全:不可能忘記處理錯誤
  • 📖 自文件化:型別就是文件
  • 零成本:沒有例外的 runtime 開銷
  • 🔗 可組合? + From trait 讓錯誤處理非常流暢

拍拍君的建議:剛開始寫 Rust 時,先用 anyhow 打天下,等專案長大了再用 thiserror 定義精確的錯誤型別。就像 Python 裡你也不會一開始就設計完整的 Exception 階層一樣 😉

下一篇我們會聊 Docker Compose 的多容器編排,敬請期待!🐳

延伸閱讀
#

Rust 入門 - 本文屬於一個選集。
§ 4: 本文

相關文章

Rust struct 與 enum:對比 Python dataclass 與 Enum
·9 分鐘· loading · loading
Rust Python Struct Enum Pattern-Matching Dataclass
Rust for Python 開發者:Ownership 與 Borrowing 入門
·7 分鐘· loading · loading
Rust Python Ownership Borrowing Memory
Rust CLI 實戰:用 clap 打造命令列工具(Python Typer 對照版)
·5 分鐘· loading · loading
Rust Cli Clap Typer Python
Polars:比 Pandas 快 10 倍的 DataFrame 新選擇
·6 分鐘· loading · loading
Python Polars Dataframe 資料分析 Rust
超快速 Python 套件管理:uv 完全教學
·6 分鐘· loading · loading
Python Uv Package Manager Rust
Python pytest:fixture + parametrize + mock 完整指南
·8 分鐘· loading · loading
Python Pytest Testing Fixture Mock Parametrize TDD