iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

「失手不可怕,可怕的是沒人知道是誰、在哪一步、為什麼失手;寫不清楚的錯誤,等於沒發生過。」
——《阿帕契開源審計錄》¹ 卷二·錯誤傳遞篇

幕間
「二號,前面十四天,女巫用藥、投票、上警,你有哪次是第一個表態的?」七號問。
二號微笑覆誦:「我有哪次是第一個……」
「這不是要你重複問題,是要你回答。」七號在羊皮紙寫下第一行註記。

白天投票,法官開始唱票。輪到我的一個狼隊友,他報出的名字是九號——可是整個上午我們在暗語裡對過的目標明明是五號。他報錯了。

那三秒鐘,桌上很安靜。報錯的隊友臉一下子白了,卡在那裡說不出話。坐我斜對面的二號接了一句:「他剛說九號——嗯,如果照這個講法,九號昨天確實有一段發言站不太住。」他把隊友最後那句話覆誦了一遍,再順著往下接自己的推論,硬是把一個失誤講成了一個看似成立的判斷。

七號的炭筆動了。我看見她記的不是「誰投錯」,而是「錯誤如何被接住」——她在旁邊畫了一條線,從報錯的人連到二號。

一個錯誤出現的當下,它是就地被處理掉,還是沿著呼叫鏈一路往上炸?四大語言的答案,分成兩大陣營。


兩種世界觀:顯式回傳 vs 堆疊展開

錯誤傳播有兩條路線。一條是顯式回傳:函式把「可能失敗」寫進回傳型別,呼叫端每一層都必須明白處理或明白往上傳,錯誤走的是資料流。另一條是堆疊展開(stack unwinding):錯誤被拋出後自動沿著呼叫堆疊往上跳,中間每一層預設「看不到」,直到某一層 catch 住。前者不會漏接但囉嗦,後者簡潔但容易忘記接。

在這兩條路線之前,還有一個更根本的分類要先做對:眼前這個失敗,是我自己的邏輯 bug(該立刻炸、越早越好,fail fast),是預期內就可能發生的合法失敗(像投給一個已經死掉的玩家,該明白處理),還是資源真的沒了的不可恢復狀況(像整份紀錄消失,只能記錄後中止)。分錯類的代價很實際:把 bug 用 try/except 吞掉,邏輯錯誤會潛伏到造成更大的傷害;把預期內的小失敗當成天塌下來,一個「投錯人」就會讓整場當機。報錯的隊友犯的正是後者——他把一個有明確補救方式的狀況,當成不可恢復的災難去反應。

Go:(T, error),每一層都得表態

Go 沒有例外機制處理一般錯誤。函式回傳一個額外的 error 值,呼叫端用 if err != nil 檢查,要嘛處理、要嘛用 fmt.Errorf 加上上下文再往上傳。errors.Is / errors.As 用來判別錯誤種類。真正不該發生的狀況才用 panic。它的優點是錯誤路徑完全攤在原始碼裡;缺點是樣板程式碼多。

// Every caller must inspect err explicitly; nothing propagates on its own.
func castVote(alive []string, target string) (string, error) {
	for _, p := range alive {
		if p == target {
			return target, nil
		}
	}
	return "", fmt.Errorf("cast vote: target %q is not an alive player", target)
}

func resolveRound(alive []string, target string) error {
	confirmed, err := castVote(alive, target)
	if err != nil {
		return fmt.Errorf("resolve round: %w", err) // wrap, add context, propagate
	}
	fmt.Printf("[VOTE_OK] %s received a vote\n", confirmed)
	return nil
}

Kubernetes 幾乎每個函式簽名都拖著一個 error,因為分散式系統裡「失敗是常態」,把它藏起來只會讓除錯更難。Go 這套設計常被抱怨「一半的程式碼都在 if err != nil」,但支持者的論點是:錯誤處理本來就是程式邏輯的一部分,讓它顯眼地佔據版面,比讓它隱形要誠實。%w 包裝讓你在每一層補上「我在做什麼的時候失敗的」,最外層再用 errors.As 拆出根因決定對策。

