JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Option 與 Result:沒有 null,錯誤即是值

在 C 裡,「這裡沒東西」和「這件事失敗了」都藏在你可以隨意忽略的普通值裡——一個 NULL 指標、一個 -1、一個過時的 errno。Rust 把這兩者都拉進型別系統,成為 Option 與 Result,於是編譯器不會讓你忘記空的情形或失敗的情形。本篇要說明它如何運作、為什麼這是誠實而非魔法,以及它如何乾淨地對映到你已經熟悉的 C 錯誤紀律。

C 留下的那個洞:一個可能不在的值

你在這個階段一路看著 Rust 用編譯期的規則,取代 C 裡那些悄無聲息的危險——所有權決定誰來釋放一個值,而借用檢查器決定誰可以讀或寫它。本篇要對付的,是你從最早的記憶體階段起就一直感受到的危險:空指標。在 C 裡,一個 `char *s` 可以指向一個真正的字串,也可以是 NULL,而兩種情況下型別完全相同。編譯器分辨不出這兩者,所以記得檢查就成了你的責任——而忘記的代價,就是那個空指標解參考:幸運的話變成一個區段錯誤,不幸的話就是無聲的毀壞。

更深層的問題是,C 沒有辦法在型別本身裡說:「這個可能不存在。」一個在表格裡查鍵的函式,即使鍵不存在也得回傳「某個東西」,所以 C 程式設計師就伸手去抓一個頻道內的訊號——一個 NULL 指標、一個 -1 索引、一個魔術值——一個被偷渡進型別正常範圍裡的哨兵值。它能用,但它對編譯器是看不見的,而且很容易被略過。Rust 的回答是:不要再偷渡了。它給你一個真正獨立的型別,它唯一的任務就是說「在,或不在」:Option 型別,寫作 `Option<T>`,恰好有兩種形狀——有一個 `T` 時是 `Some(value)`,沒有時是 `None`。

關鍵的一步是:`Option<T>` 和 `T` 是「不同的型別」。一個可能什麼都找不到的函式,回傳的是 `Option<i32>`,絕不是一個光禿禿的 `i32`。所以當你呼叫它時,你拿回的並不是一個可以直接用的整數——你拿回的是一個盒子,它要嘛是 `Some(42)`、要嘛是 `None`,而要拿到裡面那個 42 的唯一辦法,就是先處理 `None` 的可能性。空的情形不再是一個你可能忘掉的註腳;它是一道編譯器逼你穿過去的牆。這就是「沒有 null」背後唯一的想法:把「不存在」從值裡搬出來、搬進型別裡,在那裡它無處可藏。

用 match 打開那個盒子

如果你想要的值被鎖在 `Some(...)` 裡,你要怎麼把它拿出來?誠實而基礎的辦法,是用 `match` 關鍵字做模式比對。一個 `match` 列出這個值可能呈現的每一種形狀,並給每一種一條自己的分支,而——讓它安全的關鍵就在這裡——Rust 編譯器會檢查你是否涵蓋了每一種形狀。對一個 `Option<T>` 來說,恰好有兩種:`Some(x)` 和 `None`。漏掉 `None` 那條分支,你的程式就無法編譯。編譯器實際上就是那位有耐心的審閱者,它拒絕讓你交出一段忘了處理空情形的程式碼。

想像一次表格查找。在 Rust 裡,`table.get(&id)` 遞給你一個 `Option<&String>`,對它做 `match` 會有兩條分支:`Some(name) => println!("hello, {name}")` 和 `None => println!("no user {id}")`。刪掉 `None` 那條分支,建置就失敗。把 C 版擺在旁邊——`char *name = table_get(table, id);` 後面接 `if (name) ... else ...`——差別就一目了然:那段 C 即使你乾脆忘了寫 `else` 也照樣乾淨地編譯,毫無警告地一頭撞進 NULL 裡。同樣的邏輯,但兩種語言裡只有一種,把那條空的分支變成了強制的。

那幅圖裡有兩件事值得注意。第一,在 `Some(name)` 裡,那個值被「綁定」到 `name` 這個名字,而它只在那條分支裡存在——你根本無法在「它可能不存在」的地方使用 `name`,因為那條路徑是 `None` 分支,那裡並沒有名字。第二,這就是你用來拆解任何 Rust 列舉(enum)的同一套 match 機制,而不是專為 Option 焊上去的特殊小工具。`Option<T>` 不過是標準函式庫裡一個尋常的雙變體列舉;它之所以感覺像內建,只是因為它無處不在。把它理解成一個普通的列舉,就能去除它的神秘感:沒有魔法,只是一個帶標籤的聯集(tagged union),而編譯器堅持要你把它完整地解構。

Result:失敗也成了一個普通的值

「不存在」只是故事的一半;失敗是另一半。回想健壯性階段裡的 C 錯誤模型:一個呼叫透過一個哨兵值回傳值來示意失敗——open() 回傳 `-1`、malloc() 回傳 NULL——而「原因」則分開存放在全域的 errno 裡,你必須在恰好正確的瞬間、在下一個呼叫把它蓋掉之前去讀它。「是否失敗」與「為什麼」之間的這種分裂之所以脆弱,正是因為這兩部分可能彼此漂移開來。Rust 用 Result 型別把它們重新折回一起,寫作 `Result<T, E>`,有兩種形狀:`Ok(value)` 承載成功的 `T`,而 `Err(error)` 承載一個描述出了什麼錯的錯誤值 `E`,全都裝在一個回傳的東西裡。

