函数的健壮和简洁
# 4. 函数的健壮和简洁
健壮的函数意味着,无论调用者如何使用你的函数,你的函数都能以合理的方式处理调用者的任何输入,并给调用者返回预设的、清晰的错误值。即便你的函数发生内部异常,函数也会尽力从异常中恢复,尽可能地不让异常蔓延到整个程序。
而简洁优雅则意味着,函数的实现易读、易理解、更易维护,同时简洁也意味着统计意义上的更少的 bug。
# 4.1 健壮性的“三不要”原则
原则一:不要相信任何外部输入的参数。
为了保证函数的健壮性,函数需要对所有输入的参数进行合法性的检查。一旦发现问题,立即终止函数的执行,返回预设的错误值。
原则二:不要忽略任何一个错误。
在函数实现中,也会调用标准库或第三方包提供的函数或方法。对于这些调用,不能假定它一定会成功,一定要显式地检查这些调用返回的错误值。一旦发现错误,要及时终止函数执行,防止错误继续传播。
原则三:不要假定异常不会发生。
通常意义上的异常,指的是硬件异常、操作系统异常、语言运行时异常,还有更大可能是代码中潜在 bug 导致的异常,比如代码中出现了以 0 作为分母,或者是数组越界访问等情况。所以,函数设计时,就需要根据函数的角色和使用场景,考虑是否要在函数内设置异常捕捉和恢复的环节。
# 4.2 Go 函数的异常处理设计(panic)
不同编程语言表示异常(Exception)这个概念的语法都不相同。在 Go 语言中,异常这个概念由 panic 表示。
panic 指的是 Go 程序在运行时出现的一个异常情况。如果异常出现了,但没有被捕获并恢复,Go 程序的执行就会被终止,即便出现异常的位置不在主 Goroutine 中也会这样。
在 Go 中,panic 主要有两类来源:
一类来自 Go 运行时,
一类则是 Go 开发人员通过 panic 函数主动触发的。
无论是哪种,一旦 panic 被触发,后续 Go 程序的执行过程都是一样的,这个过程被 Go 语言称为 panicking。
panicking 过程:
// 函数的调用次序依次为main -> foo -> bar -> zoo。在 bar 函数中,调用 panic 函数手动触发了 panic。
func foo() {
println("call foo")
bar()
println("exit foo")
}
func bar() {
println("call bar")
panic("panic occurs in bar")
zoo()
println("exit bar")
}
func zoo() {
println("call zoo")
println("exit zoo")
}
func main() {
println("call main")
foo()
println("exit main")
}
/*
call main
call foo
call bar
panic: panic occurs in bar
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
程序从入口函数 main 开始依次调用了 foo、bar 函数,在 bar 函数中,代码在调用 zoo 函数之前调用了 panic 函数触发了异常。那示例的 panicking 过程就从这开始了。bar 函数调用 panic 函数之后,它自身的执行就此停止了,所以也没有看到代码继续进入 zoo 函数执行。并且,bar 函数没有捕捉这个 panic,这样这个 panic 就会沿着函数调用栈向上走,来到了 bar 函数的调用者 foo 函数中。
Go 捕捉 panic 并恢复程序正常执行秩序的方法,可以通过 recover 函数来实现。
func bar() {
defer func() {
if e := recover(); e != nil {
fmt.Println("recover the panic:", e)
}
}()
println("call bar")
panic("panic occurs in bar")
zoo()
println("exit bar")
}
// 更新bar函数后得到如下输出:
/*
call main
call foo
call bar
recover the panic: panic occurs in bar
exit foo
exit main
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在一个 defer 匿名函数中调用 recover 函数对 panic 进行了捕捉。recover 是 Go 内置的专门用于恢复 panic 的函数,它必须被放在一个 defer 函数中才能生效。如果 recover 捕捉到 panic,它就会返回以 panic 的具体内容为错误上下文信息的错误值。如果没有 panic 发生,那么 recover 将返回 nil。而且,如果 panic 被 recover 捕捉到,panic 引发的 panicking 过程就会停止。
其实不必专门应对 panic,一来,这样做会徒增开发人员函数实现时的心智负担。二来,很多函数非常简单,根本不会出现 panic 情况,我们增加 panic 捕获和恢复,反倒会增加函数的复杂性。同时,defer 函数也不是“免费”的,也有带来性能开销。
日常开发中应该做到以下三点:
第一点:评估程序对 panic 的忍受度
不同应用对异常引起的程序崩溃退出的忍受度是不一样的。针对各种应用对 panic 忍受度的差异,采取的应对 panic 的策略也应该有不同。
比如,一个单次运行于控制台窗口中的命令行交互类程序(CLI),和一个常驻内存的后端 HTTP 服务器程序,对异常崩溃的忍受度就是不同的。前者即便因异常崩溃,对用户来说也仅仅是再重新运行一次而已。但后者一旦崩溃,就很可能导致整个网站停止服务。
像后端 HTTP 服务器程序这样的任务关键系统,我们就需要在特定位置捕捉并恢复 panic,以保证服务器整体的健壮度。
Go 标准库中的 http server 就是一个典型的代表。Go 标准库提供的 http server 采用的是,每个客户端连接都使用一个单独的 Goroutine 进行处理的并发处理模型。也就是说,客户端一旦与 http server 连接成功,http server 就会为这个连接新创建一个 Goroutine,并在这 Goroutine 中执行对应连接(conn)的 serve 方法,来处理这条连接上的客户端请求。为了保证处理某一个客户端连接的 Goroutine 出现 panic 时,不影响到 http server 主 Goroutine 的运行,Go 标准库在 serve 方法中加入了对 panic 的捕捉与恢复,下面是 serve 方法的部分代码片段:
// $GOROOT/src/net/http/server.go // Serve a new connection. func (c *conn) serve(ctx context.Context) { c.remoteAddr = c.rwc.RemoteAddr().String() ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr()) defer func() { if err := recover(); err != nil && err != ErrAbortHandler { const size = 64 << 10 buf := make([]byte, size) buf = buf[:runtime.Stack(buf, false)] c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf) } if !c.hijacked() { c.close() c.setState(c.rwc, StateClosed, runHooks) } }() ... ... }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19serve 方法在一开始处就设置了 defer 函数,并在该函数中捕捉并恢复了可能出现的 panic。这样,即便处理某个客户端连接的 Goroutine 出现 panic,处理其他连接 Goroutine 以及 http server 自身都不会受到影响,局部不要影响整体。
第二点:提示潜在 bug
Go 语言标准库中并没有提供断言之类的辅助函数,但可以使用 panic,部分模拟断言对潜在 bug 的提示功能。
比如,标准库encoding/json包使用 panic 指示潜在 bug :
// $GOROOT/src/encoding/json/decode.go
... ...
//当一些本不该发生的事情导致我们结束处理时,phasePanicMsg将被用作panic消息,它可以指示JSON解码器中的bug,或者在解码器执行时还有其他代码正在修改数据切片。
const phasePanicMsg = "JSON decoder out of sync - data changing underfoot?"
func (d *decodeState) init(data []byte) *decodeState {
d.data = data
d.off = 0
d.savedError = nil
if d.errorContext != nil {
d.errorContext.Struct = nil
// Reuse the allocated space for the FieldStack slice.
d.errorContext.FieldStack = d.errorContext.FieldStack[:0]
}
return d
}
func (d *decodeState) valueQuoted() interface{} {
switch d.opcode {
default:
panic(phasePanicMsg)
case scanBeginArray, scanBeginObject:
d.skip()
d.scanNext()
case scanBeginLiteral:
v := d.literalInterface()
switch v.(type) {
case nil, string:
return v
}
}
return unquotedValue{}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
在valueQuoted这个方法中,如果程序执行流进入了 default 分支,那这个方法就会引发 panic,这个 panic 会提示开发人员:这里很可能是一个 bug。
在 json 包的 encode.go 中也有使用 panic 做潜在 bug 提示的例子:
// $GOROOT/src/encoding/json/encode.go
func (w *reflectWithString) resolve() error {
... ...
switch w.k.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
w.ks = strconv.FormatInt(w.k.Int(), 10)
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
w.ks = strconv.FormatUint(w.k.Uint(), 10)
return nil
}
panic("unexpected map key type")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
resolve方法的最后一行代码就相当于一个“代码逻辑不会走到这里”的断言。一旦触发“断言”,这很可能就是一个潜在 bug。
在 Go 标准库中,大多数 panic 的使用都是充当类似断言的作用的。
第三点:不要混淆异常与错误
在 Go 中,作为 API 函数的作者,一定不要将 panic 当作错误返回给 API 调用者。
# 4.3 简洁性设计(defer函数)
使用 defer 简化函数实现
defer 是 Go 语言提供的一种延迟调用机制,defer 的运作离不开函数。
- 在 Go 中,只有在函数(和方法)内部才能使用 defer;
- defer 关键字后面只能接函数(或方法),这些函数被称为 deferred 函数。defer 将它们注册到其所在 Goroutine 中,用于存放 deferred 函数的栈数据结构中,这些 deferred 函数将在执行 defer 的函数退出前,按后进先出(LIFO)的顺序被程序调度执行。
无论是执行到函数体尾部返回,还是在某个错误处理分支显式 return,又或是出现 panic,已经存储到 deferred 函数栈中的函数,都会被调度执行。所以说,deferred 函数是一个可以在任何情况下为函数进行收尾工作的好“伙伴”。
使用defer简化代码实现:
// 在函数中申请一些资源,并在函数退出前释放或关闭这些资源,比如互斥锁 mu 以及资源r1~r3
func doSomething() error {
var mu sync.Mutex
mu.Lock()
r1, err := OpenResource1()
if err != nil {
mu.Unlock()
return err
}
r2, err := OpenResource2()
if err != nil {
r1.Close()
mu.Unlock()
return err
}
r3, err := OpenResource3()
if err != nil {
r2.Close()
r1.Close()
mu.Unlock()
return err
}
// 使用r1,r2, r3
err = doWithResources()
if err != nil {
r3.Close()
r2.Close()
r1.Close()
mu.Unlock()
return err
}
r3.Close()
r2.Close()
r1.Close()
mu.Unlock()
return nil
}
// 如上,在进行资源释放,尤其是有多个资源需要释放的时候,会大大增加开发人员的心智负担。同时当待释放的资源个数较多时,整个代码逻辑就会变得十分复杂,程序可读性、健壮性也会随之下降。但即便如此,如果函数实现中的某段代码逻辑抛出 panic,传统的错误处理机制依然没有办法捕获它并尝试从 panic 恢复。
// 使用 deferred 函数改造
func doSomething() error {
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
r1, err := OpenResource1()
if err != nil {
return err
}
defer r1.Close()
r2, err := OpenResource2()
if err != nil {
return err
}
defer r2.Close()
r3, err := OpenResource3()
if err != nil {
return err
}
defer r3.Close()
// 使用r1,r2, r3
return doWithResources()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
资源释放函数的 defer 注册动作,紧邻着资源申请成功的动作,这样成对出现的惯例就极大降低了遗漏资源释放的可能性,开发人员也不用再小心翼翼地在每个错误处理分支中检查是否遗漏了某个资源的释放动作。同时,代码的简化也意味代码可读性的提高,以及代码健壮度的增强。
defer 注意事项:
第一点:明确哪些函数可以作为 deferred 函数。
对于自定义的函数或方法,defer 可以给与无条件的支持,但是对于有返回值的自定义函数或方法,返回值会在 deferred 函数被调度执行的时候被自动丢弃。
Go 语言内置函数的完全列表:
Functions:
append cap close complex copy delete imag len
make new panic print println real recover
2
3
测试Go 语言中的内置函数哪些能作为 deferred 函数:
// defer1.go
func bar() (int, int) {
return 1, 2
}
func foo() {
var c chan int
var sl []int
var m = make(map[string]int, 10)
m["item1"] = 1
m["item2"] = 2
var a = complex(1.0, -1.4)
var sl1 []int
defer bar()
defer append(sl, 11)
defer cap(sl)
defer close(c)
defer complex(2, -2)
defer copy(sl1, sl)
defer delete(m, "item2")
defer imag(a)
defer len(sl)
defer make([]int, 10)
defer new(*int)
defer panic(1)
defer print("hello, defer\n")
defer println("hello, defer")
defer real(a)
defer recover()
}
func main() {
foo()
}
/*
./defer1.go:17:2: defer discards result of append(sl, 11)
./defer1.go:18:2: defer discards result of cap(sl)
./defer1.go:20:2: defer discards result of complex(2, -2)
./defer1.go:23:2: defer discards result of imag(a)
./defer1.go:24:2: defer discards result of len(sl)
./defer1.go:25:2: defer discards result of make([]int, 10)
./defer1.go:26:2: defer discards result of new(*int)
./defer1.go:30:2: defer discards result of real(a)
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
- append、cap、len、make、new、imag 等内置函数都是不能直接作为 deferred 函数的;
- close、copy、delete、print、recover 等内置函数则可以直接被 defer 设置为 deferred 函数。
对于那些不能直接作为 deferred 函数的内置函数,可以使用一个包裹它的匿名函数来间接满足要求,以 append 为例:
defer func() {
_ = append(sl, 11)
}()
2
3
第二点:注意 defer 关键字后面表达式的求值时机
defer 关键字后面的表达式,是在将 deferred 函数注册到 deferred 函数栈的时候进行求值的。
func foo1() {
for i := 0; i <= 3; i++ {
defer fmt.Println(i)
}
}
func foo2() {
for i := 0; i <= 3; i++ {
defer func(n int) {
fmt.Println(n)
}(i)
}
}
func foo3() {
for i := 0; i <= 3; i++ {
defer func() {
fmt.Println(i)
}()
}
}
func main() {
fmt.Println("foo1 result:")
foo1()
fmt.Println("\nfoo2 result:")
foo2()
fmt.Println("\nfoo3 result:")
foo3()
}
// 分析 foo1、foo2 和 foo3 中 defer 后的表达式的求值时机。
// foo1依次压入 deferred 函数栈的函数是:
/*
fmt.Println(0)
fmt.Println(1)
fmt.Println(2)
fmt.Println(3)
*/
// 当 foo1 返回后,deferred 函数被调度执行时,上述压入栈的 deferred 函数将以 LIFO 次序出栈执行,这时的输出的结果为:
/*
3
2
1
0
*/
// foo2 中 defer 后面接的是一个带有一个参数的匿名函数。每当 defer 将匿名函数注册到 deferred 函数栈的时候,都会对该匿名函数的参数进行求值。依次压入 deferred 函数栈的函数是:
/*
func(0)
func(1)
func(2)
func(3)
*/
// 当 foo2 返回后,deferred 函数被调度执行时,上述压入栈的 deferred 函数将以 LIFO 次序出栈执行,因此输出的结果为:
/*
3
2
1
0
*/
// foo3 中 defer 后面接的是一个不带参数的匿名函数。依次压入 deferred 函数栈的函数是:
/*
func()
func()
func()
func()
*/
// 当 foo3 返回后,deferred 函数被调度执行时,上述压入栈的 deferred 函数将以 LIFO 次序出栈执行。匿名函数会以闭包的方式访问外围函数的变量 i,并通过 Println 输出 i 的值,此时 i 的值为 4,因此 foo3 的输出结果为:
/*
4
4
4
4
*/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
无论以何种形式将函数注册到 defer 中,deferred 函数的参数值都是在注册的时候进行求值的。
第三点:知晓 defer 带来的性能损耗
进行性能基准测试(Benchmark),可以直观地看看 defer 究竟会带来多少性能损耗。
基于 Go 工具链,可以很方便地为 Go 源码写一个性能基准测试,只需将代码放在以“_test.go”为后缀的源文件中,然后利用 testing 包提供的“框架”就可以完成:
// 包含两个测试用例,分别是BenchmarkFooWithDefer和 BenchmarkFooWithoutDefer。前者测量的是带有 defer 的函数执行的性能,后者测量的是不带有 defer 的函数的执行的性能。
// defer_test.go
package main
import "testing"
func sum(max int) int {
total := 0
for i := 0; i < max; i++ {
total += i
}
return total
}
func fooWithDefer() {
defer func() {
sum(10)
}()
}
func fooWithoutDefer() {
sum(10)
}
func BenchmarkFooWithDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
fooWithDefer()
}
}
func BenchmarkFooWithoutDefer(b *testing.B) {
for i := 0; i < b.N; i++ {
fooWithoutDefer()
}
}
// Go 1.12.7 版本测试结果:
$go test -bench . defer_test.go
goos: darwin
goarch: amd64
BenchmarkFooWithDefer-8 30000000 42.6 ns/op
BenchmarkFooWithoutDefer-8 300000000 5.44 ns/op
PASS
ok command-line-arguments 3.511s
// 结论:使用 defer 的函数的执行时间是没有使用 defer 函数的 8 倍左右。
// Go 1.17 版本测试结果:
$go test -bench . defer_test.go
goos: darwin
goarch: amd64
BenchmarkFooWithDefer-8 194593353 6.183 ns/op
BenchmarkFooWithoutDefer-8 284272650 4.259 ns/op
PASS
ok command-line-arguments 3.472s
// 带有 defer 的函数执行开销,仅是不带有 defer 的函数的执行开销的 1.45 倍左右,已经达到了几乎可以忽略不计的程度,可以放心使用。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54