向调用者返回某种形式的错误信息是库历程必须提供的一项功能。通过前面介绍的函数多返回值的特性,Go中的错误信息可以很容易同正常情况下的返回值一起返回给调用者。方便起见,错误通常都用内置接口error
类型表示。
type error interface {
Error() string
}
库开发人员可以通过实现该接口来丰富其内部功能,使其不仅能够呈现错误本身,还能提供更多的上下文信息。举例来说,os.Open
函数会返回os.PathError
错误。
// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
Op string // "open", "unlink", etc.
Path string // The associated file.
Err error // Returned by the system call.
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
PathError
的Error
方法会生成类似下面给出的错误信息:
open /etc/passwx: no such file or directory
这条错误信息包括了足够的信息:出现异常的文件名,操作类型,以及操作系统返回的错误信息等,因此即使它冒出来的时候距离真正错误发生时刻已经间隔了很 久,也不会给调试分析带来很大困难,比直接输出一句“no such file or directory” 要友好的多。
如果可能,描述错误的字符串应该能指明错误发生的原始位置,比如在前面加上一些诸如操作名称或包名称的前缀信息。例如在image
包中,用来输出未知图片类型的错误信息的格式是这样的:“image: unknown format” 。
对于需要精确分析错误信息的调用者,可以通过类型开关或类型断言的方式查看具体的错误并深入错误的细节。就PathErrors
类型而言,这些细节信息包含在一个内部的Err
字段中,可以被用来进行错误恢复。
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // Recover some space.
continue
}
return
}
在上面例子中,第二个if
语句是另一种形式的类型断言。如该断言失败,ok
的值将为false且e
的值为nil
。如果断言成功,则ok
值为true,说明当前的错误,也就是e
,属于*os.PathError
类型,因而可以进一步获取更多的细节信息。
通常来说,向调用者报告错误的方式就是返回一个额外的error
变量: Read
方法就是一个很好的例子;该方法返回一个字节计数值和一个error
变量。但是对于那些不可恢复的错误,比如错误发生后程序将不能继续执行的情况,该如何处理呢?
为了解决上述问题,Go语言提供了一个内置的panic
方法,用来创建一个运行时错误并结束当前程序(关于退出机制,下一节还有进一步介绍)。该函数接受一个任意类型的参数,并在程序挂掉之前打印该参数内容,通常我们会选择一个字符串作为参数。方法panic
还适用于指示一些程序中的不可达状态,比如从一个无限循环中退出。
// A toy implementation of cube root using Newton's method.
func CubeRoot(x float64) float64 {
z := x/3 // Arbitrary initial value
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// A million iterations has not converged; something is wrong.
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
以上仅仅提供一个应用的示例,在实际的库设计中,应尽量避免使用panic
。如果程序错误可以以某种方式掩盖或是绕过,那么最好还是继续执行而不是让整个程序终止。不过还是有一些反例的,比方说,如果库历程确实没有办法正确完成其初始化过程,那么触发panic
退出可能就是一种更加合理的方式。
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
对于一些隐式的运行时错误,如切片索引越界、类型断言错误等情形下,panic
方法就会被调用,它将立刻中断当前函数的执行,并展开当前Goroutine的调用栈,依次执行之前注册的defer函数。当栈展开操作达到该Goroutine栈顶端时,程序将终止。但这时仍然可以使用Go的内建recover
方法重新获得Goroutine的控制权,并将程序恢复到正常执行的状态。
调用recover
方法会终止栈展开操作并返回之前传递给panic
方法的那个参数。由于在栈展开过程中,只有defer型函数会被执行,因此recover
的调用必须置于defer函数内才有效。
在下面的示例应用中,调用recover
方法会终止server中失败的那个Goroutine,但server中其它的Goroutine将继续执行,不受影响。
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
在这里例子中,如果do(work)
调用发生了panic,则其结果将被记录且发生错误的那个Goroutine将干净的退出,不会干扰其他Goroutine。你不需要在defer指示的闭包中做别的操作,仅需调用recover
方法,它将帮你搞定一切。
只有直接在defer函数中调用recover
方法,才会返回非nil
的值,因此defer函数的代码可以调用那些本身使用了panic
和recover
的库函数而不会引发错误。还用上面的那个例子说明:safelyDo
里的defer函数在调用recover
之前可能调用了一个日志记录函数,而日志记录程序的执行将不受panic状态的影响。
有了错误恢复的模式,do
函数及其调用的代码可以通过调用panic
方法,以一种很干净的方式从错误状态中恢复。我们可以使用该特性为那些复杂的软件实现更加简洁的错误处理代码。让我们来看下面这个例子,它是regexp
包的一个简化版本,它通过调用panic
并传递一个局部错误类型来报告“解析错误”(Parse Error)。下面的代码包括了Error
类型定义,error
处理方法以及Compile
函数:
// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
return string(e)
}
// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
// doParse will panic if there is a parse error.
defer func() {
if e := recover(); e != nil {
regexp = nil // Clear return value.
err = e.(Error) // Will re-panic if not a parse error.
}
}()
return regexp.doParse(str), nil
}
如果doParse
方法触发panic,错误恢复代码会将返回值置为nil
—因为defer函数可以修改命名的返回值变量;然后,错误恢复代码会对返回的错误类型进行类型断言,判断其是否属于Error
类型。如果类型断言失败,则会引发运行时错误,并继续进行栈展开,最后终止程序 —— 这个过程将不再会被中断。类型检查失败可能意味着程序中还有其他部分触发了panic,如果某处存在索引越界访问等,因此,即使我们已经使用了panic
和recover
机制来处理解析错误,程序依然会异常终止。
有了上面的错误处理过程,调用error
方法(由于它是一个类型的绑定的方法,因而即使与内建类型error
同名,也不会带来什么问题,甚至是一直更加自然的用法)使得“解析错误”的报告更加方便,无需费心去考虑手工处理栈展开过程的复杂问题。
if pos == 0 {
re.error("'*' illegal at start of expression")
}
上面这种模式的妙处在于,它完全被封装在模块的内部,Parse
方法将其内部对panic
的调用隐藏在error
之中;而不会将panics
信息暴露给外部使用者。这是一个设计良好且值得学习的编程技巧。
顺便说一下,当确实有错误发生时,我们习惯采取的“重新触发panic”(re-panic)的方法会改变panic的值。但新旧错误信息都会出现在崩溃 报告中,引发错误的原始点仍然可以找到。所以,通常这种简单的重新触发panic的机制就足够了—所有这些错误最终导致了程序的崩溃—但是如果只想显示最 初的错误信息的话,你就需要稍微多写一些代码来过滤掉那些由重新触发引入的多余信息。这个功能就留给读者自己去实现吧!