Go笔记 - 错误处理

289 阅读7分钟

为什么需要错误处理呢?因为系统的资源有限,并不总是能满足程序的需求;因为程序员总是在写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类型的绝不返回其他类型;如果错误码多个,写上注释,并说明错误码的含义。

参考

Error Handling In Go, Part I

Error Handling In Go, Part II