23 函数:怎么让函数更简洁健壮?

你好,我是Tony Bai。

在上一节中,我们开始学习函数设计的相关知识,学习了如何基于Go函数的多返回值机制进行函数错误值构造方式的设计,你还记得那几种错误值构造与处理的策略吗?当然,良好的函数设计不仅仅要包含错误设计,函数的健壮性与简洁优雅也是我们在函数设计过程中要考虑的问题。

健壮的函数意味着,无论调用者如何使用你的函数,你的函数都能以合理的方式处理调用者的任何输入,并给调用者返回预设的、清晰的错误值。即便你的函数发生内部异常,函数也会尽力从异常中恢复,尽可能地不让异常蔓延到整个程序。而简洁优雅,则意味着函数的实现易读、易理解、更易维护,同时简洁也意味着统计意义上的更少的bug。

这一节课,我们就将继续我们的函数设计之旅,聚焦在健壮与简洁这两方面,我们需要重点关注的内容。

我们先从健壮性开始。

健壮性的“三不要”原则

函数的健壮性设计包括很多方面,首先就有最基本的“三不要”原则,我们简单来分析一下。

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

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

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

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

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

这里,我们先要确定一个认知:异常不是错误。错误是可预期的,也是经常会发生的,我们有对应的公开错误码和错误处理预案,但异常却是少见的、意料之外的。通常意义上的异常,指的是硬件异常、操作系统异常、语言运行时异常,还有更大可能是代码中潜在bug导致的异常,比如代码中出现了以0作为分母,或者是数组越界访问等情况。

虽然异常发生是“小众事件”,但是我们不能假定异常不会发生。所以,函数设计时,我们就需要根据函数的角色和使用场景,考虑是否要在函数内设置异常捕捉和恢复的环节。

在这三条健壮性设计原则中,做到前两条是相对容易的,也没有太多技巧可言。但对第三条异常的处理,很多初学者拿捏不好。所以在这里,我们就重点说一下Go函数的异常处理设计

认识Go语言中的异常:panic

不同编程语言表示异常(Exception)这个概念的语法都不相同。在Go语言中,异常这个概念由panic表示。一些教程或文章会把它译为恐慌,我这里依旧选择不译,保留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

我们再根据前面对panicking过程的诠释,理解一下这个例子。

这里,程序从入口函数main开始依次调用了foo、bar函数,在bar函数中,代码在调用zoo函数之前调用了panic函数触发了异常。那示例的panicking过程就从这开始了。bar函数调用panic函数之后,它自身的执行就此停止了,所以我们也没有看到代码继续进入zoo函数执行。并且,bar函数没有捕捉这个panic,这样这个panic就会沿着函数调用栈向上走,来到了bar函数的调用者foo函数中。

从foo函数的视角来看,这就好比将它对bar函数的调用,换成了对panic函数的调用一样。这样一来,foo函数的执行也被停止了。由于foo函数也没有捕捉panic,于是panic继续沿着函数调用栈向上走,来到了foo函数的调用者main函数中。

同理,从main函数的视角来看,这就好比将它对foo函数的调用,换成了对panic函数的调用一样。结果就是,main函数的执行也被终止了,于是整个程序异常退出,日志”exit main”也没有得到输出的机会。

不过,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")
}

在更新版的bar函数中,我们在一个defer匿名函数中调用recover函数对panic进行了捕捉。recover是Go内置的专门用于恢复panic的函数,它必须被放在一个defer函数中才能生效。如果recover捕捉到panic,它就会返回以panic的具体内容为错误上下文信息的错误值。如果没有panic发生,那么recover将返回nil。而且,如果panic被recover捕捉到,panic引发的panicking过程就会停止。

关于defer函数的内容我们等会还会详细讲。此刻你只需要知道,无论bar函数正常执行结束,还是因panic异常终止,在那之前设置成功的defer函数都会得到执行就可以了。

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

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

我们可以看到main函数终于得以“善终”。那这个过程中究竟发生了什么呢?

