golang語言defer特性詳解.md

[TOC]

golang語言defer特性詳解

defer語句是go語言提供的一種用於註冊延遲調用的機制,它可以讓函數在當前函數執行完畢後執行,是go語言中一種很有用的特性。由於它使用起來簡單又方便,所以深得go語言開發者的歡迎。但是,真正想要使用好這一特性,卻得對這一特性深入理解它的原理,不然很容易掉進一些奇怪的坑裏還找不到原因。接下來,我們將一起來探討defer的使用方式,使用場景及一些容易產生誤解、混淆的規則。

什麼是defer

首先我們來看下defer語句的官方解釋

A "defer" statement invokes a function whose execution is deferred to the moment the surrounding function returns, either because the surrounding function executed a return statement, reached the end of its function body, or because the corresponding goroutine is panicking.

defer語句註冊了一個函數調用,這個調用會延遲到defer語句所在的函數執行完畢後執行,所謂執行完畢是指該函數執行了return語句、函數體已執行完最後一條語句或函數所在協程發生了恐慌。

如何使用defer

defer的使用方式很簡單,只需要在一個正常函數調用前面加上defer關鍵字即可(類似起協程時的go關鍵字),defer後面的函數調用會在defer所在的函數執行完畢後執行, 需要注意的是defer後面只能是函數調用,不能是表達式如 a++等。

 

//demo1 defer使用實例
func main() {
    fmt.Println("test1")
    defer fmt.Println("defer")
    fmt.Println("test2")
}

如demo1所示,我們先按正常函數調用調用了一行打印"test1",隨後用defer關鍵字編寫了一個延遲調用,打印“defer”, 最後再編寫了一個正常函數調用語句打印"test2",通過三個打印的輸出順序來簡單看一下defer的執行時機,運行結果如下圖所示

72569236.png

從demo1的運行結果我們可以看到,輸出順序是 "test1","test2","defer",因此,雖然fmt.Println(defer)函數調用語句出現的早於fmt.Println(test2),但由於其前面加了defer關鍵字,延遲到了最後test2打印執行完了才真正執行函數調用

爲什麼需要defer

我們在寫代碼的時候,經常會需要申請一些資源,比如申請可用數據庫連接、打開文件句柄、申請鎖、獲取可用網絡連接、申請內存空間等,這些資源都有一個共同點那就是在我們使用完之後都需要將其釋放掉,否則會造成內存泄漏或死鎖等其它問題。但由於開發人員一時疏忽忘記釋放資源是常有的事。此外,對於一些出口比較多的函數,需要在每個出口處都重複的編寫資源釋放代碼,既容易造成遺漏,也導致很多重複代碼,代碼不夠簡潔。

golang直接在語言層面提供defer關鍵字來解決上述問題。當我們成功申請了一項資源後,馬上使用defer語句註冊資源的釋放操作,在函數運行完畢後,就會自動執行這些操作釋放資源,可以極大程度的避免了對資源釋放的遺忘。此外,對於出口較多的函數,也無需在每個出口處再去編寫釋放資源的代碼。如示例demo2是一個打開文件獲得句柄處理文件再關閉文件的操作

 

    //demo2 通過緊跟着資源申請代碼的defer來保證資源得到釋放

    f, err := os.Open(filename)
    if err != nil {
        panic(err)
    }
    defer f.Close()  //釋放資源
    /*
        讀取和處理文件內容
    */

當打開文件成功獲得文件句柄資源後,馬上通過defer定義一個釋放資源的延遲調用f.Close(),避免後續忘記釋放資源,然後再編寫實際文件內容處理的代碼。

因此,在諸如打開連接/關閉連接;申請/釋放鎖;打開文件/關閉文件等成對出現的操作場景裏,defer會顯得格外方便和適用。

defer使用規則

Each time a “defer” statement executes, the function value and parameters to the call are evaluated as usual and saved anew but the actual function is not invoked. Instead, deferred functions are invoked immediately before the surrounding function returns, in the reverse order they were deferred. If a deferred function value evaluates to nil, execution panics when the function is invoked, not when the “defer” statement is executed.

