
由于是對技術的個人評判,歡迎理性討論。
我曾經也當過純函數式的腦殘粉,認為宇宙第一棒的代數數據結構用來處理錯誤,是無上的優雅和絕對的安全。一個看似人畜無害的接口拋出異常帶來的崩潰,是各類疑難雜癥的罪魁禍首。綜合起來,sum 類型相比 exception 的優勢有:
- 不可變數據結構,purity is dignity;
- 編譯檢查,必須處理,懶鬼退散,nobody can avoid it(異常經常不被妥善處理,直至發酵);
- 可以寫在接口聲明中,并且嚴格規定了能產生的錯誤類型,anyone can hear the lion in the box(幾乎所有使用異常機制的語言都不要求標注異常,且一個函數可能拋出任何預料之外類型的異常);
- 恢復錯誤時沒有exception那么大的開銷。可以用清晰簡潔的方法處理結果(
unwrap
,expect
等臨時方法,以及and
,or
等 combinator),并且能清楚地區分對待嚴重故障(panic)和預料之內的小問題(相對而言,捕捉異常要寫大堆 try ... catch 方塊,并且 exception 和 panic 一樣直接崩潰)。
然而我越去實際使用這兩種方法,越發覺得 exception 要遠好于 sum type。原因如下:
- 不強制要求處理異常,不是異常機制的問題,而是編譯器設計的問題。編譯器可以要求拋出異常的函數標注其拋出的異常類型,且調用這種函數的函數如果不處理這些異常,也必須標注這些異常的類型;
- 如果強制處理異常,程序員就會抱怨 try ... catch 太多太煩。但這個麻煩也完全是設計問題。學習 Rust,同樣可以設計
?
運算符,用例如foo()?
,在foo()
未拋出異常時直接使用結果,否則拋出異常給上層。unwrap
,except
之類的操作符也很容易實現,combinator 也不在話下; - 代碼量大了,到處都是
Result<T, E>
很讓人崩潰。很多很簡單的功能因為要適應其內部調用的函數或外部調用它的函數,也不得不給返回類型加上Result
。雖然為了安全,這是必要的開銷,但是這里面暗藏兩個問題:- 第1個,多寫那么多
Result
,并不能在錯誤出現時讓我獲得更多。層層傳遞的Result
不會自動保存調用鏈條,無法像 exception 那樣從最深處 propogate(浮現?),保存直達病灶的堆棧信息。所以到需要輸出問題的時候,Result
只有一行,exception 有幾百行(或許編譯器也可以給Result做優化); - 第2個,
Result
的 err type 實際上自縛手腳,把函數里可能產生的錯誤類型勒死在 err type 上。有時函數可能產生多種錯誤,卻非要用一個單獨的錯誤來統一,而這個單獨錯誤還得起一個面面俱到的名字,最后名字變得極為抽象,不明所以,最后就統一一種了事(雖然經常是工程設計問題,但很多時候身不由己)。相比而言,拋出何種 exception 完全由各個功能模塊自己決定,不需要相互約束。IO 產生的錯誤到外面還是 IOError,沒有轉換的開銷。
- 第1個,多寫那么多
- 最后一點,用了
Result
,函數簽名不再純凈,我的工廠生產的啤酒不再是啤酒,而是Result<啤酒, 生產錯誤>
,做出來的菜不再是菜,而是Result<菜, 失敗料理>
。再也不能揮揮灑灑寫邏輯,思考也受處理Result
阻礙。
所以我的提法是,不要拋棄異常。甚至,在有方便的異常處理操作符,且編譯器嚴格要求程序去處理之時,可以完全不需要 Result
。作為例子,看以下 Rust 代碼,它是一個語法分析程序:
enum Token {/* 定義詞法分析的結果:token */
}enum Expr {/* 定義語法分析的結果:表達式 */
}/* 包含多種錯誤,但嚴格來說 EOF 并不算詞法錯誤 */
enum LexError {EOF,UnexpectedEOF,Lexeme(String),
}/* 從源碼獲取下一個 token */
fn next_token(source: &str) -> Result<Token, LexError> { /* ... */ }/* 不得不將 LexError 整合進來 */
enum ParseError {EOF,UnexpectedEOF,Syntax(String),
}/* 從源碼解析一個表達式,看起來還挺優雅,但有轉換開銷 */
fn parse(source: &str) -> Result<Expr, ParseError> {/* ... */match next_token(source) {Ok(token) => /* ... */,Err(LexError::EOF) => Err(ParseError::EOF),Err(LexError::UnexpectedEOF) => Err(ParseError::UnexpectedEOF),Err(LexError::Lexeme(msg)) => Err(ParseError::Syntax(msg)),}
}/* 事情剛開始麻煩起來 */
enum ParseFileError {Syntax(ParseError),IO(IOError),
}/* 從源文件路徑讀取源碼并解析成表達式,混入了 io::Error,可以看到判斷邏輯已十分復雜,很多時候程序員都直接 unwrap 了事 */
fn parseFile(path: &str) -> Result<Expr, ParseFileError> {let mut file = std::fs::File::open(path);if let Err(ioError) = file {return ParseFileError::IO(ioError);}let mut source = String::new();if let Err(ioError) = file.read_to_string(&mut contents) {return ParseFileError::IO(ioError);}let expr = parse(&source[..]);if let Err(parseError) = expr {return ParseFileError::Syntax(parseError);}return expr.unwrap();
}
再來看有嚴格的 exception 會怎樣:
enum Token {/* 定義詞法分析的結果:token */
}enum Expr {/* 定義語法分析的結果:表達式 */
}/* 可以任意定義多種異常 */
#[derive(Exception)]
struct EOF;#[derive(Exception)]
struct UnexpectedEOF;#[derive(Exception)]
struct LexError(String);#[derive(Exception)]
struct SyntaxError(String);/* 返回類型變為單純的 Token,加上異常標注 */
fn next_token(source: &str) -> Tokenthrows EOF, UnexpectedEOF, LexError { /* ... */ }/* 返回類型變為單純的 Expr,并且只需處理有必要處理的 next_token 拋出的異常 */
fn parse(source: &str) -> Exprthrows EOF, UnexpectedEOF, SyntaxError {/* ... */let token = next_token(source) except {LexError(msg) => throw SyntaxError(msg),_ => throw _,}
}/* io::Error 除了要標注,可以完全不用管,清爽很多。再見了,unwrap! */
fn parseFile(path: &str) -> Exprthrows EOF, UnexpectedEOF, SyntaxError, io::Error {let mut file = std::fs::File::open(path)?;let mut source = String::new();file.read_to_string(&mut contents)?;let expr = parse(&source[..])?;return expr;
}
意義不言自明。
更新,感謝評論區 lanus 大佬(不知道為什么@不到)的啟發,補充兩點。
- 足夠強大的編譯器可以推斷 Exception,而不需要手動用 throws 標記。相反,用 nothrow 標記不希望拋異常的函數,在里面編譯器強制要求捕獲并處理所有異常。
- Result 即便要轉換,但開銷還是少于恢復 exception。我想了一下,解決方向有兩種,一種是仍然不要 Result,設法降低 exception 開銷;另一種是加入 Result,但不用標注,由編譯器推斷,保留兩種開銷不同的機制。后者看起來更完美,但編譯器要暗戳戳地改變返回類型。并且必須有匿名枚舉,就像 TypeScript 的那種純粹的 sum type。