在更新后的代码中,当bar函数调用panic函数触发异常后,bar函数的执行就会被中断。但这一次,在代码执行流回到bar函数调用者之前,bar函数中的、在panic之前就已经被设置成功的derfer函数就会被执行。这个匿名函数会调用recover把刚刚触发的panic恢复,这样,panic还没等沿着函数栈向上走,就被消除了。

所以,这个时候,从foo函数的视角来看,bar函数与正常返回没有什么差别。foo函数依旧继续向下执行,直至main函数成功返回。这样,这个程序的panic“危机”就解除了。

面对有如此行为特点的panic,我们应该如何应对呢?是不是在所有Go函数或方法中,我们都要用defer函数来捕捉和恢复panic呢?

如何应对panic?

其实大可不必。

一来,这样做会徒增开发人员函数实现时的心智负担。二来,很多函数非常简单,根本不会出现panic情况,我们增加panic捕获和恢复,反倒会增加函数的复杂性。同时,defer函数也不是“免费”的,也有带来性能开销(这个我们后面会讲解)。

那么,日常情况下我们应该怎么做呢?我这里提供了三点经验,你可以参考一下。

第一点:评估程序对panic的忍受度

首先,我们应该知道一个事实:不同应用对异常引起的程序崩溃退出的忍受度是不一样的。比如,一个单次运行于控制台窗口中的命令行交互类程序(CLI),和一个常驻内存的后端HTTP服务器程序,对异常崩溃的忍受度就是不同的。

前者即便因异常崩溃,对用户来说也仅仅是再重新运行一次而已。但后者一旦崩溃,就很可能导致整个网站停止服务。所以,针对各种应用对panic忍受度的差异,我们采取的应对panic的策略也应该有不同。像后端HTTP服务器程序这样的任务关键系统,我们就需要在特定位置捕捉并恢复panic,以保证服务器整体的健壮度。在这方面,Go标准库中的http server就是一个典型的代表。

Go标准库提供的http server采用的是,每个客户端连接都使用一个单独的Goroutine进行处理的并发处理模型。也就是说,客户端一旦与http server连接成功,http server就会为这个连接新创建一个Goroutine,并在这Goroutine中执行对应连接(conn)的serve方法,来处理这条连接上的客户端请求。

前面提到了panic的“危害”时,我们说过,无论在哪个Goroutine中发生未被恢复的panic,整个程序都将崩溃退出。所以,为了保证处理某一个客户端连接的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)
        }
    }()
    ... ...
}

你可以看到,serve方法在一开始处就设置了defer函数,并在该函数中捕捉并恢复了可能出现的panic。这样,即便处理某个客户端连接的Goroutine出现panic,处理其他连接Goroutine以及http server自身都不会受到影响。

这种局部不要影响整体的异常处理策略,在很多并发程序中都有应用。并且,捕捉和恢复panic的位置通常都在子Goroutine的起始处,这样设置可以捕捉到后面代码中可能出现的所有panic,就像serve方法中那样。

第二点:提示潜在bug

有了对panic忍受度的评估,panic是不是也没有那么“恐怖”了呢?而且,我们甚至可以借助panic来帮助我们快速找到潜在bug。

C语言中有个很好用的辅助函数,断言(assert宏)。在使用C编写代码时,我们经常在一些代码执行路径上,使用断言来表达这段执行路径上某种条件一定为真的信心。断言为真,则程序处于正确运行状态,断言为否就是出现了意料之外的问题,而这个问题很可能就是一个潜在的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{}
}

我们看到,在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")
}

这段代码中,resolve方法的最后一行代码就相当于一个“代码逻辑不会走到这里”的断言。一旦触发“断言”,这很可能就是一个潜在bug。

我们也看到,去掉这行代码并不会对resolve方法的逻辑造成任何影响,但真正出现问题时,开发人员就缺少了“断言”潜在bug提醒的辅助支持了。在Go标准库中,大多数panic的使用都是充当类似断言的作用的

第三点:不要混淆异常与错误

在日常编码中,我经常会看到一些Go语言初学者,尤其是一些有过Java语言编程经验的程序员,会因为习惯了Java那种基于try-catch-finally的错误处理思维,而将Go panic当成Java的“checked exception”去用,这显然是混淆了Go中的异常与错误,这是Go错误处理的一种反模式。

