错误
库例程通常需要向调用者返回某种类型的错误提示。之前提到过,Go 语言的多值返回特性, 使得它在返回常规的值时,还能轻松地返回详细的错误描述。使用这个特性来提供详细的错误信息是一种良好的风格。 例如,我们稍后会看到, os.Open 在失败时不仅返回一个 nil 指针,还返回一个详细描述错误的 error 值。
按照约定,错误的类型通常为 error,这是一个内建的简单接口。
type error interface {
Error() string
}
库的编写者通过更丰富的底层模型可以轻松实现这个接口,这样不仅能看见错误,还能提供一些上下文。前已述及,除了通常的 *os.File 返回值, os.Open 还返回一个 error 值。若该文件被成功打开, error 值就是 nil ,而如果出了问题,该值就是一个 os.PathError。
// PathError 记录一个错误以及产生该错误的路径和操作。
type PathError struct {
Op string // "open"、"unlink" 等等。
Path string // 相关联的文件。
Err error // 由系统调用返回。
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
PathError 的 Error 会生成如下错误信息:
open /etc/passwx: 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() // 恢复一些空间。
continue
}
return
}
这里的第二条 if 是另一种 类型断言。若它失败, ok 将为 false,而 e 则为 nil. 若它成功,ok 将为 true,这意味着该错误属于 *os.PathError 类型,而 e 能够检测关于该错误的更多信息。
Panic
向调用者报告错误的一般方式就是将 error 作为额外的值返回。 标准的 Read 方法就是个众所周知的实例,它返回一个字节计数和一个 error。但如果错误是不可恢复的呢?有时程序就是不能继续运行。
为此,我们提供了内建的 panic 函数,它会产生一个运行时错误并终止程序 (但请继续看下一节)。该函数接受一个任意类型的实参(一般为字符串),并在程序终止时打印。 它还能表明发生了意料之外的事情,比如从无限循环中退出了。
// 用牛顿法计算立方根的一个玩具实现。
func CubeRoot(x float64) float64 {
z := x/3 // 任意初始值
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// 一百万次迭代并未收敛,事情出错了。
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 的栈,运行任何被推迟的函数。 若回溯到达 goroutine 栈的顶端,程序就会终止。不过我们可以用内建的 recover 函数来重新取回 goroutine 的控制权限并使其恢复正常执行。
调用 recover 将停止回溯过程,并返回传入 panic 的实参。 由于在回溯时只有被推迟函数中的代码在运行,因此 recover 只能在被推迟的函数中才有效。
recover 的一个应用就是在服务器中终止失败的 goroutine 而无需杀死其它正在执行的 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,其结果就会被记录, 而该 Go 程会被干净利落地结束,不会干扰到其它 goroutine。我们无需在推迟的闭包中做任何事情, recover 会处理好这一切。
由于直接从被推迟函数中调用 recover 时不会返回 nil, 因此被推迟的代码能够调用本身使用了 panic 和 recover 的库函数而不会失败。例如在 safelyDo 中,被推迟的函数可能在调用 recover 前先调用记录函数,而该记录函数应当不受 Panic 状态的代码的影响。
通过恰当地使用恢复模式,do 函数(及其调用的任何代码)可通过调用 panic 来避免更坏的结果。我们可以利用这种思想来简化复杂软件中的错误处理。 让我们看看 regexp 包的理想化版本,它会以局部的错误类型调用 panic 来报告解析错误。以下是一个 error 类型的 Error 方法和一个 Compile 函数的定义:
// Error 是解析错误的类型,它满足 error 接口。
type Error string
func (e Error) Error() string {
return string(e)
}
// error 是 *Regexp 的方法,它通过用一个 Error 触发 Panic 来报告解析错误。
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// Compile 返回该正则表达式解析后的表示。
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 // 清理返回值。
err = e.(Error) // 若它不是解析错误,将重新触发 Panic。
}
}()
return regexp.doParse(str), nil
}
若 doParse 触发了 Panic,恢复块会将返回值设为 nil —被推迟的函数能够修改已命名的返回值。在 err 的赋值过程中, 我们将通过断言它是否拥有局部类型 Error 来检查它。若它没有, 类型断言将会失败,此时会产生运行时错误,并继续栈的回溯,仿佛一切从未中断过一样。 该检查意味着若发生了一些像索引越界之类的意外,那么即便我们使用了 panic 和 recover 来处理解析错误,代码仍然会失败。
通过适当的错误处理,error 方法(由于它是个绑定到具体类型的方法, 因此即便它与内建的 error 类型名字相同也没有关系) 能让报告解析错误变得更容易,而无需手动处理回溯的解析栈:
if pos == 0 {
re.error("'*' illegal at start of expression")
}
尽管这种模式很有用,但它应当仅在包内使用。Parse 会将其内部的 panic 调用转为 error 值,它并不会向调用者暴露出 panic。这是个值得遵守的良好规则。
顺便一提,这种重新触发Panic的惯用法会在产生实际错误时改变Panic的值。 然而,不管是原始的还是新的错误都会在崩溃报告中显示,因此问题的根源仍然是可见的。 这种简单的重新触发Panic的模型已经够用了,毕竟他只是一次崩溃。 但若你只想显示原始的值,也可以多写一点代码来过滤掉不需要的问题,然后用原始值再次触发Panic。 这里就将这个练习留给读者了。