上面這段是官方對defer使用機制的描述,大概意思是:每次defer語句執行時,會將defer定義的函數以及函數參數拷貝出來壓到一個專門的defer函數棧中,此時,函數並不會真正執行;當在外層函數退出之前,defer函數會按照定義的順序逆序執行;如果defer要執行的函數爲nil,會在函數退出之前defer函數真正執行時panic,而不是在defer語句執行時。

文字不長,理解起來似乎也不難,但是如果不深入瞭解,坑卻不少,總結下來大概有這麼幾個要注意的地方:

  • 一個函數中有多個defer時的運行順序
  • defer語句執行時的拷貝機制
  • defer如何影響函數返回值

此外,還有一些在這段話裏沒有講述的如defer與閉包、defer和panic等知識點。接下里,我們會挨個分析一下。

多個defer運行順序

當一個函數中含有多個defer語句時,函數return前會按defer定義的順序逆序執行,先進後出,也就是說最先註冊的defer函數調用最後執行。這一機制也好理解,後申請的資源有可能對前面申請的資源有依賴,如果將先申請的資源直接釋放掉了可能會導致後申請的資源釋放時各種異常。我們可以通過一個例子來驗證一下執行順序。

 

//demo3 多個defer執行順序
func main() {
    fmt.Println("test1")
    defer fmt.Println("defer1")
    fmt.Println("test2")
    defer fmt.Println("defer2")
    fmt.Println("test3")
}

運行結果如下圖所示:

59492720.png

當執行完函數正常執行語句test1,test2和test3的打印後,先執行了後定義的延遲調用fmt.Println("defer2"),最後執行了最先定義的延遲調用fmt.Println("defer1")

defer語句執行時的拷貝機制

經過前文的講述,我們知道,當我們執行defer語句時,函數調用不會馬上發生,語言層面會先把defer註冊的函數及變量拷貝到defer棧中保存,直到函數return前才執行defer中的函數調用。需要格外注意的是,這一拷貝拷貝的是那一刻函數的值和參數的值。註冊之後再修改函數值或參數值時,不會生效。接下來我們同樣用代碼說話:

 

//demo4 defer函數在defer語句執行那一刻就已經確定
func main() {
    test := func() {
        fmt.Println("I am function test1")
    }
    defer test()
    test = func() {
        fmt.Println("I am function test2")
    }
}

運行結果如下圖所示:

63360292.png

在demo4中,我們定義了一個函數變量test,然後將test調用添加爲一個延遲調用,隨後,修改test的值,defer雖然是最後運行,但是從結果中我們可以看到,執行的依舊是defer註冊時那一刻test對應的函數調用,也即是打印了test1的函數調用。
函數參數也是同樣的道理,接下來我們看一個函數參數的例子

 

//demo5 defer函數參數的值在註冊那一刻就已經確定
func f5() {
    x := 10
    defer func(a int) {
        fmt.Println(a)
    }(x)
    x++
}

64066195.png

可以看到,執行的輸出的是10而不是11。這也是同樣的道理,在使用defer註冊延遲函數那一刻,函數參數的值已經確定是10,後續x的變化不會影響到已經拷貝儲存好的函數參數。
到這裏,拷貝規則似乎很明確了,然而,我們再來看看以下兩個demo,讀者可以在看結果之前,自己先想一下輸出結果。

 

//demo6 defer 函數傳遞參數爲指針傳遞
func main() {
    x := 10
    defer func(a *int) {
        fmt.Println(*a)
    }(&x)
    x++
}

//demo7 defer 延遲函數爲閉包
func main() {
    x := 10
    defer func() {
        fmt.Println(x)
    }()
    x++
}

運行結果爲:
demo6:

66839319.png

demo7:

67030108.png

很多人可能會覺得應該輸出10,然而運行下來兩個程序最後輸出的結果都爲11而不是10,這是怎麼回事呢,不是說函數調用都已經在defer語句執行時就已經確認了嗎,怎麼最後輸出的結果都爲11而不是10呢,是這個規則是錯的嗎?其實並不是,我們來具體分析一下。

在demo6中,與demo5的區別在於,demo5傳遞的是一個int型的值,而demo6傳遞的是一個int型的指針,那我們按照拷貝規則想一下,在defer語句執行時,函數參數實際上傳遞的是一個指針,指向變量x的地址,當函數return之前defer定義的函數調用執行時,該指針指向的地址對應的值即x已經變成了11,所以打印11是正常的,也並沒有違反該拷貝規則。

