学习 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
是否实现了特定行为,不用担心引用的三方库升级后,程序逻辑失败。