Java:try-catch,checked 與 unchecked 的分野

Java 用例外。拋出後堆疊自動展開,直到匹配的 catch。它獨有的設計是checked exception:某些例外必須寫在方法簽名的 throws 上,呼叫端被編譯器強迫處理;RuntimeException 這類 unchecked 則不強制。try-with-resources 保證資源釋放。checked 的初衷是逼人正視錯誤,但被濫用時會逼出一堆「catch 了什麼也不做」的空區塊。

class InvalidTargetException extends Exception { // checked: caller is forced to handle
    InvalidTargetException(String message) { super(message); }
}

String castVote(List<String> alive, String target) throws InvalidTargetException {
    if (!alive.contains(target)) {
        throw new InvalidTargetException("target " + target + " is not an alive player");
    }
    return target;
}

void resolveRound(List<String> alive, String target) {
    try {
        System.out.println("[VOTE_OK] " + castVote(alive, target) + " received a vote");
    } catch (InvalidTargetException e) {
        System.err.println("[VOTE_REJECTED] " + e.getMessage());
    }
}

Apache Kafka 定義了大量精細的例外階層(如可重試與不可重試的分野),client 就靠這個階層決定要重送還是直接放棄。checked exception 是 Java 獨有的實驗,其他主流語言都沒跟進:它的理想是「編譯器逼你面對每一個宣告會發生的失敗」,但實務上常演變成到處 catch (Exception e) {} 的空吞,或是把 checked 包成 unchecked 再拋。現代 Java 的傾向是:真正屬於呼叫端該決策的失敗才用 checked,其餘用 unchecked,並且絕不留空的 catch 區塊。

Rust:Result<T, E> 加上 ?,顯式但不囉嗦

Rust 走顯式回傳路線,但用 ? 運算子解決了樣板問題:?Ok 時取出值,在 Err 時提早回傳並自動做型別轉換。真正不可恢復的狀況才 panic!Option<T> 處理「可能沒有值」。編譯器不允許你默默忽略一個 Result

#[derive(Debug)]
struct VoteError(String);

// Return type declares up front: this can fail.
fn cast_vote(alive: &[&str], target: &str) -> Result<String, VoteError> {
    if alive.contains(&target) {
        Ok(target.to_string())
    } else {
        Err(VoteError(format!("target {target} is not an alive player")))
    }
}

fn resolve_round(alive: &[&str], target: &str) -> Result<(), VoteError> {
    let confirmed = cast_vote(alive, target)?; // propagate on Err, unwrap on Ok
    println!("[VOTE_OK] {confirmed} received a vote");
    Ok(())
}

Apache DataFusion 把解析、規劃、執行每一步的失敗都包進 DataFusionError,用 ? 一路上傳到最外層才決定如何回報使用者。? 的巧妙在於它同時做了兩件事:Err 時提早 return,而且透過 From trait 自動把底層錯誤型別轉成當前函式宣告的錯誤型別。所以你既保有「每一層都看得見失敗」的顯式性,又不必寫滿 match。函式簽名裡的 -> Result<T, E> 本身就是文件——任何人讀到就知道呼叫它要準備好接住失敗,不會被看起來一定成功的函式名騙了。

Python:try-except,但別裸接

Python 也是例外加堆疊展開。關鍵紀律是 except 要指定具體的例外類別,不要寫裸露的 except:——那會連程式本身的 bug 都一起吞掉。raise ... from e 保留原始因果鏈。自訂例外類別讓你精準攔截「我預期的那種失敗」。

class InvalidTargetError(Exception):
    pass


def cast_vote(alive: list[str], target: str) -> str:
    if target not in alive:
        raise InvalidTargetError(f"target {target} is not an alive player")
    return target


def resolve_round(alive: list[str], target: str) -> None:
    try:
        print(f"[VOTE_OK] {cast_vote(alive, target)} received a vote")
    except InvalidTargetError as e:  # specific, not a bare except
        print(f"[VOTE_REJECTED] {e}")

