为什么需要错误处理呢?因为系统的资源有限,并不总是能满足程序的需求;因为程序员总是在写bug,需要处理错误;因为用户总是在瞎搞,需要对客户的请求进行过滤处理;因为别有用心的人总是存在,需要阻止程序被黑;等等。
错误处理已经成为编写健壮代码不可或缺的一部分,良好的错误处理,能保证代码健壮的运行。这里不得不提Rust,为了保证安全,Rust要求程序要捕获和处理错误,Rust经典的错误处理模式:match
模式。不过今天的主角是Go。在进入Go之前,先聊两句C程序的错误处理(这里不讨论C++等其他语言的try...catch...方式和Go错误处理方式的优劣)。C程序的错误处理方式非常的单一(简陋):
- 通过返回错误码。由于C程序不支持多参数返回,错误码占了唯一的位置,有点不爽。
- 通过设置
errno
值。可以通过扩展系统的errno
值,提供更多的错误信息。 - 实现DIY错误处理机制,返回保存错误信息的结构体。
Linux几乎所有的系统调用API都使用了前两种方式,原因是,系统调用是在内核态中运行的,返回错误码或设置errno
值不会分配内存,不存在内核数据与用户数据进行传输的情况,防止内存泄露等一系列问题。DIY错误处理机制要做好总是绕不开内存分配的坑,C的内存是需要手动回收的。有的会说,不就是手动回收嘛!不用就释放就行了,你以为自己是垃圾回收器
,历史告诉我们程序员在这方面总是会出错的。DIY错误处理机制大概率会造成:
-
在写法上就不太友好,总是伴随着释放操作(释放内存)。
-
内存搞不好就容易泄露。
-
分配内存也会消耗性能,不过可以通过类似与Go的
sync.Pool
缓存机制缓解。
言归正传,得益于Go是自动垃圾回收机制,内存泄露不是问题(不过Go还是会存在占着内存不使用的情况,了解Go的垃圾回收机制还是很必要的)。那么Go的错误处理是怎样的呢?先看一下Go的经典错误处理模板:
if value, err = do_someting_can_produce_error(); err != nil {
handle_the_error(err)
} else {
get_what_I_want(value)
}
上面的模板没什么特别的,和C的错误处理模板简直一毛一样。不同的是,多返回值和err不是错误码,而是可以携带更多信息的结构体(error
接口)。
error 接口
Go一般使用error
接口作为错误返回值。下面是Go的error
接口:
type error interface {
Error() string
}
使用接口作为返回值的好处是可是实现多态
,只要错误信息实现了该接口,就能作为返回值,配合上Go的反射功能,能够区分不同的错误类型。下面看一个例子:
package main
import "fmt"
import "errors"
type et struct{}
func (e *et) Error() string {
return string("expect 'et' error")
}
func print_error(e error) {
switch interface{}(e).(type) {
case *et:
fmt.Println(e)
default:
fmt.Println(e)
}
}
func main() {
e := errors.New("expect 'errors' error")
print_error(e)
e2 := &et{}
print_error(e2)
}
输出:
expect 'errors' error
expect 'et' error
上面的例子通过switch type
匹配不同error
接口的实际类型,进行不同的错误处理分支。switch type
的错误处理模式的好处是,能够实现更加个性的错误处理,但在标准库中用得更多的是定义package
范围的错误变量。看下面的例子:
package main
import "fmt"
import "errors"
var (
err_1 = errors.New("err_1")
err_2 = errors.New("err_2")
)
func print_error(e error) {
switch e {
case err_1:
fmt.Println("I am err_1")
case err_2:
fmt.Println("I am err_2")
default:
fmt.Println(e)
}
}
func main() {
e := err_1
print_error(e)
e = err_2
print_error(e)
e = errors.New("other error")
print_error(e)
}
输出:
I am err_1
I am err_2
other error
这种错误处理方式和C设置errno
值错误处理方式很相似(通过strerror(errno)
获取具体的错误原因)。通过这种方式的好处是:
- 没有类型断言带来的性能损失,该例子是通过比较指针值的方式。
- 错误类型的值全局唯一,不需要在堆上分配内存,能稍微减少垃圾回收的压力。
不好的地方是:错误值要一开始就能确定,如果使用errors.New()
函数创建错误变量,此时变量指针在编译时无法确定,也就没办法使用该模式。
下面看一个糟糕的例子:
package main
import "fmt"
type et struct {
err string
}
func (e *et) Error() string {
return e.err
}
func print_error(e error) {
err, _ := e.(*et)
switch err.err {
case "err_1":
fmt.Println("I am err_1")
case "err_2":
fmt.Println("I am err_2")
default:
fmt.Println(err)
}
}
func main() {
e := &et{err: "err_1"}
print_error(e)
e = &et{err: "err_2"}
print_error(e)
e = &et{"other err"}
print_error(e)
}
上面不好的地方是,通过比较字符串(错误信息)的方式区分不同的错误类型,实现错误分支处理。因为错误信息是一个易变的东西,哪天一不小心修改了错误信息,没有同时修改错误处理分支匹配的信息,就造成bug了。
我认为的error
接口使用方法:
- 如果仅需要判断有没有错误发生,没有不同的错误类型处理分支,使用
errors.New()
的方式最好,发生错误直接记录日志。 - 有不同错误分支,优先考虑使用
package
内定义变量的方式。不能满足要求时,配合使用类型断言。
panic && recover
panic
是Go的运行时恐慌,一般都是程序发生致命错误时才会抛出。panic...recover
机制和 try...catch
机制很像,都会逆着函数调用栈向上传递,直到错误被捕获。但try...catch
作为一种正常的错误处理机制,而panic...recover
更多的是作为一种异常的错误处理机制。读到过一些Go文档和书籍都提倡:Go程序不应该panic,特别是在写库的时候。的确大多数情况下,一个健壮的程序是不需要引入panic
的。在写库函数不要使用panic
,是因为我们不能要求调用端代码有recover
机制。库代码更多是应该返回错误信息,而不是通过panic
尝试终止程序。
那什么时候可以用panic
呢?
- 内部代码。限制
panic...recover
的使用范围,不能同panic
向客户端代码抛出异常。Go-Gin
(开源的http/https框架)的内部代码就经常使用panic...recover
错误处理机制。 - 示例代码。示例代码为了保证可读性,避免冗长的错误处理流程。
- 应用程序代码。即非库代码,因为代码处于最上层,使用
panic...recover
处理方式也是有意为之,程序异常退出也在意料之中。 - 非用不可的时候。就是使用
panic
抛异常更加符合直觉或者说错误不被允许的。
下面看一个除零的例子:
package main
import "fmt"
func main() {
z := 0
a := 10 / z
fmt.Println(a)
}
输出:
panic: runtime error: integer divide by zero
goroutine 1 [running]:
main.main()
/tmp/a.go:7 +0x11
exit status 2
上面Go就使用了panic
的方式向上传递错误。在除法表达式
中没有比抛异常更符合直觉的方式,难道每次使用除法时还要返回一个error
值?下面也是一个可用panic
的例子:
package main
import "fmt"
type onlyOneCopy struct {
a int
noCopy *onlyOneCopy
}
func onlyOneCopyNew() *onlyOneCopy {
i := onlyOneCopy{
a: 1,
}
i.noCopy = &i
return &i
}
func (i *onlyOneCopy) do_something() {
if i.noCopy != i {
panic("can't not copy `onlyOneCopy`")
}
fmt.Println(i.a)
}
func main() {
i := onlyOneCopyNew()
i.do_something()
ic := *i
ic.do_something()
}
输出:
1
panic: can't not copy `onlyOneCopy`
goroutine 1 [running]:
main.(*onlyOneCopy).do_something(0xc000084f30)
/tmp/a.go:21 +0xb4
main.main()
/tmp/a.go:31 +0x63
exit status 2
上面的例子定义了一个结构体,但该结构体有一个强制性的要求就是,不能进行复制。因为是强制性的要求,任何调用者都应该遵守,此时我认为直接使用panic
抛异常会比使用error
作为返回值,更加具有约束作用。
题外话
分享一下我写C程序的错误处理方法,通过错误码向上层传递错误,错误发生的地方记录错误日志,以便排查。错误码尽量简单,如果能返回bool
类型的绝不返回其他类型;如果错误码多个,写上注释,并说明错误码的含义。