高效的 Go 编程 Effective Go - 错误
免费 - Sunrise - - 浏览量: 0 - 文章来源
库函数很多时候必须将错误信息返回给函数的调用者。如前所述,Go允许函数可以有多个返回值的特性,使得函数的调用者在得到正常返回值的同时,可以获取到更为详细的错误信息。对库函数的设计者来说,一种推荐的做法是使用特性来提供详细的异常信息。 例如, os.Open
在异常时并不仅仅返回一个 nil
指针, 它同时会返回一个错误值,用于描述是什么原因导致了异常的发生。
按照约定,错误的类型通常为 error
,这是一个内置的简单接口。
type error interface {
Error() string
}
库的开发者可以自由地用更丰富的模型实现这个接口,这样不仅可以看到错误,还可以提供一些上下文。如前所述,除了通常的*os.File
返回值外,os.Open
还返回一个错误值。如果文件被成功打开,错误将为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 // 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
}
}
// 一百万次迭代并未收敛,事情出错了。
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")
}
}
recover
当 panic
被调用后(包括不明确的运行时错误,例如切片越界访问或类型断言失败), 程序将立刻终止当前函数的执行,并开始回溯Go协程的栈,运行任何被推迟的函数。 若回溯到达Go协程栈的顶端,程序就会终止。不过我们可以用内建的 recover
函数来重新或来取回Go协程的控制权限并使其恢复正常执行。
调用 recover
将停止回溯过程,并返回传入 panic
的实参。 由于在回溯时只有被推迟函数中的代码在运行,因此 recover
只能在被推迟的函数中才有效。
recover
的一个应用就是在服务器中终止失败的Go协程而无需杀死其它正在执行的Go协程。
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协程会被干净利落地结束,不会干扰到其它Go协程。我们无需在推迟的闭包中做任何事情, 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 会触发 panic
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。 这里就将这个练习留给读者了。