Hacking Limbo

Reading / Coding / Hacking

Go 语言的错误处理机制

这段时间在学习 Go 语言,接触到一些比较“另类”的语言特性,其中一个就是它的错误处理机制,跟我以往所知的都不太一样。在我正儿八经地使用过的编程语言(C / Python / Ruby / JavaScript)里面,处理程序错误的方式大致有两种:1. 返回特殊值 2. 抛出异常。

C 语言属于第一种。函数调用出错时会返回特殊值,并有可能根据场景设置某个全局的(一般是 thread-local 的)错误代码。比如 printf 函数正常情况下返回的是已输出的字符数,如果出错,就返回负值。Linux (或其他遵循 POSIX 标准的 UNIX 系统) system calls 的函数传递错误信息的主要途径是设置全局的 errno 变量,这个变量保存的是当前线程最后一次发生的错误所对应的错误代码(详情见 man errno)。Windows API 中也有类似的函数,叫 GetLastError,也是 thread-local 的。

这种风格的错误处理方式可以看作是一种历史遗留的存在,一方面是由于 C 语言本身比较古老,在错误处理的语言特性上不会有太大革新;另一方面主流的操作系统(Linux / UNIX / Windows)提供的系统调用大量使用了这种方式,也不会有太大改变。此种风格的优点是语言设计者不需要在错误处理方面花太多心思,而缺点是函数返回错误所能携带的信息很有限,往往需要额外的文档解释。另外,大多数语言都不会强制程序员检查返回值,这些错误很容易被忽略,而每个函数调用都检查返回值的话会令代码看上去非常啰嗦(所以我们很少去检查 printf 是不是返回了负值)。

Python, Ruby 和 JavaScript 都提供了异常处理机制,属于第二种。异常处理包括两部分,一是抛出,二是捕捉。语言的运行时环境以及用户代码都有可能抛出预定义的或用户自行定义的异常,在 Python 和 Ruby 中以类实例的形式存在,在 JavaScript 中可以是任意值。异常被抛出后,当前代码会终止执行,而异常会传递给上层的函数调用者——上层如果不处理这个异常,就继续往上抛,直到虚拟机捕捉到异常并终止整个程序的执行为止。异常的捕捉可以在当前代码块或外层代码块(如上层函数)中进行,其效果是:1. 阻止该异常的传递(也支持重新抛出) 2. 继续异常发生点之后的代码的执行(Ruby 提供的 retry 语句更强大,会重新执行触发异常的代码)。

很多主流的编程语言都采用了这种风格,尽管某些细节上存在差异。其优点是程序在标示错误时能提供更丰富的上下文,包括运行时环境和函数调用堆栈等信息,方便程序调试。代码看上去也会简洁很多,除了显式捕捉一些特别重要的(如 Rails 里的 ActiveRecord::RecordInvalid)之外,其他不需要特殊处理的异常,要么在最顶层添加通用的处理(比如在 Web 应用中显示 500 页面),要么直接终止程序运行并打印 backtrace 信息(试试 python -m SimpleHTTPServer abc)。它的缺点没那么容易察觉,但也确实存在:程序中任何位置都有可能抛出异常,未捕捉的异常会终止程序的运行,而并不是所有程序都适合在顶层做通用的处理,所以写代码时总是要考虑两个问题:1. 我调用的代码会不会抛出异常 2. 我是否需要在当前代码中捕捉异常。对于大型项目的代码维护来说,这两个问题并没有那么容易确定,是个很大的思维负担(记住这一点,下文会有提及)。

初学 Go 语言时,以为它只是单纯继承了 C 语言的错误处理风格——在 Go 程序里到处都可以看到这样的代码:

f, err = os.Open(filename)
if err != nil {
    fmt.Println(err)
}

读了 golang.org 上两篇关于错误处理的文章之后,才知道 error 接口的存在(我还以为它只是个内置的数据类型),并明白了 panicrecover 的使用场景。