demo7與demo5、demo6稍稍有些不一樣,demo7的x並不是通過函數調用的參數傳進去的,而是一個閉包,閉包裏的變量本質上是對上層變量的引用,因此最後的值就是引用的值,也可以說,defer函數閉包變量的值實際上到最後執行時,才最終確認是多少,因此與前面的拷貝規則也並不衝突,我們可以通過如下demo做個驗證,即將兩處x的地址打印出來看是否一致

 

//demo8 defer 閉包驗證
func main() {
    x := 10
    fmt.Printf("normal:%p\n", &x)
    defer func() {
        fmt.Printf("defer:%p\n", &x)
        fmt.Println(x)
    }()
    x++
}

運行結果如下:

68470038.png

地址一致,可證實閉包裏和外層引用了同一塊內存空間,外層的改變會影響到閉包裏面值的改變

defer和函數返回值

從官方話術中我們可以知道defer發生的時機是在函數執行return語句之後,既然在return之後,是不是意味着我們可以利用defer來對函數的返回值做一些事情呢,那麼什麼情況下defer會影響到函數返回值,什麼時候不會影響呢?

defer和非命名返回值

我們先來看以下兩個例子

 

//demo10 defer函數與非命名返回值之間的關係
func f10() int {
    x := 10
    defer func() {
        x++
    }()
    return x
}

//demo11 defer函數與非命名返回值之間的關係
func f11() *int {
    a := 10
    b := &a
    defer func() {
        *b++
    }()
    return b
}
func main() {
    fmt.Println("f10", f10())
    fmt.Println("f11", *f11())
}

我們可以推算一下結果,然後再實際運行一下看結果和自己所想是否一致,在本demo中,f10和f11執行的結果如下圖所示

65520288.png

f10中,延遲函數的調用並沒有影響到返回值,f11中,延遲函數的調用成功"影響"到了返回值, 這個怎麼來理解呢。其實我們可以對函數返回進行"拆解","拆解"後的代碼如下所示:

 

//demo10_1 defer函數與非命名返回值之間的關係, return拆解
func f10_1() int {
    x := 10
    defer func() {
        x++
    }()
    //return x => 拆解
    _result := x
    return _result //實際返回的是_result的值,因此defer中修改x的值對返回值沒有影響
}

//demo11_1 defer函數與返回值之間的關係, return拆解
func f11_1() *int {
    a := 10
    b := &a
    defer func() {
        *b++
    }()
    //return b => 拆解
    _result := b
    return _result //執行defer函數調用*b++, 修改了b指向的內存空間的值,實際返回的是result指針
}

注:拆解成這樣只是爲了方便理解,拆解後的代碼會更加清晰,函數返回值的變化也更加直觀,當我們無法判斷時,就可以將return操作一分爲二,一部分是計算返回值,一部分是真正的返回,再去判斷就不容易出錯了。各部分執行順序如下

  • 計算返回值
  • 執行defer函數調用
  • 函數返回第一步中計算的返回值

因此,實際上在這種模式中,defer無法實際影響到函數的返回值,對於f11中,函數返回的指針的值並沒有變化,受影響的只是該指針指向的區域對應的值,可以說時間接上改變了返回值,跟普通函數傳入指針的做法沒什麼區別。我們可以通過直接在defer函數調用中,改變b指針指向來證實這一規則。

 

//demo12 defer函數與非命名返回值之間的關係
func f12() *int {
    a := 10
    b := &a
    fmt.Println("b", b)
    defer func() {
        c := 12
        b = &c
        fmt.Println("defer", b)
    }()
    return b
}

69055407.png

從輸出結果可以看到,雖然defer中把b的指針重新指向了值爲12的c的地址,但是最終返回值並未改變。其實這一特性,與前文講述的defer的特性很像,函數內容都是在return/defer語句執行那一刻就已經確定,延遲函數調用並不會改變返回值/參數值/函數值

defer和命名返回值

