learn-tech/专栏/TonyBai·Go语言第一课/23函数:怎么让函数更简洁健壮?.md
2024-10-16 06:37:41 +08:00

31 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        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类比较常见的有IOException、TimeoutException、EOFException、FileNotFoundException等等。看到这里你是不是觉得这些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
}

// 使用r1r2, 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()

// 使用r1r2, 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我们下节课见。