查看Java标准类库,我们可以看到一些Java已预定义好的checked exception类,比较常见的有IOExceptionTimeoutExceptionEOFExceptionFileNotFoundException,等等。看到这里,你是不是觉得这些checked exception和我们上一节讲的“哨兵错误值”十分相似呢?。它们都是预定义好的、代表特定场景下的错误状态。

那Java的checked exception 和Go中的panic有啥差别呢?

Java的checked exception用于一些可预见的、常会发生的错误场景,比如,针对checked exception的所谓“异常处理”,就是针对这些场景的“错误处理预案”。也可以说对checked exception的使用、捕获、自定义等行为都是“有意而为之”的。

如果它非要和Go中的某种语法对应来看,它对应的也是Go的错误处理,也就是基于error值比较模型的错误处理。所以,Java中对checked exception处理的本质是错误处理,虽然它的名字用了带有“异常”的字样。

而Go中的panic呢,更接近于Java的RuntimeException+Error,而不是checked exception。我们前面提到过Java的checked exception是必须要被上层代码处理的,也就是要么捕获处理,要么重新抛给更上层。但是在Go中,我们通常会导入大量第三方包,而对于这些第三方包API中是否会引发panic,我们是不知道的。

因此上层代码,也就是API调用者根本不会去逐一了解API是否会引发panic,也没有义务去处理引发的panic。一旦你在编写的API中,像checked exception那样使用panic作为正常错误处理的手段,把引发的panic当作错误,那么你就会给你的API使用者带去大麻烦!因此,在Go中,作为API函数的作者,你一定不要将panic当作错误返回给API调用者

到这里,我们已经基本讲完了函数健壮性设计要注意的各种事项,你一定要注意我前面提到的这几点。接下来,我们进入下一部分,看看函数的简洁性设计。

使用defer简化函数实现

对函数设计来说,如何实现简洁的目标是一个大话题。你可以从通用的设计原则去谈,比如函数要遵守单一职责,职责单一的函数肯定要比担负多种职责的函数更简单。你也可以从函数实现的规模去谈,比如函数体的规模要小,尽量控制在80行代码之内等。

但我们这个是Go语言的课程,所以我们的角度更侧重于Go中是否有现成的语法元素,可以帮助我们简化Go函数的设计和实现。我也把答案剧透给你,有的,它就是defer

同样地,我们也用一个具体的例子来理解一下。日常开发中,我们经常会编写一些类似下面示例中的伪代码:

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
}

我们看到,这类代码的特点就是在函数中会申请一些资源,并在函数退出前释放或关闭这些资源,比如这里的互斥锁mu以及资源r1~r3就是这样。

函数的实现需要确保,无论函数的执行流是按预期顺利进行,还是出现错误,这些资源在函数退出时都要被及时、正确地释放。为此,我们需要尤为关注函数中的错误处理,在错误处理时不能遗漏对资源的释放。

但这样的要求,就导致我们在进行资源释放,尤其是有多个资源需要释放的时候,比如上面示例那样,会大大增加开发人员的心智负担。同时当待释放的资源个数较多时,整个代码逻辑就会变得十分复杂,程序可读性、健壮性也会随之下降。但即便如此,如果函数实现中的某段代码逻辑抛出panic,传统的错误处理机制依然没有办法捕获它并尝试从panic恢复。

Go语言引入defer的初衷,就是解决这些问题。那么,defer具体是怎么解决这些问题的呢?或者说,defer具体的运作机制是怎样的呢?

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

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

而且,无论是执行到函数体尾部返回,还是在某个错误处理分支显式return,又或是出现panic,已经存储到deferred函数栈中的函数,都会被调度执行。所以说,deferred函数是一个可以在任何情况下为函数进行收尾工作的好“伙伴”。

我们回到刚才的那个例子,如果我们把收尾工作挪到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() 
}