那麼,defer就真的無法影響函數的返回值了嗎?其實也不然。在go語言中,一個函數返回返回值有兩種形式,除了前面講的那種之外,還有一種返回形式叫命名返回值,那麼,在命名返回值中,defer會是什麼效果呢。我們依舊通過代碼來看一下。

 

//demo13 defer函數與命名返回值之間的關係
func f13() (result int) {
    defer func() {
        result++
    }()
    return 10
}

//demo14 defer函數與命名返回值之間的關係
func f14() (result int) {
    result = 10
    defer func() {
        result++
    }()
    return result
}

//demo15 defer函數與命名返回值之間的關係
func f15() (b *int) {
    a := 10
    b = &a
    fmt.Println("b", b)
    defer func() {
        c := 12
        b = &c
        fmt.Println("defer", b)
    }()
    return
}

func main() {
    fmt.Println("f13", f13())
    fmt.Println("f14", f14())
    t := f15()
    fmt.Println("f15", *t, t)
}

執行結果如下圖所示:

69933562.png

從結果可以看到,三個示例中返回值都被defer函數調用成功修改,我們同樣可以通過return拆解來理解這一現象。在前面的非命名函數中,最後一步我們可以拆解成有一個專門儲存函數返回值的臨時變量,最終函數返回的是該變量的值,因此defer函數對原有返回值的修改無效,但是在命名返回方式中,最終函數返回的就是命名返回變量的值,因此,對該命名返回變量的修改會影響到最終的函數返回值

defer和recover

在go語言中,當程序發生異常時,一般我們可以選擇直接panic來讓程序停止運行,但很多時候,在程序異常停止前,我們希望做一些“掃尾工作”。此外,對於服務端程序來說,很多異常情況是可以容忍程序繼續執行的,並不希望程序因此宕掉,此時我們可能更希望捕獲異常然後通過異常的類型來判斷是否需要將程序從異常中恢復。因此,go語言提供了recover函數來進行panic捕獲。由於程序任何位置都可能發生恐慌,因此,作爲函數退出必定執行的defer延遲調用裏,是最適合捕獲panic的位置(我們前面提到,defer延遲調用執行的時機之一就是發生panic時),所以,在go語言的設計裏,recover只會在defer中生效,且此時defer延遲調用必須是匿名函數,defer+recover起到了很多語言裏面try...catch...的效果。同樣來看一個例子

 

//demo16 defer與recover
func f16() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("catch err:", err)
        }
    }()
    panic(errors.New("TEST"))
}

func main() {
    f16()
    fmt.Println("I am OK.")
}

如果f16中沒有異常捕獲,panic會導致整個程序直接退出,fmt.Println("I am OK.")這一語句將無法執行,當我們在defer中將入了recover異常捕獲後,執行結果如下圖所示

71774279.png

程序成功捕獲異常並從異常中恢復成功的繼續往下執行打印出了"I am OK."。最後有興趣的讀者可以再試想以下,如果defer中發生異常,又會發生什麼事呢?這個由於不算defer的知識點,我們就不在此驗證了。

總結

在本文中,我們主要通過一些示例探討了如下一些defer的特性

  • defer本質上是註冊了一個延時函數,當defer語句所在上下文函數執行完畢後再進行延遲函數的實際調用

  • defer函數及對應參數在defer語句執行時就已經確定,只不過將函數執行延後

  • 當存在多個defer時,依照defer語句執行的先後順序,逆序進行延遲函數調用

  • defer和閉包一起用時,閉包變量的值在函數調用執行時才最終確定

  • 對於非命名返回值函數,defer無法修改返回值,但對於命名返回值函數,可以通過defer來修改函數的返回值,因此,當我們想通過defer來靈活操作函數返回值時,可使用命名返回值方式

  • defer + recover 有點類似於其它語言的try…catch,recover只在defer延遲函數調用裏才能生效

defer是go語言中極其實用又方便的特性,使用好了可以使程序更加安全,讓代碼簡潔又優雅,但前提是對其本身的特性掌握透徹。只要我們對本文中這些規則都理解清楚了,相信可以在defer的使用上更加得心應手。



作者:木鳥飛魚
鏈接:https://www.jianshu.com/p/57acdbc8b30a
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

發佈了67 篇原創文章 · 獲贊 27 · 訪問量 36萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章