• 各种编程语言对于异常处理的方式对比
  • 发布于 2个月前
  • 164 热度
    0 评论
异常处理属于编程语言必须精心设计的基础语法之一。事实上一直一来,并没有哪门语言的异常处理方式让我觉得十分舒畅,大概也是因为异常处理本身便是些惹人不开心的程序逻辑。这一次疫情居家时间较长,闲来无事时思考及此,便把自己一些不成熟的想法记录下来。

首先是我最为熟悉的 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 事件来统一处理异常,这有点像为跳楼的人准备的大垫子,甭管整个系统中的异常传递多么混乱,最后提供一些兜底操作便草草了事,接得住接不住的,尽力而为。


Python 几乎存在同样的困境,也是 try 搭配 raise 的流程跳跃式的异常控制。加之 else、finally 一堆不伦不类的控制手段,无甚新意。

Java 相对要理性一些。所有的异常处理逻辑也是基于“抛出+捕获”的模式,但却有了不少条理性。然而 Java 对于异常类型的定义却过于复杂,显然是想通过不同的 class 来区别对待所有异常。如果你让一个 JavaScript 程序员说出 5 个 NativeError,他可能答不出 2 个。但如果你让一个 Java 程序员说出 5 个标准库中的异常,他可能会说:“要不然我说 50 个给你听吧?”

Java 的异常自 Throwable 开始,分出编码中几乎不会使用的 Error 后,用户代码侧更多的是 Exception。事实上,我在使用 Java 的时候,大多会出于简便只定义一个 ApplicationException 并使其继承自 RuntimeException,之后只区分两类异常——抛出后需要最终用户处理的以及抛出后用来给程序员追踪错误信息的。其实能够交给最终用户处理的部分,到底算不算异常,是有待斟酌的。


如“用户名或密码错误”,是正常流程中的一种特例结果还是异常流程,是一直存在争议的(Rust 显然弱化了这种争议)。说到这里有些过于发散了,回到 Java 的异常控制上。它有着一致的 try...catch 控制流,但这种模式一个致命的缺点是你并不清楚异常产生自哪一行调用,同时也存在异常分支过多或异常类型不得不向上泛化的情况,尽管可以通过语法糖来改善,但终归是种跳跃式的流程控制。当然,你也可以每次调用均 try 一下,而不进行 try 合并,但这显然会造成代码的极大臃肿。


相较“抛出+捕获”的模式,Go 把 Err 作为一种返回值的方式显得明智许多。对于程序的调用方,异常的产生只是一种返回结果的特例,它不会造成调用者流程的跳跃。所谓异常,只不过是一种有别于寻常的返回结果,结果直接产生自被调用者,这样的设计思路看似简单无新意,却合理地跳出了 try...catch... 的怪圈,甚为优秀。

Go 能够同时返回正常结果和 Err ,是借助于其语言的“多返回值”特性,但对于调用方来讲又不得不频繁地检查 Err 是不是存在,不存在时才会进行正常处理。因此 Go 程序中充满了大量的 if err != nil 判断,并不比 Java 的 try...catch... 优雅。再者,如不慎遗漏此判断,又很容易产生程序 BUG 。

相较之下,Rust 在这方面确实设计得更为精良,它没有选择在语言层面支持多返回值,毕竟不管多少返回值,事实上都是可以通过元组或其它复合结构来实现的。它选择了通过枚举来实现。要么正常,要么异常(Rust 并不叫做“异常”),选择枚举似乎是合理的。


Rust 将这个枚举命名为 Result 似乎也体现了其用意,Err 只不过是一种不同的返回情况,它与 Ok 在编程语言形式上并无二异而只存在自然语义上的区分,枚举通过变体的泛型承载不同的内部数据类型。编译器层面因知 Result 的明确类型,故而可以对接收方做强制检查的约束,这有些像各大语言(包括 Rust 自身)对待空指针的 Optional 模式。加之 Rust 的模式匹配,处理 Result 又比  if err != nil 优雅许多。


试想这样一种场景:“读取 100 个文件,对于读取成功的文件,获取其文本内容;失败的文件记录错误原因。”Java 的方式通常要构造两个集合,开启遍历后,在遍历条件的内部不停地  try...catch... ,并在 try 或 catch 两个流程中跳跃式地收集信息。而 Rust 的方式却只需要一个存放 Result 的 vector 即可。大道至简。

用户评论