我们看到,使用defer后对函数实现逻辑的简化是显而易见的。而且,这里资源释放函数的defer注册动作,紧邻着资源申请成功的动作,这样成对出现的惯例就极大降低了遗漏资源释放的可能性,我们开发人员也不用再小心翼翼地在每个错误处理分支中检查是否遗漏了某个资源的释放动作。同时,代码的简化也意味代码可读性的提高,以及代码健壮度的增强。

那我们日常开发中使用defer,有没有什么要特别注意的呢?

defer使用的几个注意事项

大多数Gopher都喜欢defer,因为它不仅可以用来捕捉和恢复panic,还能让函数变得更简洁和健壮。但“工欲善其事,必先利其器“,一旦你要用defer,有几个关于defer使用的注意事项是你一定要提前了解清楚的,可以避免掉进一些不必要的“坑”。

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

这里,你要清楚,对于自定义的函数或方法,defer可以给与无条件的支持,但是对于有返回值的自定义函数或方法,返回值会在deferred函数被调度执行的时候被自动丢弃。

而且,Go语言中除了自定义函数/方法,还有Go语言内置的/预定义的函数,这里我给出了Go语言内置函数的完全列表:

Functions:
	append cap close complex copy delete imag len
	make new panic print println real recover

那么,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()
 }

运行这个示例代码,我们可以得到:

$go run defer1.go
# command-line-arguments
./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)

我们看到,Go编译器居然给出一组错误提示!

从这组错误提示中我们可以看到,append、cap、len、make、new、imag等内置函数都是不能直接作为deferred函数的,而close、copy、delete、print、recover等内置函数则可以直接被defer设置为deferred函数。

不过,对于那些不能直接作为deferred函数的内置函数,我们可以使用一个包裹它的匿名函数来间接满足要求,以append为例是这样的:

defer func() {
	_ = append(sl, 11)
}()

第二点:注意defer关键字后面表达式的求值时机

这里,你一定要牢记一点:defer关键字后面的表达式,是在将deferred函数注册到deferred函数栈的时候进行求值的

我们同样用一个典型的例子来说明一下defer后表达式的求值时机:

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。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带来的性能损耗

通过前面的分析,我们可以看到,defer让我们进行资源释放(如文件描述符、锁)的过程变得优雅很多,也不易出错。但在性能敏感的应用中,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倍左右,已经达到了几乎可以忽略不计的程度,我们可以放心使用。

小结

好了,今天的课讲到这里就结束了。在这一讲中,我们延续上一节的脉络,讲解了函数设计过程中应该考虑的、健壮性与简洁性方面的内容。

在函数健壮性方面,我给出了“三不要”原则,这三个原则你一定要记住。这里我们重点讲解了第三个原则:不要假定异常不会发生。借此,我们认识了Go语言中表示异常的panic,也学习了panic发生后的代码执行流程。基于panic的行为特征,我们给出了Go函数设计过程中应对panic的三点经验,这里你要注意,“评估程序对panic的忍受度”是我们选取应对panic措施的前提。

另外,对于来自像Java这样的、基于Exception进行错误处理的编程语言的Go初学者们,切记不要将panic与错误处理混淆。

接下来,我们又讲解了如何让函数实现更加简洁。简洁性对于函数来说意味着可读性更好,更易于理解,也有利于我们代码健壮性的提升。Go语言层面提供的defer机制可用于简化函数实现,尤其是在函数申请和释放资源个数较多的情况下。

如果我们要用好defer,前提就是要了解defer的运作机制,这里你要把握住两点:

  • 函数返回前,deferred函数是按照后入先出(LIFO)的顺序执行的;
  • defer关键字是在注册函数时对函数的参数进行求值的。

最后,在最新Go版本Go1.17中,使用defer带来的开销几乎可以忽略不计了,你可以放心使用。

思考题

defer是Gopher们都喜欢的语言机制,那么我想请你思考一下,除了捕捉panic、延迟释放资源外,我们日常编码中还有哪些使用defer的小技巧呢?一个小提示:你可以阅读一下Go标准库中关于defer的使用方法,看看是否能总结出一些小tips。

欢迎你把这节课分享给更多对Go语言函数感兴趣的朋友。我是Tony Bai,我们下节课见。