日常使用时,如果只是想返回一个简单的错误信息(比如 access denied),只需return errors.New("xxx: access denied"),稍微复杂一点的可以用 errors.Errorf输出格式化的字符串。这里有一个编码约定,即错误信息本身要标明错误发生的上下文,比如 os.Open 返回的错误信息会是 open /etc/passwd: permission denied 而不仅仅是 permission denied

在另外一些使用场景里,函数调用者会希望获取错误发生的更具体的信息,单靠字符串来传递这些信息显然不可行,替代的做法是定义一个新类型,实现它的 Error() 方法。这个方法会被 log.Fatal()fmt.Println 之类的函数调用,把它当成一个普通的字符串,而需要读取额外信息的调用者,则用 type assertion 将 error 类型转换为实际的错误类型(代码来自 Error Handling and Go):

type SyntaxError struct {
    msg    string // description of error
    Offset int64  // error occurred after reading Offset bytes
}

func (e *SyntaxError) Error() string { return e.msg }

// decoding json data
if err := dec.Decode(&val); err != nil {
    if serr, ok := err.(*json.SyntaxError); ok {
        line, col := findLine(f, serr.Offset)
        return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
    }
    return err
}

按照 Defer, Panic, and Recover这篇文章所说,Go 程序的一个惯例是对外的 API 尽量使用显式的 error 返回值,而内部错误处理可以根据需要使用 panicrecover 来简化流程。

关于两者的区别,Go 语言的核心开发者 Russ Cox 的解释是:在函数中如果出现了可预料的错误(比如网络连接失败,文件不存在,非法的 format 字符,等等),那就应该返回 error("if your function is in any way likely to fail, it should return an error")。如果是不可预料的(不应该出现的,“无法挽回的”)错误,比如数组越界(应该视作程序员的失误而不是程序运行环境的异常),那就是 panic. 按照 Go 的默认行为,"An unexpected panic like this is a serious bug in the program and by default kills it",而如果你想避免程序因为这种异常退出,可以用 recover 机制。

在函数内部调用 panic 会立即终止当前函数的执行,由当前调用栈逐层返回,一直到最顶层的 main 函数或是被某一层的 recover 捕捉。panic 可以接收值作为参数,而这个值会由 recover 返回给调用者。在这个语义上 panicrecover 跟传统的 try...catch 很相似,但它们有一个很刻意的限制:panic 被调用时会立即终止当前函数,开始执行由 defer 指定的清理函数,然后返回上一层调用栈,这意味着 recover 只有在 deferred function (不知道怎么翻译)中才能起作用,不能像 try...catch 那样在任意位置捕捉异常。

以上所述的机制与 C 语言的错误处理风格相比,只改进了错误信息传递这部分,而代码冗余以及容易忽略错误的问题却没有解决,网上吐槽这个问题的人不止我一个,在 Why I’m not leaving Python for Go 这篇文章中,作者 ubershmekel 说由于它没有“现代的”错误处理机制,他暂时不考虑使用 Go 语言("without modern error handling – I’m not going")。

Russ Cox 在 Google Plus 的一个 post 里回应了这篇文章。他解释了为什么 Go 要用函数返回值来表示错误而不是像目前的主流语言一样抛出异常。Russ Cox 说,在大型程序中判断是否要捕捉异常非常痛苦,影响开发效率(因为任何一个漏网的异常都有可能导致程序终止,降低了容错能力)。虽然 Go 语言的设计者也想让 Go 程序尽量简洁,但如果这种简洁是以增加大型程序维护成本为代价的话,就得不偿失了,这可以看作是语言设计上的一个折衷:

The error returns used by Go are admittedly inconvenient to callers, but they also make the possibility of the error explicit both in the program and in the type system.

While simple programs might want to just print an error and exit in all cases, it is common for more sophisticated programs to react differently depending on where the error came from, in which case the try + catch approach is actually more verbose than explicit error results.

It is true that your 10-line Python program is probably more verbose in Go. Go's primary target, however, is not 10-line programs.

这篇 post 消除了我一半的困惑,但是函数调用返回的错误容易被忽略这个问题要怎么解决呢?Russ Cox 没说,ubershmekel 说大概可以用代码静态分析检测出来,不知道是否可行,需要进一步的研究。

参考资料