注意這如何直接治好了 errno 的脆弱。沒有一個分開的全域要在正確時機去讀,也沒有那個「原因可能被覆寫」的空窗——原因就搭乘在那個告訴你「它失敗了」的同一個 `Err(...)` 裡面,綁在一起、不可能配錯。而且就和 Option 一樣,`Result<T, E>` 是個與 `T` 不同的型別,所以一個可能失敗的函式回傳的是 Result,而你無法在不先面對 `Err` 那條分支的情況下使用成功的值。整套「檢查回傳值、然後也許檢查 errno、依正確順序、在下一個呼叫之前」的繁複舞步,塌縮成編譯器逼你回答的一個誠實問題:它是 `Ok` 還是 `Err`?

還有兩個額外的設計,讓這件事變得實用而非說教。標準函式庫為 Result 標上了一個屬性,意思是「不要默默把我丟掉」——用文字說,就是一個「必須使用」(must-use)的標記——所以如果你呼叫一個回傳 Result 的函式卻完全忽略那個值,編譯器會發出警告。C 永遠做不到的事——為了你把錯誤丟在地上而對你嘮叨——Rust 預設就做了。而 `E`,那個錯誤型別,是由你來選的:可以是你自己一個簡單的失敗情形列舉,也可以是一個承載訊息與成因的更豐富型別。錯誤是你設計的資料,而不是一個被超載使用的整數。

傳遞錯誤:? 運算子與對應的 C 寫法

在健壯性階段,你遇過每個錯誤都會逼出的抉擇:就地處理它、把它往上傳遞、或中止。在 C 裡,傳遞是一件手工苦差事——一個函式偵測到 `-1`、做完它自己的清理、再依樣回傳 `-1`,於是失敗在每一層都靠人工沿著呼叫堆疊往上爬。Rust 保留了一模一樣的想法,卻給了它一小段語法:問號運算子,寫作 `?`。把它放在一個產生 Result 的運算式後面,意思是「如果這是 `Ok`,就把值解出來、繼續往下走;如果是 `Err`,就立刻從整個函式回傳那個 `Err`」。

// Rust: ? propagates the Err automatically; on Ok it hands back the value
fn read_config(path: &str) -> Result<String, std::io::Error> {
    let text = std::fs::read_to_string(path)?;  // Err here -> return it now
    Ok(text)                                     // reached only if read succeeded
}

// the equivalent honest C, written out by hand:
//   int fd = open(path, O_RDONLY);
//   if (fd == -1) return -1;        // propagate
//   ssize_t n = read(fd, buf, len);
//   if (n == -1) { close(fd); return -1; }   // propagate, after cleanup
? 就是 C 階段那個 if (ret == -1) return -1; 的模式,被濃縮成一個字元。

把 C 擺在旁邊看,就能明白 `?` 並不是什麼新魔法——它就是你早已練習過的傳遞紀律,被提升進語言裡,讓你無法把那些樣板程式碼寫得隱隱出錯。而它和你在本階段稍早學到的所有權彼此咬合:當 `?` 因為一個 `Err` 而提早返回時,這個函式到那一刻為止所擁有的每一個值,仍然會在離場途中被自動丟棄,靠的是那條會釋放記憶體、會關閉檔案的同一條 離開作用域即丟棄規則。那個糾纏 C 傳遞路徑的洩漏檔案描述符——goto-cleanup 那篇不得不解決的那個問題——在這裡根本不可能發生,因為清理是由值來負責的,而不是由控制流程。

兩件不同的工作:可復原的錯誤 vs 臭蟲

Option 與 Result 是給「預期之中」的情境用的,而不是給程式設計上的失誤用的,這一點很重要——而 Rust 把這條線畫在健壯性階段所畫的地方,介於一個可復原的錯誤與一個臭蟲之間。一個不存在的檔案、一個解析失敗的數字、一個地圖裡缺席的鍵:這些都是尋常、可預期的結果,它們屬於 `Option` 與 `Result`,當成值來處理。而一個臭蟲——一個越過陣列尾端的索引、一個你自己的程式碼本該維持卻被打破的不變式——則是另一種生物:它意味著程式如今處於一個它的作者沒有打算過的狀態,再走下去就不安全。

Rust 用一個 panic 來處理第二類,也就是 `unwrap()` 會觸發、越界索引也會自行觸發的那種立即、受控的停止。一個 panic 比較接近 C 裡那種「帶訊息的 abort()」或一次失敗的斷言,而不是一個可復原的錯誤:它是語言在說「假設已經破了;別假裝你還能繼續」。於是,這份紀律是你針對每種情境所下的判斷——對呼叫者能合理被期待去處理的情況回傳一個 `Result`,而讓本該不可能發生的情況觸發一個 panic。到處用 `unwrap()` 來閃避空情形,會把整個好處都丟光;那恰恰就是 C 容許你做的那種無聲忽略,只是穿上了 Rust 的語法外衣。