Go 2.0 设计草案摘要

前段时间 Go 官方宣布了2.0大版本的更新,并发布设计草案,对有关错误处理和泛型更改的设计进行公开讨论。这篇文章就围绕草案提出的三个主题分别进行描述和总结。

错误处理

Go 被人诟病的一个地方在于它冗长重复的错误处理代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return err
}
defer r.Close()

w, err := os.Create(dst)
if err != nil {
return err
}
defer w.Close()

if _, err := io.Copy(w, r); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
}

一个普通的文件拷贝函数,错误检查相关的代码行数是功能的两到三倍,这在使用其他语言的程序员看来是荒谬的,Go 官方在草案中也提到,为了让程序员重视错误处理而设计的现有错误处理方案,反而因为冗长而重复的写法更使人容易忽略。因此在 Go 2中,官方希望减少错误检查的代码量的同时保留显式处理的方式,于是他们引入了 checkhandle 关键字。

上面的代码改写后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func CopyFile(src, dst string) error {
handle err { // handler A
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 { // handler B
w.Close()
os.Remove(dst)
}

check io.Copy(w, r)
check w.Close()
return nil
}

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 语句,在命名返回参数的函数里会把其他参数的当前值一并返回,否则将其他参数的零值返回。

至于为什么不命名为 trycatch,官方的解释是 check 的作用是检查错误值,用 try err 会比较奇怪,另外 handle 也会比 catch 更符合语境。

错误值

1.x版本的 Go 的错误类型比较简单,就是对字符串做一个简单的封装,所有对错误的描述都只能限定在一个字符串内表达:

1
2
3
4
5
6
7
8
9
10
11
12
func New(text string) error {
return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
s string
}

func (e *errorString) Error() string {
return e.s
}

Go 官方推荐为原始错误信息添加上下文,因此当多层调用发生错误时往往得到的结果是这样子:

1
write users database: call myserver.Method: dial myserver:3333: open /etc/resolv.conf: permission denied

这已经是最优雅的例子,在实际开发中,往往无法保证所有程序员都能编写清晰完整的错误相关描述,并且缺乏堆栈和位置信息,定位问题时常让人感到头疼。现在社区已经有许多实现自定义错误类型的第三方包,对 Go 原生的错误类型进行拓展来一定程度上解决问题。官方推荐的做法是对错误类型进行封装,这样能在保留底层错误的同时添加其他信息,但需要牺牲的代价是不能对封装后的错误值进行简单的相等判断,类型断言也会失效。因此,设计草案中官方原生提供了两个函数:errors.Is() 和 errors.As(),用于自定义类型的相等判断和类型断言:

1
2
3
4
5
// instead of err == io.ErrUnexpectedEOF
if errors.Is(err, io.ErrUnexpectedEOF) { ... }

// instead of pe, ok := err.(*os.PathError)
if pe, ok := errors.As(*os.PathError)(err); ok { ... pe.Path ... }

底层实现也不难理解,errors 包实现了一个 Unwrap 方法,能检查错误类型的封装链,具体实现设计草案里有详细说明。

另外一个改进的地方是错误值的打印输出,errors 包提供了 Format API 和 Print API,相较于之前简单的字符串打印,新的设计允许打印更多的细节,包括堆栈追踪和其他相关信息,并且能保证一致的顺序和格式输出。现在可以生成格式如下的输出:

1
2
3
4
5
6
7
8
9
write users database:
/path/to/database.go:111
--- call myserver.Method:
/path/to/grpc.go:222
--- dial myserver:3333:
/path/to/net/dial.go:333
--- open /etc/resolv.conf:
/path/to/os/open.go:444
--- permission denied

无论堆栈深度或其他上下文如何,错误生成花费的成本是固定的,这一点和1.x版本保持一致。

泛型

Go 另外一个被人诟病的地方就是缺乏泛型,1.x版本内可以通过 interface{} 和类型断言实现参数多态,但这样需要对每个类型编写重复的代码;官方也曾对泛型的加入表明态度,一方面是他们觉得现有方案在运行时的复杂性不能让人满意,另一方面他们也确实成功脱离泛型实现了一些底层的数据结构,这些使得在 Go 中加入泛型“并不是一件急迫的事”。

我们以获取哈希表的键数组为例,1.x的写法如下:

1
2
func Keys(m map[int]string) []int {...}
keys := Keys(map[int]string{1:"one", 2: "two"})

示例代码用于获取一个键类型为 int,值类型为 string 的哈希表的键数组,如果所有传入参数的类型都是我们预期的,这没问题,但如果现在需要传入一个键类型为 string 的哈希表,编译器就会报类型错误。解决这个问题,你必须另外编写一个传入参数类型为 map[string]string 的函数,或在保留同一个函数的基础上,将传入参数的类型改为 interface{},然后在函数内部通过类型断言识别类型,再分别编写代码。更糟糕的情况是,由于哈希表的值类型可以是任意的,你会为本来并不需要关心的值类型编写大量的重复代码,只为了通过函数的类型检查。

ok,现在到了2.0版本官方终于给出了他们对于泛型的设计,针对我们的问题新的设计提供了一种解决方案:

1
2
func Keys(type K, V)(m map[K]V) []K {...}
keys := Keys(int, string)(map[int]string{1:"one", 2: "two"})

新的代码保留了 Go 一贯简洁易懂的风格,但这个例子比较特别,因为它并不需要对参数的类型做约束,我们看看另一个例子,获取数组值的总和:

1
2
3
4
5
6
7
8
9
10
11
contract Addable(t T) {
t + t
}

func Sum(type T Addable)(x []T) T {
var total T
for _, v := range x {
total += v
}
return total
}

在 Go 里并不是所有类型的值都能使用操作符‘+’进行相加,因此为了解决泛型约束的问题,设计草案引入了 contract 关键字,一个 contract 相当于一个函数体,包含了类型必须支持的操作,编译器能在编译期进行检查,确保所有泛型值的操作都满足约束,避免运行时错误。

最后

这篇文章的内容都是基于 Go 2设计草案,正式版发布后可能会有改动,以正式版为准。