不一样的go语言-error
前言
go语言的error处理方式,在目前流行的编程语言中属于刺头。似乎天生就是用来有别于他人标记。TIOBE排行榜全十除了C语言,无一例外是try catch的阵营。而排在go之前的语言除了C与perl外,同样是try catch的忠实拥趸。那么go的设计者为什么要这么做呢,只是为博人眼球吗?
关于error
在go语言的定义中,error不一定表示一个错误,它也可以表示其他信息。在标准库中可以看到如文件尾io.EOF的定义,而第三方库中亦有如jdbc驱动中的sql.ErrNoRows的使用,由此可见,在go中error完全可以看作是一种特殊的返回值,以帮助调用方获知被调用函数的执行情况而决定后续的代码逻辑。error的设计,按照流行的说辞就是让程序员面向异常编程,脑海里要时刻记得处理error。虽然这并没有什么不对,毕竟现在也提倡防御性编程,但就因为eerror的笼统且go支持多返回值,导致错误处理代码严重影响正常的业务逻辑,多返回值带来的判断组合成倍的增长。我认为go的error设计并没有摆脱C语言的影子,甚至只是换了个方式来表达而已,仍然是值模式。此时的go 2.0草案已着手解决这个问题,只是终究还是回归了类try catch模式。
//当前go 1的错误处理方式
f, err := os.Open(fileName)
if err != nil {
// handle error here
}
//使用GO 2草案中的错误处理方式
func CopyFile(src, dst string) error {
handle err {//类try-catch模式
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
r := check os.Open(src)
defer r.Close()
w := check os.Create(dst)
handle err {
w.Close()
os.Remove(dst) // (only if a check fails)
}
check io.Copy(w, r)
check w.Close()
return nil
}
最佳实践
吐槽归吐槽,但还是要回归主题。go的异常处理由error、panic、recover组成,可在作用上等同于java的Exception、throw、catch/finally。异常处理是任何一门编程语言都需要考虑如何处理的事情,特别是对于想要成为系统性、项目性的语言。go的异常处理的特殊性使得其在诞生之初,就被吐槽得最多,因而也被研究实践得最多。不同的人使用同一种语言敲出的代码可以有完全不一样的感觉,自然也就有良莠之分。所以语言永远都只是一种工具,如何使用工具及如何用好工具才是关键。就像用java抛出Exception,当throws的细分异常超过一个时,你会立刻马上想到抛出一个大Exception。这就是工具的滥用,相信设计者最初的目的决不在此。java异常的严谨性在于可以让调用者清楚地看到将要调用的方法的可控性,无异常抛出声明的方法可以放心调用,不需要处理异常;反正则可以知道被调用的方法会抛出哪些细分的异常,然后在调用的地方小心地处理这些异常(虽然java的try-catch语法很啰嗦,也很丑)。当然这一切都建立在所有的团队成员都深刻地认识到异常的最佳实践的前提下。go也不例外,也有一些最佳实践。
java将异常分为错误(error)、未检视异常(unchecked exception)、已检视异常(checked exception),未检视、已检视异常这两个概念即使多年的老鸟也依然分不清楚,包括我在内。其实简单地划分就是当前可妥善处理或当前无能为力。已检视异常属于当前可妥善处理的范围,即
程序可对这类异常提供分支、恢复等处理方案的异常,比如FileNotFoundException,解决方案可以是读取备选文件或者跳过;而error、未检视异常则属于当前无能为力的异常(在写代码的时候不知道或者意料不到),这类异常通常是在运行时才能发现且通常是程序员的错或者依赖的运行环境异常(如操作系统、JVM)。而go在规范上则不区分,并且建议要妥善处理每一个方法返回的异常,哪怕是只打印一行日志。一个典型的go处理异常的例子如下:
package main
import (
"fmt"
"github.com/go-redis/redis"
)
func main() {
defer func() {
//尝试处理panic
//注意: recover只在defer函数中有效
if p := recover(); p != nil {
err, ok := p.(error)
if ok {
//此处只是简单的打印error
fmt.Printf("panic, %s\n", err.Error())
}
//打印异常堆栈
fmt.Printf("panic stack: %s\n", string(debug.Stack()))
}
}()
//连接redis
client := redis.NewClient(&redis.Options{
Addr: "localhost",
DB: 0,
MinIdleConns: 10,
})
pong, err := client.Ping().Result()
if err != nil {
//异常发生, 程序中断执行,转而执行defer声明的函数
panic(err)
}
fmt.Printf("连接redis成功, ping: %s\n", pong)
}
从上述代码中可以看到,如果调用了很多可能发生错误的方法,整个代码视界内,将会出现一堆的if err!= nil这样的语句,然后里面就一个打印错误日志的语句。总之让人有点不爽,但习惯了其实也还好。那么对于自己编写的方法,应该如何定义error呢?
最佳实践 | 方式 | 说明 |
---|---|---|
位置 | 最后 | 返回值列表最后一个值且不要返回多个error |
统一 | 同类型的错误,消息体保持一致 | 使用error.New创建error或者使用pkg/errors包 |
二值 | bool | 布尔逻辑类的方法使用bool代替error |
释放资源 | 在操作资源时,定义defer函数用于资源释放 | defer函数会被按先后顺序入栈,执行时按相反的顺序出栈执行 |
非法分支 | 按业务逻辑不应该出现的分支可使用panic中止执行,并交由recover处理 | - |
非法参数 | 对于Must类开头的函数,使用panic | 这种设计可以避免调用方处理error,但调用方需小心处理panic,运行的程序不应该因为panic崩溃 |
我认为异常处理的关键在于如何告知调用方你写的函数出错了,哪里出错了,出了什么错,然后交由调用方处理(语言给调用方提供工具中断程序或恢复异常等方式)。在这个指导方针下,我觉得java显然对调用方更为友好,从方法声明就可以知道方法会抛出哪些异常,不需要查看源代码,然后一个大代码块的try,catch各个细分的Exception(不分青红皂白直接catch Exception的程序员不是好程序员)。
除此之外,go有很多增加魔力值的点,比如type、defer、类型推断、闭包、channel、无括号、cgo,不足之处在于标准库太弱、没有泛型、切片与数组易混。此外,go没有函数重载,虽然这并不是什么大问题。