Go 语言中的错误处理

学习 Go 语言之前,我认为为程序员提供错误与异常处理的最方便的语言是 Python;学习 Go 语言之后,我的看法没有任何改变……

@DGideas

本文参考了 @ethancai 的这篇文章@davecheney 在 Gocon Spring 2016 上的这篇演讲稿,以及若干位于 golang.org 上的博客文章和文档。

Go 语言有着异于其他程序设计语言的错误处理体验。要说 Go 程序设计语言的一大特色,恐怕就是它频繁出现的 if err != nil 语句块了。得益于 Go 语言函数的多返回值(multiple results)特性,一个不一定总能成功执行的函数可以依靠一个类型为 error 的返回值表示其执行过程是否处于异常状态,例如:

f, err := os.Open("filename.ext")
if err != nil {
	log.Fatal(err)
}

这种传递机制可以帮助 Go 语言程序员(尤其是函数的设计者)在不重载函数返回结果本身意义的情况下,额外向函数的调用者传达“函数调用是否符合预期”的信息。事实上,Go 语言的设计者认为使用类似 try-catch-finally 语句块进行错误与异常处理会导致代码层次繁琐。从语言层面要求程序员显式处理错误有助于提升程序的健壮性。

为了在 Go 语言中处理错误,我们需要了解有关 error 类型的更多信息。

error 是什么?

src/builtin/builtin.go 中的代码中,我们注意到语言本身是通过以下方式定义 error 类型的:

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
	Error() string
}

这些语句代表什么?有经验的 Go 语言程序员告诉我们,它表明错误(errors)仅仅是个(value)。语言将 error 实现为接口(interface)类型,也就是说——任何实现 error 这个 interface 的类型本身都属于错误类型

标准库 errors 提供的 New() 方法容易让我们定义新的错误类型。例如,标准库 io包含自定义错误类型 io.EOF,表示读取时遇到文件结尾(End-Of-File,EOF)。io.EOF 的定义如下:

var EOF = errors.New("EOF")

有了自定义错误类型,我们拥有了能够区分一个错误是“何种错误”的能力,下面是根据错误类型判断的错误处理方法:

buf := make([]byte, 100)
n, err := r.Read(buf)
buf = buf[:n]
if err == io.EOF {
	log.Fatal("read failed:", err)
}

就像 Python 语言中的 except EOFError: 一样,上方的语句只捕获 io.EOF 一种错误。这种方法并不是很灵活。如果上游对 Read() 函数的逻辑进行了修改,使其返回的错误类型变成了 io.END 或者其他类型,则所有调用 r.Read() 函数下的错误处理逻辑均需修改。

说到这里,我们可能会冒出很多想法来变通地解决这个类型变化的问题。诸多方法中,最靠谱的一种方法是依靠 err.Error() 返回的文本来判断是何种错误:

f, err := os.Open("filename.ext")
if strings.Contains(err.Error(), "not found") {
	log.Fatal(err)
}

error 类型中 Error() 返回的字符串是为人类用户设计的,因为错误描述随时可能发生变化。在你的职业生涯中,永远不要依赖字符串判断错误类型,我们在下边将会看到更加优美的错误处理方法。

两条建议……

还有另外两种处理错误的更常用方法:作为函数的设计者,我们可以在函数中自定义错误类型,让上层函数的调用者通过类型断言判别错误类型;或者只检查是否发生错误(err != nil),即不知道被调用函数返回的错误任何细节。这些方法各有利弊,我们接下来讨论这些方法。

在被调函数中自定义错误类型的行为看起来跟上一节介绍的 io.EOF 的做法类似,不过由于我们自定义了错误类型,就可以向错误值中附加更多上下文信息:

type MyError struct {
	Msg string
	File string
	Line int
}

func (e *MyError) Error() string {
	return fmt.Sprintf("%s:%d: %s", e.File, e.Line. e.Msg)
}

return &MyError{"Something happened", "server.go", 42}

这段来自 @davecheney 的示例代码自定义了一种能保存错误上下文(文件名称,代码行数)信息的错误类型。当错误信息被传递时,即使顶层函数打印错误日志的时候也能够精确定位到是哪个文件的哪一部分内容遇到错误。Go 语言有一个类似的笑话:程序员在一个复杂依赖关系的网络访问程序里层层传递错误,终于在顶层,错误信息被打印出来:

No such file or directory.

显然,这种错误信息对调试没有任何帮助。不过,像 github.com/pkg/errors 这样的框架提供了 Wrap()Cause() 函数对,这些函数将错误层层封装后传递给顶层函数。这样,顶层函数打印的信息就包含了满足程序员调试需求的足够上下文:

func Wrap(cause error, message string) error
func Cause(err error) error

使用Wrap()Cause() 函数对后,错误打印信息类似这样显示:

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

对吧,看起来好多了!

程序员需要知道,所有根据错误类型推断错误的做法都会增加模块之间的依赖性。当我们处理错误时,如果只关心是否发生错误,而不关注错误类型,我们就可以使用 if err != nil 这种形式,这通常也是初学者最先学习到的错误处理方法。作为上层代码,你需要知道的就是被调用函数是否正常工作。如果你接受这个原则,将极大降低模块之间的耦合性。

有时候,我们并不关注错误的具体类型是什么。而是关注错误是否实现了某一个行为。比如,标准库 net 定义的错误类型 net.Error 是这样的:

type Error interface {
	error
	Timeout() bool   // Is the error a timeout?
	Temporary() bool // Is the error temporary?
}

在诸如网络通信等任务中,如果遇到错误,需要了解错误的属性,以决定是否需要重试等操作:

type temporary interface {
	Temporary() bool    // IsTemporary returns true if err is temporary.
}

func IsTemporary(err error) bool {
	te, ok := err.(temporary)
	return ok && te.Temporary()
}

这种实现方式的好处在于,不需要知道具体的错误类型,也就不需要引用定义了错误类型的额外库。如果你是底层代码的开发者,哪天你想更换一个实现更好的 error,也不用担心影响上层代码逻辑。如果你是上层代码的开发者,你只需要关注 error 是否实现了特定行为,不用担心引用的三方库升级后,程序逻辑失败。