首先是我最为熟悉的 JavaScript 语言,它对异常的设计显然过于粗糙。结构层面上讲,作为一门基于原型的语言,万物归宗始于 Object 这是自然,异常的概念则始于 Error ,在 Error 之下定义了一些 NativeError ,同时 Error 也作为用户自定义异常的原型起点。
一切似乎倒也合理,但在流程层面,JavaScript 充分显示出了它的先天不足,出生便是个没人疼爱的孩子,大约是抄袭自 Java 的 try...catch... 和 throw 语法便是其全部的控制能力。由于其简陋的天性,又不像 Java 那样在方法签名上明确给出异常标记,加之其语言特色造成的大量异步编程,追踪 Error 对象的流转过程简直是一场灾难。
Error 被 catch 之后,可以被继续 throw ,可以被 return ,也可以被当作参数传入回调函数中。ES6 Promise 后,又会被放入 Promise 的 catch() 回调中。Nodejs 社区曾一度基于约定,把 Error放在回调函数的第一个参数中,大抵也是对语言缺少规矩的无奈之举,而基于社区约定的编码健壮性如何,可想而知。
加之语言层面唯一的 try...catch 又不能捕获异步产生的 Error ,加之 async/await的出现,想在 JavaScript 中精确地控制异常,委实需要极大的心智负担。所以 JavaScript 程序员更喜欢通过捕获 error 事件来统一处理异常,这有点像为跳楼的人准备的大垫子,甭管整个系统中的异常传递多么混乱,最后提供一些兜底操作便草草了事,接得住接不住的,尽力而为。
Java 的异常自 Throwable 开始,分出编码中几乎不会使用的 Error 后,用户代码侧更多的是 Exception。事实上,我在使用 Java 的时候,大多会出于简便只定义一个 ApplicationException 并使其继承自 RuntimeException,之后只区分两类异常——抛出后需要最终用户处理的以及抛出后用来给程序员追踪错误信息的。其实能够交给最终用户处理的部分,到底算不算异常,是有待斟酌的。
如“用户名或密码错误”,是正常流程中的一种特例结果还是异常流程,是一直存在争议的(Rust 显然弱化了这种争议)。说到这里有些过于发散了,回到 Java 的异常控制上。它有着一致的 try...catch 控制流,但这种模式一个致命的缺点是你并不清楚异常产生自哪一行调用,同时也存在异常分支过多或异常类型不得不向上泛化的情况,尽管可以通过语法糖来改善,但终归是种跳跃式的流程控制。当然,你也可以每次调用均 try 一下,而不进行 try 合并,但这显然会造成代码的极大臃肿。
相较之下,Rust 在这方面确实设计得更为精良,它没有选择在语言层面支持多返回值,毕竟不管多少返回值,事实上都是可以通过元组或其它复合结构来实现的。它选择了通过枚举来实现。要么正常,要么异常(Rust 并不叫做“异常”),选择枚举似乎是合理的。
Rust 将这个枚举命名为 Result 似乎也体现了其用意,Err 只不过是一种不同的返回情况,它与 Ok 在编程语言形式上并无二异而只存在自然语义上的区分,枚举通过变体的泛型承载不同的内部数据类型。编译器层面因知 Result 的明确类型,故而可以对接收方做强制检查的约束,这有些像各大语言(包括 Rust 自身)对待空指针的 Optional 模式。加之 Rust 的模式匹配,处理 Result 又比 if err != nil 优雅许多。