错误

未匹配的标注

库函数很多时候必须将错误信息返回给函数的调用者。如前所述,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()
}

PathErrorError 会生成如下错误信息:

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, 因此被推迟的代码能够调用本身使用了 panicrecover 的库函数而不会失败。例如在 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 来检查它。若它没有, 类型断言将会失败,此时会产生运行时错误,并继续栈的回溯,仿佛一切从未中断过一样。 该检查意味着若发生了一些像索引越界之类的意外,那么即便我们使用了 panicrecover 来处理解析错误,代码仍然会失败。

通过适当的错误处理,error 方法(由于它是个绑定到具体类型的方法, 因此即便它与内建的 error 类型名字相同也没有关系) 能让报告解析错误变得更容易,而无需手动处理回溯的解析栈:

if pos == 0 {
    re.error("'*' illegal at start of expression")
}

尽管这种模式很有用,但它应当仅在包内使用。Parse 会将其内部的 panic 调用转为 error 值,它并不会向调用者暴露出 panic。这是个值得遵守的良好规则。

顺便一提,这种重新触发 Panic 的惯用法会在产生实际错误时改变Panic的值。 然而,不管是原始的还是新的错误都会在崩溃报告中显示,因此问题的根源仍然是可见的。 这种简单的重新触发Panic的模型已经够用了,毕竟他只是一次崩溃。 但若你只想显示原始的值,也可以多写一点代码来过滤掉不需要的问题,然后用原始值再次触发Panic。 这里就将这个练习留给读者了。

本文章首发在 LearnKu.com 网站上。

本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://learnku.com/docs/effective-go/20...

译文地址:https://learnku.com/docs/effective-go/20...

上一篇 下一篇
贡献者:5
讨论数量: 0
发起讨论 只看当前版本


暂无话题~