由于是对技术的个人评判,欢迎理性讨论。
我曾经也当过纯函数式的脑残粉,认为宇宙第一棒的代数数据结构用来处理错误,是无上的优雅和绝对的安全。一个看似人畜无害的接口抛出异常带来的崩溃,是各类疑难杂症的罪魁祸首。综合起来,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。