前段时间 Go 官方宣布了2.0大版本的更新,并发布设计草案,对有关错误处理和泛型更改的设计进行公开讨论。这篇文章就围绕草案提出的三个主题分别进行描述和总结。
错误处理
Go 被人诟病的一个地方在于它冗长重复的错误处理代码:
1 | func CopyFile(src, dst string) error { |
一个普通的文件拷贝函数,错误检查相关的代码行数是功能的两到三倍,这在使用其他语言的程序员看来是荒谬的,Go 官方在草案中也提到,为了让程序员重视错误处理而设计的现有错误处理方案,反而因为冗长而重复的写法更使人容易忽略。因此在 Go 2中,官方希望减少错误检查的代码量的同时保留显式处理的方式,于是他们引入了 check
和 handle
关键字。
上面的代码改写后如下:
1 | func CopyFile(src, dst string) error { |
check 是对原有 if err != nil
的一种简写,实际上,check 用于检查 error 类型的值或返回参数中以 error 类型结尾的函数。check 在检查 error 类型的返回参数完成后,会将这个参数删除并将剩余的参数返回。
handle 关键字提供了一种集中式的错误处理,所有被 check 捕获的错误,会提交到代码上层最近的一个 handle 块进行处理,若这个代码块中不包含 return 语句,则错误继续交给上一层的 handle 块进行处理,直至执行其中一个 handle 块里的 return 语句。比如上面的代码中在 io.Copy(w, r)
处产生的错误,会先交给 handler B 进行处理,执行完后会往上提交给 handler A 执行 return 语句。需要注意的是 handle 块是存在作用域的,具体交给哪个错误处理链函数在编译期决定而不是运行期。
所有最后一个返回参数为 error 类型的函数都会隐式实现 default handler
,会把接收的 error 类型的值分配给最后一个结果并执行 return 语句,在命名返回参数的函数里会把其他参数的当前值一并返回,否则将其他参数的零值返回。
至于为什么不命名为 try
和 catch
,官方的解释是 check 的作用是检查错误值,用 try err 会比较奇怪,另外 handle 也会比 catch 更符合语境。
错误值
1.x版本的 Go 的错误类型比较简单,就是对字符串做一个简单的封装,所有对错误的描述都只能限定在一个字符串内表达:
1 | func New(text string) error { |
Go 官方推荐为原始错误信息添加上下文,因此当多层调用发生错误时往往得到的结果是这样子:
1 | write users database: call myserver.Method: dial myserver:3333: open /etc/resolv.conf: permission denied |
这已经是最优雅的例子,在实际开发中,往往无法保证所有程序员都能编写清晰完整的错误相关描述,并且缺乏堆栈和位置信息,定位问题时常让人感到头疼。现在社区已经有许多实现自定义错误类型的第三方包,对 Go 原生的错误类型进行拓展来一定程度上解决问题。官方推荐的做法是对错误类型进行封装,这样能在保留底层错误的同时添加其他信息,但需要牺牲的代价是不能对封装后的错误值进行简单的相等判断,类型断言也会失效。因此,设计草案中官方原生提供了两个函数:errors.Is() 和 errors.As(),用于自定义类型的相等判断和类型断言:
1 | // instead of err == io.ErrUnexpectedEOF |
底层实现也不难理解,errors 包实现了一个 Unwrap
方法,能检查错误类型的封装链,具体实现设计草案里有详细说明。
另外一个改进的地方是错误值的打印输出,errors 包提供了 Format API 和 Print API,相较于之前简单的字符串打印,新的设计允许打印更多的细节,包括堆栈追踪和其他相关信息,并且能保证一致的顺序和格式输出。现在可以生成格式如下的输出:
1 | write users database: |
无论堆栈深度或其他上下文如何,错误生成花费的成本是固定的,这一点和1.x版本保持一致。
泛型
Go 另外一个被人诟病的地方就是缺乏泛型,1.x版本内可以通过 interface{}
和类型断言实现参数多态,但这样需要对每个类型编写重复的代码;官方也曾对泛型的加入表明态度,一方面是他们觉得现有方案在运行时的复杂性不能让人满意,另一方面他们也确实成功脱离泛型实现了一些底层的数据结构,这些使得在 Go 中加入泛型“并不是一件急迫的事”。
我们以获取哈希表的键数组为例,1.x的写法如下:
1 | func Keys(m map[int]string) []int {...} |
示例代码用于获取一个键类型为 int,值类型为 string 的哈希表的键数组,如果所有传入参数的类型都是我们预期的,这没问题,但如果现在需要传入一个键类型为 string 的哈希表,编译器就会报类型错误。解决这个问题,你必须另外编写一个传入参数类型为 map[string]string
的函数,或在保留同一个函数的基础上,将传入参数的类型改为 interface{}
,然后在函数内部通过类型断言识别类型,再分别编写代码。更糟糕的情况是,由于哈希表的值类型可以是任意的,你会为本来并不需要关心的值类型编写大量的重复代码,只为了通过函数的类型检查。
ok,现在到了2.0版本官方终于给出了他们对于泛型的设计,针对我们的问题新的设计提供了一种解决方案:
1 | func Keys(type K, V)(m map[K]V) []K {...} |
新的代码保留了 Go 一贯简洁易懂的风格,但这个例子比较特别,因为它并不需要对参数的类型做约束,我们看看另一个例子,获取数组值的总和:
1 | contract Addable(t T) { |
在 Go 里并不是所有类型的值都能使用操作符‘+’进行相加,因此为了解决泛型约束的问题,设计草案引入了 contract
关键字,一个 contract 相当于一个函数体,包含了类型必须支持的操作,编译器能在编译期进行检查,确保所有泛型值的操作都满足约束,避免运行时错误。
最后
这篇文章的内容都是基于 Go 2设计草案,正式版发布后可能会有改动,以正式版为准。