Apache Airflow 用 AirflowSkipException 和一般失敗做區分,排程器據此決定一個 task 是重試、跳過,還是讓整條 DAG 停下來。

https://ithelp.ithome.com.tw/upload/images/20260917/20183684lyfdWamYkg.png


代理人的失手:工具呼叫的錯誤也不該被裸吞

那位報錯隊友最大的問題,其實不是報錯本身,而是沒有一個機制讓「這是一次失敗」被明確標記出來,逼著全場(或呼叫端)正視它。AI 代理人呼叫外部工具時,面臨的是一模一樣的抉擇。Claude 官方的工具使用規範定義得很乾脆:當一次工具呼叫失敗——目標玩家不存在、API 逾時、參數不合法——回傳給模型的 tool_result 內容區塊,必須明確標記 is_error: true,並附上具體的失敗原因文字。模型看到這個標記,才知道「這一步沒有成功,我該根據錯誤內容決定要重試、換一種呼叫方式,還是把失敗誠實回報給使用者」。

這正是本篇要說的:把失敗顯式地攤在資料裡,而不是讓工具靜默回傳一個看起來正常、實則錯誤的結果。如果 MCP 伺服器(Day 08 談過的那雙水晶球)在查驗失敗時偷偷回傳一個空字串或預設值,而不是標記 is_error,代理人就會像那位隊友一樣——把一次失敗誤判成一個可以拿來推論的合法結果,然後順著錯的前提一路往下走。無論是 Go 的 (T, error)、Rust 的 Result<T, E>,還是 Claude 工具協議裡的 is_error,共同的紀律都是同一句話:失敗必須是資料流裡看得見的一等公民,不能被悄悄吞掉或偽裝成成功。


回到牌桌:錯誤沒有被接住,是被改寫了

那三秒之後我才看懂七號記的東西。報錯的隊友做的是 panic——邏輯瞬間卡死,一句話都接不下去,像一段沒有 recover 的程式。而二號做的不是「處理錯誤」,是把錯誤沿著他自己的敘事重新包裝:覆誦上一句,再接一段推論,讓失誤看起來像是刻意的判斷。錯誤沒有被修正,只是被轉了向。

好的錯誤處理——不管顯式回傳還是 try-catch——共同底線是:講清楚是誰、在哪一步、為什麼失敗,讓上層能做正確的決定。二號那句話恰恰相反,它讓上層(全場)拿到的是一個被抹掉來源的錯誤:唱票時報錯的是誰、原本的目標是什麼、為什麼會報錯,全被他那句「照這個講法」蓋掉了。這在程式裡對應到一種很難查的 bug:某一層 catch 到例外之後,換一個訊息重新拋出,卻沒有用 raise ... from%w 保留原始因果——最外層看到的堆疊軌跡是乾淨的,但它指向的是錯的地方。

七號記的「錯誤如何被接住」,其實是在追蹤同一件事:一個失誤進入系統之後,是被誠實地標記、修正,還是被沿途改寫成別的東西。這一階段她還不做推論,但這條從報錯者連到二號的線,已經是一筆結構性證據。

我還注意到一件說不上來的事。法官報完死訊、宣布放逐之後,那道官腔底下好像還壓著另一種很輕的聲音——像是有個東西,在他搆不到的地方,每當一個版本被改寫,就在旁邊悄悄留下一份沒被動過的原稿。

讀完這篇,你應該能寫出一個回傳 Result / (T, error) 的函式並用 ?if err != nil 逐層傳播,也能解釋 Java checked 與 unchecked 例外的取捨。想再練,Go 官方文件、Rust 官方 Book 與 Python 官方文件都有完整章節。


參考資料與延伸閱讀


¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。


上一篇
Day 12|死者的牌何時該被忘記:四種記憶體回收觀"
下一篇
Day 14|女巫扣著兩瓶藥:動態陣列的擴容祕密
系列文
狼人自爆的心路歷程:一個「AI人」的30天自學修煉17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言