汉娜·阿伦特报道了阿道夫·艾希曼在耶路撒冷因组织屠杀犹太人受审的案件,在法庭上,她发现艾希曼并不是一个冷静且思维缜密的杀手,相反,“说话时间越长,越能感受到他表达力的匮乏与思考能力的缺失,他不会站在别人的立场思考问题,之所以无法同他交流,并不是因为他说谎,而是因为他周围围绕着坚固的壁垒,屏蔽他的言辞和他人的存在,从而帮他一并拒绝着真相。”她震惊于恶的平庸性(Banality of Evil)。
艾尔特拉克和蓝色地鼠一起编写健壮的软件,在编辑器面前,他发现 if err != nil 并不是什么精巧的错误处理机制,相反,编程时间越长,越能感受到地鼠错误处理机制的缺失,他不会帮程序员抛出和拦截错误,之所以无法用条件判断以外的方式处理错误,并不是因为地鼠不想,而是因为已有的简单工具就足够提升程序的健壮性。他震惊于错误的平庸性——错误仅仅是包含 Error() 的
接口
而已。
魂断杰威幕
众所周知,嘲讽 Java 是这个博客的传统,所以今天的文章也以这个开始。在 Java 中,处理「错误」的方式是抛出异常。这里的异常(Exception)是指程序中可以预料到的问题,比如用户本应输入整数却输入了非数字字符,而错误(Error)在 Java 中指的是那些几乎无法恢复,会使程序退出的问题。
是的,术语开始混乱起来了,详见下表。
|
Java |
Go |
| 可预期到的、程序应处理的问题 |
异常(Exception) |
错误(Error) |
| 没有预料到的、难以恢复的问题 |
错误(Error) |
崩溃(Panic1) |
为了方便讨论,在正式讨论 Go 语言之前,我会避免使用「错误」这个词,统一使用「异常」和「崩溃」两个词。老实说,「崩溃」这个词也不准确,Java 程序如果抛出 Error 不一定会终止程序,而 Go 程序如果 panic 也能够恢复,但姑且先这么用着吧。
Java 的异常和崩溃共用一套机制,他们甚至共用一个基类 Throwable(可抛出类)。Java 的异常和崩溃都是被抛出(throw)的,而抛出 Throwable 的函数的调用者可以接住(catch)被抛出的东西。不过 Oracle 的文档对 Error(崩溃)和 Exception(异常)两个子类的描述很有意思。
The class Exception and its subclasses are a form of Throwable that indicates conditions that a reasonable application might want to catch.
——
Exception - Java Platform SE 8
An Error is a subclass of Throwable that indicates serious problems that a reasonable application should not try to catch.
——
Error - Java Platform SE 8
简单来说,异常应该被接住,而崩溃不应该被接住。任何 Throwable 只要没被接住,就会使程序崩溃,提示 Unhandled Exception(未被处理的异常)——等等,你有没有发现,没有被接住的明明是 Throwable,为什么提示信息会是 Unhandled Exception?难道 Error 不也是 Throwable 吗?
是的是的,我们已经发现 Java 让异常和崩溃共享同一个机制引发的问题了。异常和崩溃都可以被抛出,但区别在于崩溃不该被接住,而异常应该被处理。没有处理的异常会导致 Unhandled Exception,而本就不应该被处理的错误也会导致 Unhandled Exception。Java 还有一个
UncaughtExceptionHandler
接口,用来处理线程里那些没有被接住的异常,我们来看看接口中包含的方法签名。
interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
啊,是的,代码里写的是 Throwable,可名字里明明是 Exception 呢。显然 Throwable 作为父类的抽象层次比子类 Exception 高,而熟悉 OOP 的程序员应该知道,父类不应该知晓子类存在,而一个为父类设计的接口竟然包含子类的名字,尽管没有直接依赖子类,但显然不是好的设计。更何况,我们已经体验到这种做法造成的迷惑和混乱了。
如果崩溃不应该被接住,那它一开始就不应该与需要被接住的异常归属于同一基类。如果子类之间的行为不同,就违反了里氏替换原则。Always glad to find design flaws in Java.
我们一直在谈抛与接,是时候来看看代码里怎么写这两个东西了。「接」想必各位都很熟悉了,try ... catch 语句在 JavaScript 等语言中也能见到。
try {
doSomething(); // 这个函数抛出异常
} catch(IOException e) {
// 接住 IOException
// 处理这个异常
} catch(Exception e) {
// Exception 是所有异常的基类
// 处理剩下其他类型的异常
}
如果不把 doSomething() 包裹在 try 里会怎么样呢?你会得到 Unhandled Exception 信息。为什么会这样呢?因为 doSomething 的方法签名声明了异常。
void doSomething() throws Exception;
throws 关键词用在方法签名中,表示这个方法会抛出异常,而调用者应该抓住这个异常。如果声明了多种不同类型的异常,那么调用者应该抓住所有类型的异常,或者…… 只写一条 catch(Exception e)。如果调用了可能会抛出异常的方法却没有放在 try 里面,就会得到 Unhandled Exception。
真的吗?如果我就是不想要写 try ... catch 呢?
那就让 Java 教会你踢皮球,把这个错误抛给你的调用者就好了!
// 这段代码会出错
doSomething();
// 这段代码处理了异常
try {
doSomething();
} catch(Exception e) {
// ...
}
// 这段代码向上抛出了异常
void faireQuelqueChose() throws Exception {
doSomething();
}
老实说,我写 Java 程序的时候就这么干过好几次,大不了最后这样写:
public static void main(String[] args) throws Exception {
// 想怎么抛怎么抛!
}
不过异常具体是怎么被抛出的?
void doSomething() throws Exception {
// 有不对劲的事情发生了
// 赶紧抛出异常告诉调用者
throw Exception("Something weird happened!")
}
嗯?这是个新的关键词吗?看起来是的,用在方法签名上告知调用者可能抛出的异常类型的关键词是 throws,而用在方法体内部抛出异常的语句是 throw。这个语句和一个标记了 throws 的方法调用在某种程度上是等价的,在方法体里写 throw Exception() 和可能会抛出异常的 doSomething(),都需要做异常处理,要么放在 try ... catch 里,要么往上抛让调用者处理。
原来 Java 程序员在一开始就要区分 throw 和 throws 这种细微的区别,怪不得我那个半吊子 Python 老师这么喜欢
纠结语言保留字
!
与此同时,Lisp 程序员:语法…… 语法太多了…… 看不懂……。
No Exception
让我们总结一下,在 Java 中,异常和崩溃都要被抛出。抛出异常就等于说是要调用者(或者调用者的调用者)接住并处理这个异常,把错误处理的责任向上传递。这其实很符合逻辑,因为有一些错误就是没办法在方法内部解决,比如一个发起 HTTP 请求的方法遇到请求超时就只能上报问题。抛出错误就等于说是要程序因无法处理的错误而终止。
抛与接看起来很精巧,许多人都喜欢此类机制。如果机制是必要的,那么机制的存在使得学习和理解的成本上升,使得语言变得臃肿,或者引发机制内部的混淆与问题(比如 Error 也算 Unhandled Exception),似乎也还算能接受。可问题是,机制真的是必要的吗?我们真的需要这一层额外的抽象吗?
程序中应该被解决的错误被称作异常,抽象为 Exception;程序中无法解决的错误被称作崩溃,抽象为 Error。这两个类都是被抛出的,被称作「可抛出类」,所以抽象为 Throwable。为什么需要最高的这层抽象?这种抽象真的合理吗?它的子类难道不是两个很不同的东西吗?
思来想去,让 Error 和 Exception 归为一个基类的原因,我只能想到「它们都是被抛出的」这一个。正方形和矩形都是「可求面积的」,那么就可以都归类为 Squarable 吗?显然不能,正方形不能继承矩形已经是里氏替换原则的一个经典案例,让正方形和矩形都归为一个基类更是荒唐。类比之后可以发现,Error 和 Exception 也是如此,仅仅因为想让它们共用抛出机制就归为一个基类是愚蠢的。仔细看的话,Error 倒像是 Exception 的一个不需要被接住的例外(exception),而 Oracle 自己对 Exception 的描述却是「conditions that a reasonable application might want to catch」,所以到底接还是不接呢?好像不接也行,那 Exception 和 Error 真的有区分的必要吗?但好像不区分也不行,毕竟确实是两个概念。
是时候终止这场混乱了。
“Errors are values.”
这句话是
Rob Pike
说的。其实我还想更进一步说:Errors are conditions. 这里的 condition,既是状况,也是条件。仔细想的话,如果没有错误处理机制,我们就是会用条件分支语句处理异常情况。
func addThreeNumbers(numbers []int) string {
if len(numbers) > 3 {
return "too many numbers"
} else if len(numbers) < 3 {
return "too few numbers"
} else {
return strconv.Itoa(numbers[0] + numbers[1] + numbers[2])
}
}
假设这个错误在内部处理不了,需要交给调用者处理。比方说,某个函数仅仅是读取文件,它并不知道读取文件是为了什么,也就不会知道要是文件不存在应该怎么办。假设这个函数的调用者是为了读取配置文件,而它知道,没有文件的时候就写入默认配置。函数的调用者要怎么知道函数没有获取到文件呢?最简单的做法是返回空值。
func getConfig() {
config := readConfigFromFile(CONFIG_PATH)
if config == "" {
config = DEFAULT_CONFIG
}
}
等等,你的意思是说返回值就能让调用者知道函数内部发生了需要处理的错误,是吗?Hmmmm…… 我好奇为什么还会有人发明一整套混乱的机制欸?
你可能会说:“这不能强迫开发者解决问题,他们完全可以忽略错误而不做处理,而 Java 强制通过异常抛出机制要求程序员解决所有的异常,无论如何都是要通过 try ... catch 处理的,或者还有全局的错误拦截。总之 Java 的设计让程序员更容易编写健壮的软件!”
Hmmmm…… 我好奇这段代码有什么效果欸?
public static void main(String[] args) throws Exception {
// 想怎么抛怎么抛!
}
这么做也没有任何后果,可以把错误一直往上抛,直到程序的入口方法。反正 Java 里的方法定义已经要写一堆 public static 了,多写几个 throws Exception 也不会觉得有什么区别吧?
你可能还会说:“异常抛出机制为错误处理提供了统一的解决方案,所有异常都基于 Exception 类,彼此还相互继承,更是通过 OOP 的机制让错误处理变得更灵活!”
Hmmmm…… 原来是 OOP 啊。说起来,Go 由于缺少继承,的确不算 OOP 语言,但我们从不少 Java 程序员也喜欢读的四人帮《
设计模式
》中得知,组合优先于继承,并且应该针对接口编程而不是针对实现编程。好的设计模式中很少见到继承,因为不方便复用,方便复用的是接口。
啊对,Go 语言也有统一的错误处理范式,但没有引入任何新机制,因为错误的全部定义只有三行代码:
type error interface {
Error() string
}
所有实现了 Error() 函数的结构体都是错误值。于是我们回到 Rob Pike 说的「Errors are values」。如何让调用者明确知道函数内部发生了错误,而不只是恰好返回了空值呢?——再返回一个专门的错误值。
func getConfig() {
config, err := readConfigFromFile(CONFIG_PATH)
if err != nil {
config = DEFAULT_CONFIG
fmt.Println("config: using defaults because of error", err)
}
}
func readConfigFromFile(path string) Config {
if body, ok := readFile(string); ok {
if config, ok := parseBody(body); ok {
return config, nil
} else {
return nil, &errorString{"error parsing config file body"}
}
} else {
return nil, &errorString{"error reading file"}
}
}
这是通用的解决方案,尤其是用于有多种类型错误的情况。假设一个操作只有成功与否,而我们不关心失败的具体原因,只关心成功没有,那可不可以让错误处理变得更简单呢?当然,返回布尔值就好了,看到上面的 ok 了吗?它接收到的是函数的第二个返回值,在这个例子里,这第二个返回值不是 error 类型,而是 bool 类型。
如果没有接收函数的所有返回值,编译器就会报错:assignment mismatch。如果接受了错误值却没有使用,编译器就会报错:declared and not used。这两个错误信息都不是为错误处理而生的,而是本来就存在。Go 语言没有发明 unhandled exception 这种东西来要求程序员处理错误,编译器甚至不知道「错误」这个概念,它只知道有函数返回值没有被赋值给变量,只知道有变量被声明了但没有使用,而这对 Go 语言严格的编译器来说都是不可接受的。当然,和 Java 一样,这也有规避方案,比方说把错误赋值给 _,编译器就会认为这个变量不需要使用,通过检查。不管有没有专门的错误处理机制,错误都很容易被程序员忽略,不做处理,这本来就不应该是由编程语言本身强加给程序员的事情。就像 Rust 实际上并不是一门安全的语言,无论怎么样,就连 Cloudflare 的高级工程师也会滥用 unwrap()。Java 把 throw 做得这么难处理,还用 try ... catch 把代码的嵌套层次变得越来越深,反而是多此一举。
用接口统一错误类型,用函数的多返回值上报错误,用严格的编译器类型检查预防错误不被处理,这整条链路都没有引入任何新的机制。Go 的错误处理范式,竟然如此平庸和无趣。
“Don’t Panic”
Don’t use panic for normal error handling. Use error and multiple return values.
——
Go Wiki: Go Code Review Comments
Go 官方给出的常见代码审查评论中,有一条是 Don’t panic,意思是说不要用 panic() 做错误处理。这个内置函数会终止当前函数栈的执行,并且会让当前函数本身也成为一个 panic(),也终止当前函数的调用者的执行,panic 就会这样沿着函数调用栈一直往上,直到程序入口函数,最后终止程序,除非中途调用了 recover() 恢复程序。
「沿着函数调用栈往上」,这听起来很像 Java 的抛出机制。Java 的 Throwable 一旦被抛出,就会一直往上直到被 try ... catch 接住,如果没有被接住就会使程序终止——和 panic() 一样。你发现问题了吗?Exception 也是 Throwable,倘若异常没有被处理,也会使程序崩溃。这种情况却是 Go 想要避免的,所以 Go 建议程序员使用多返回值和 error 类型,而不是使用会使程序终止的 panic()。
这再次印证了「异常情况」和「会使程序崩溃的情况」本来就是两个完全不同的东西,被 Java 硬塞到了 Throwable 里,就为了让它们共用本就不合理的抛出机制。异常情况仅仅是程序的一个分支条件,这个条件可以用布尔值或者 error 类型的值表示。如果没有好好处理这种分支条件,那么程序顶多出现意想不到的行为,不应该崩溃。Java 的设计却使得本应该成为分支条件的语句,直接终止了整个程序运行。
会使程序终止的情况不应该是可以预料的分支条件,比方说死锁、内存溢出和数组越界。Java 说一个合理的应用程序不应该抓住崩溃(Error),而 Go 提供了恢复崩溃(panic)的方法。
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered ", r)
}
}()
panic("Panicking!")
defer 也不是专门为了处理异常而发明的新语法,它所做的仅仅是推迟代码执行。推迟到当前函数的最后,当然也在 panic() 之后(不过在 return 之前)。由于 defer 语句在 panic() 之后执行,就可以在 defer 中执行 recover(),使得 recover() 在 panic() 之后执行,这样就恢复了崩溃。
由于 panic() 会自己沿着函数调用栈往上,所以不需要在相关链路中的每一个函数签名中都写上 throws Exception 之类的东西,只要在某个环节执行 recover() 就好了。
不过无论怎么说,panic() 都不如 error 常用,也不鼓励使用。
最后
质疑 if err != nil,理解 if err != nil,成为 if err != nil 的拥护者,然后狠狠嘲讽 Java。这便是艾尔特拉克魂断杰威幕之后身处岣琅的心路历程,回见了。