背景介绍
刚刚接触 Go 的同学肯定会对 Go 里面的 panic 和 error 有些困惑,很常见的一种误解是把 panic 当成 Exception, 之间的区别也只是叫法不同。但其实他们是完全不同的两个概念,下面我们来看下具体的原因。
错误处理的历史
在分享 Go 语言错误处理最佳实践之前,我们先来了解一下 “错误处理” 的 历史小故事(原文)。
在很早很早以前的C语言时代,函数只支持单个值返回,所以对于错误处理特别不方便。一种常见的错误处理方式是设置函数的返回值为 1 或者 NULL,然后函数的调用方通过读取全局的 errno 来知道具体的错误是什么。复杂一些的情况则可以通过传递一个 struct,在这个结构体中设置具体的错误信息。
然后 C++ 出现了,引入了抛出异常(try ... catch...)的机制。这样的好处是不用在返回的数据结构中传递错误信息了,上层的函数可以通过 catch 来捕获这个异常。如果上层的函数不知道怎么处理这个问题,可以直接忽略,让再上层的函数 catch 这个问题。但是这个方式也有个问题:每个函数都能抛出异常。所以凡是出现调用,都要捕获异常。特别是在做事务之类的操作时,都要考虑实现对应的回滚逻辑。
So the designers of Java sat down, stroked their beards and decided that the problem was not exceptions themselves, but the fact that they could be thrown without notice; hence Java has checked exceptions.
然后 Java 出现了,觉得问题并不是出现在了 exception 本身,而是 exception 不能随随便便被抛出。所以 Java 在抛出异常前一定要先声明会抛出异常。这个方式确实使错误处理大有改观,同时更加的安全。但是渐渐的 Java 的的错误处理被滥用了,更像是一种流程控制。无论大小的异常都被抛出,有些甚至都不算是异常,而真正的异常可能在这个过程中被忽略。比如出现了数组访问越界,或者空指针引用的问题都会被上游无脑 catch 住。
如今 Go 语言出现了,Go 解决这个问题的方式是 "没有异常",而是通过多值返回 error 来表示错误。Go 语言把真正的异常叫做 panic,是指出现重大错误,比如数组越界之类的编程BUG或者是那些需要人工介入才能修复的问题,比如程序启动时加载资源出错等等。
When you
panic
in Go, you’re freaking out, it’s not someone elses problem, it’s game over man.
这是老爷子原文的表述,可以自行感受一下~
回到现实
了解这个背景故事以后,我们知道了 Go 的 error 和 panic 分别为了解决了什么问题。总结一下 Go 的 error 就相当于原先的 "Exception",而那些异常严重的 "Exception" 叫 panic。这样完美的解决了上层业务无脑 catch 的情况。这是一个很棒的思路,但是在实践中会有一些问题。
最常见的:业务逻辑满屏的 error ,模块库大量预留错误
正好今天在看 "http://github.com/grpc/grpc-go" 里面的代码,随便找一个文件比如 rpc_util.go 就有一些“预留”的 error,但实际会直接返回 nil。
func (o MaxRecvMsgSizeCallOption) before(c *callInfo) error {
c.maxReceiveMessageSize = &o.MaxRecvMsgSize
return nil
}
从模块开发者角度考虑这很正常,因为这个函数一旦被使用,再次改变返回值会变得非常麻烦,那么干脆预留一个 error 是个很好的主意。但是别的程序员就崩溃了,你可能也看过以下的代码 。
// 使用error的写法
func first() error {return nil}
func second() error {return nil}
func third() error {return nil}
func fourth() error {return nil}
func fifth() error {return nil}
func Do() error {
var err error
if err = first(); err == nil {
if err = second(); err == nil {
if err = third(); err == nil {
if err = fourth(); err == nil {
if err = fifth(); err == nil {
return nil
}
}
}
}
}
return err
}
所以过多的预留 error,会造成流程控制极大的问题。那么如果这个时候我们使用以下 panic ,你会发现世界瞬间干净了许多。
// panic 写法
func Do2() (err error) {
defer func(){
if r:= recover() ; r!= nil{
err = fmt.Errorf("Error: %+v", r)
}
}()
first2()
second2()
third2()
fourth2()
fifth2()
return
}
所以在某些使用场景下 panic 也可以是一个很好的流程控制工具。
最佳实践:
其实为了解决这个问题,还看了很多其他的讨论,但我觉得核心的问题是大家所在的角度不同。所以较好的方式是在不同的场景考虑。
作为模块提供者:
对外接口一律返回 error
按照规范,作为模块提供者,对外的接口应该准确的返回 error,而不应该出现自定义的 panic。因为对于使用者来说他没有义务来捕捉你模块内部的问题,也不应该关心这个问题。否则又退化为了try catch
形式。但是当真正的 panic 出现时,绝对不要无脑的catch 住,返回一个 error,让上层函数明确的接收到,避免出现更大的问题。
除此以外,模块提供者可以参考一下类似 regexp.MustCompile
的实现,增加一个 Must
声明 ,表明这个函数是用 panic 替代 error。因为作为模块提供者无法预测使用者的业务场景,可能某些 error 对于上层业务来说就是 panic,方便上层业务的开发。
作为业务编写者:
Follow your heart.
作为业务代码,我觉得很重要的一点是可维护性。如果违反这个 error 的规范能够极大提示可读性,节约大量的工作量,那么应该果断的使用 panic 来做流程控制。而且在语言使用过程中违背设计者的本意是一个很常见的事情,新语言被创造的背后也是为了解决问题,更好的提升大家的开发效率。所以即使和设计者的本意冲突又何妨呢?当然了,期待未来出现一个合适的流程控制语法,可以更好的满足大家的需求。
参考资料
Panics, stack traces and how to recover [best practice]yourbasic.org彩蛋:
其实很多的基础库也都是用的 panic 做流程控制的,比如 "fmt","encoding/json",所以 follow your heart~