go基础14-函数:怎么让函数更简洁健壮?

148 阅读7分钟

健壮性的“三不要”原则

原则一:不要相信任何外部输入的参数。

函数的使用者可能是任何人,这些人在使用函数之前可能都没有阅读过任何手册或文档,他们会向函数传入你意想不到的参数。因此,为了保证函数的健壮性,函数需要对所有输入的参数进行合法性的检查。一旦发现问题,立即终止函数的执行,返回预设的错误值。

原则二:不要忽略任何一个错误。

在我们的函数实现中,也会调用标准库或第三方包提供的函数或方法。对于这些调用,我们不能假定它一定会成功,我们一定要显式地检查这些调用返回的错误值。一旦发现错误,要及时终止函数执行,防止错误继续传播。

原则三:不要假定异常不会发生。

认识 Go 语言中的异常:panic

panic 指的是 Go 程序在运行时出现的一个异常情况。如果异常出现了,但没有被捕获并恢复,Go 程序的执行就会被终止,即便出现异常的位置不在主 Goroutine 中也会这样。

在 Go 中,panic 主要有两类来源,一类是来自 Go 运行时,另一类则是 Go 开发人员通过 panic 函数主动触发的。无论是哪种,一旦 panic 被触发,后续 Go 程序的执行过程都是一样的,这个过程被 Go 语言称为 panicking。

Go 官方文档以手工调用 panic 函数触发 panic 为例,对 panicking 这个过程进行了诠释:当函数 F 调用 panic 函数时,函数 F 的执行将停止。不过,函数 F 中已进行求值的 deferred 函数都会得到正常执行,执行完这些 deferred 函数后,函数 F 才会把控制权返还给其调用者。

对于函数 F 的调用者而言,函数 F 之后的行为就如同调用者调用的函数是 panic 一样,该panicking过程将继续在栈上进行下去,直到当前 Goroutine 中的所有函数都返回为止,然后 Go 程序将崩溃退出。

我们用一个例子来更直观地解释一下 panicking 这个过程:

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")
}

上面这个例子中,从 Go 应用入口开始,函数的调用次序依次为main -> foo -> bar -> zoo。在 bar 函数中,我们调用 panic 函数手动触发了 panic。

call main
call foo
call bar
panic: panic occurs in bar

不过,Go 也提供了捕捉 panic 并恢复程序正常执行秩序的方法,我们可以通过 recover 函数来实现这一点。

我们继续用上面这个例子分析,在触发 panic 的 bar 函数中,对 panic 进行捕捉并恢复,我们直接来看恢复后,整个程序的执行情况是什么样的。这里,我们只列出了变更后的 bar 函数代码,其他函数代码并没有改变:

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")
}

我们执行更新后的程序,得到如下结果:

call main
call foo
call bar
recover the panic: panic occurs in bar
exit foo
exit main

使用 defer 简化函数实现

defer 是 Go 语言提供的一种延迟调用机制,defer 的运作离不开函数。怎么理解呢?这句话至少有以下两点含义:

  • 在 Go 中,只有在函数(和方法)内部才能使用 defer;
  • defer 关键字后面只能接函数(或方法),这些函数被称为 deferred 函数。defer 将它们注册到其所在 Goroutine 中,用于存放 deferred 函数的栈数据结构中,这些 deferred 函数将在执行 defer 的函数退出前,按后进先出(LIFO)的顺序被程序调度执行(如下图所示)。

image.png

defer 使用的几个注意事项

第一点:明确哪些函数可以作为 deferred 函数

append、cap、len、make、new、imag 等内置函数都是不能直接作为 deferred 函数的,而 close、copy、delete、print、recover 等内置函数则可以直接被 defer 设置为 deferred 函数。不过,对于那些不能直接作为 deferred 函数的内置函数,我们可以使用一个包裹它的匿名函数来间接满足要求,

第二点:注意 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。foo1 中 defer 后面直接用的是 fmt.Println 函数,每当 defer 将 fmt.Println 注册到 deferred 函数栈的时候,都会对 Println 后面的参数进行求值。根据上述代码逻辑,依次压入 deferred 函数栈的函数是:

fmt.Println(0)
fmt.Println(1)
fmt.Println(2)
fmt.Println(3)

因此,当 foo1 返回后,deferred 函数被调度执行时,上述压入栈的 deferred 函数将以 LIFO 次序出栈执行,这时的输出的结果为:

3
2
1
0

然后我们再看 foo2。foo2 中 defer 后面接的是一个带有一个参数的匿名函数。每当 defer 将匿名函数注册到 deferred 函数栈的时候,都会对该匿名函数的参数进行求值。根据上述代码逻辑,依次压入 deferred 函数栈的函数是:

func(0)
func(1)
func(2)
func(3)

因此,当 foo2 返回后,deferred 函数被调度执行时,上述压入栈的 deferred 函数将以 LIFO 次序出栈执行,因此输出的结果为:

3
2
1
0

最后我们来看 foo3。foo3 中 defer 后面接的是一个不带参数的匿名函数。根据上述代码逻辑,依次压入 deferred 函数栈的函数是:

func()
func()
func()
func()

所以,当 foo3 返回后,deferred 函数被调度执行时,上述压入栈的 deferred 函数将以 LIFO 次序出栈执行。匿名函数会以闭包的方式访问外围函数的变量 i,并通过 Println 输出 i 的值,此时 i 的值为 4,因此 foo3 的输出结果为:

4
4
4
4

通过这些例子,我们可以看到,无论以何种形式将函数注册到 defer 中,deferred 函数的参数值都是在注册的时候进行求值的。

第三点:知晓 defer 带来的性能损耗

这里,我们用一个性能基准测试(Benchmark),直观地看看 defer 究竟会带来多少性能损耗。基于 Go 工具链,我们可以很方便地为 Go 源码写一个性能基准测试,只需将代码放在以“_test.go”为后缀的源文件中,然后利用 testing 包提供的“框架”就可以了,我们看下面代码:

// 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()
    }
}

这个基准测试包含了两个测试用例,分别是 BenchmarkFooWithDefer 和 BenchmarkFooWithoutDefer。前者测量的是带有 defer 的函数执行的性能,后者测量的是不带有 defer 的函数的执行的性能。在 Go 1.13 前的版本中,defer 带来的开销还是很大的。我们先用 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.13 版本开始,Go 核心团队对 defer 性能进行了多次优化,到现在的 Go 1.17 版本,defer 的开销已经足够小了。我们看看使用 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 倍左右,已经达到了几乎可以忽略不计的程度,我们可以放心使用。

此文章为3月Day14学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。