作者 | 饒全成
責編 | 胡巍巍
defer是Go語言提供的一種用於註冊延遲調用的機制:讓函數或語句可以在當前函數執行完畢後(包括通過return正常結束或者panic導致的異常結束)執行。
深受Go開發者的歡迎,但一不小心就會掉進它的溫柔陷阱,只有深入理解它的原理,我們才能輕鬆避開,寫出漂亮穩健的代碼。
為了更好的閱讀體驗,按慣例我手動貼上文章目錄:
什麼是defer?
defer是Go語言提供的一種用於註冊延遲調用的機制:讓函數或語句可以在當前函數執行完畢後(包括通過return正常結束或者panic導致的異常結束)執行。
defer語句通常用於一些成對操作的場景:打開連接/關閉連接;加鎖/釋放鎖;打開文件/關閉文件等。
defer在一些需要回收資源的場景非常有用,可以很方便地在函數結束前做一些清理操作。在打開資源語句的下一行,直接一句defer就可以在函數返回前關閉資源,可謂相當優雅。
f, _ := os.Open("defer.txt")defer f.Close()
注意:以上代碼,忽略了err, 實際上應該先判斷是否出錯,如果出錯了,直接return. 接著再判斷 f是否為空,如果 f為空,就不能調用 f.Close()函數了,會直接panic的。
為什麼需要defer?
程式設計師在編程的時候,經常需要打開一些資源,比如資料庫連接、文件、鎖等,這些資源需要在用完之後釋放掉,否則會造成內存洩漏。
但是程式設計師都是人,是人就會犯錯。
因此經常有程式設計師忘記關閉這些資源。Golang直接在語言層面提供 defer關鍵字,在打開資源語句的下一行,就可以直接用 defer語句來註冊函數結束後執行關閉資源的操作。因為這樣一顆「小小」的語法糖,程式設計師忘寫關閉資源語句的情況就大大地減少了。
怎樣合理使用defer?
defer的使用其實非常簡單:
f,err := os.Open(filename)if err != nil {panic(err)}if f != nil {defer f.Close()}
在打開文件的語句附近,用defer語句關閉文件。這樣,在函數結束之前,會自動執行defer後面的語句來關閉文件。
當然,defer會有小小地延遲,對時間要求特別特別特別高的程序,可以避免使用它,其他一般忽略它帶來的延遲。
defer進階
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語句執行的時候,會把函數「壓棧」,函數參數會被拷貝下來;當外層函數(非代碼塊,如一個for循環)退出時,defer函數按照定義的逆序執行;如果defer執行的函數為nil, 那麼會在最終調用函數的產生panic.
defer語句並不會馬上執行,而是會進入一個棧,函數return前,會按先進後出的順序執行。也說是說最先被定義的defer語句最後執行。先進後出的原因是後面定義的函數可能會依賴前面的資源,自然要先執行;否則,如果前面先執行,那後面函數的依賴就沒有了。
在defer函數定義時,對外部變量的引用是有兩種方式的,分別是作為函數參數和作為閉包引用。作為函數參數,則在defer定義時就把值傳遞給defer,並被cache起來;作為閉包引用的話,則會在defer函數真正調用時根據整個上下文確定當前的值。
defer後面的語句在執行的時候,函數調用的參數會被保存起來,也就是複製了一份。真正執行的時候,實際上用到的是這個複製的變量,因此如果此變量是一個「值」,那麼就和定義的時候是一致的。如果此變量是一個「引用」,那麼就可能和定義的時候不一致。
舉個例子:
funcmain() {var whatever [3]struct{}for i := range whatever {deferfunc() { fmt.Println(i) }() }}
執行結果:
222
defer後面跟的是一個閉包(後面會講到),i是「引用」類型的變量,最後i的值為2, 因此最後列印了三個2.
有了上面的基礎,我們來檢驗一下成果:
type number intfunc(n number)print() { fmt.Println(n) }func(n *number)pprint() { fmt.Println(*n) }funcmain() {var n numberdefer n.print()defer n.pprint()deferfunc() { n.print() }()deferfunc() { n.pprint() }()n = 3}
執行結果是:
3330
第四個defer語句是閉包,引用外部函數的n, 最終結果是3; 第三個defer語句同第四個;第二個defer語句,n是引用,最終求值是3.第一個defer語句,對n直接求值,開始的時候n=0, 所以最後是0。
利用defer原理
有些情況下,我們會故意用到defer的先求值,再延遲調用的性質。想像這樣的場景:在一個函數裡,需要打開兩個文件進行合併操作,合併完後,在函數執行完後關閉打開的文件句柄。
funcmergeFile()error {f, _ := os.Open("file1.txt")if f != nil {deferfunc(f io.Closer) {if err := f.Close(); err != nil { fmt.Printf("defer close file1.txt err %v\n", err) } }(f) }// …… f, _ = os.Open("file2.txt")if f != nil {deferfunc(f io.Closer) {if err := f.Close(); err != nil { fmt.Printf("defer close file2.txt err %v\n", err) } }(f) }returnnil}
上面的代碼中就用到了defer的原理,defer函數定義的時候,參數就已經複製進去了,之後,真正執行close()函數的時候就剛好關閉的是正確的「文件」了,妙哉!可以想像一下如果不這樣將f當成函數參數傳遞進去的話,最後兩個語句關閉的就是同一個文件了,都是最後一個打開的文件。
不過在調用close()函數的時候,要注意一點:先判斷調用主體是否為空,否則會panic. 比如上面的代碼片段裡,先判斷 f不為空,才會調用 Close()函數,這樣最安全。
defer命令的拆解
如果defer像上面介紹地那樣簡單(其實也不簡單啦),這個世界就完美了。事情總是沒這麼簡單,defer用得不好,是會跳進很多坑的。
理解這些坑的關鍵是這條語句:
return xxx
上面這條語句經過編譯之後,變成了三條指令:
1. 返回值 = xxx2. 調用defer函數3. 空的return
1,3步才是Return 語句真正的命令,第2步是defer定義的語句,這裡可能會操作返回值。
下面我們來看兩個例子,試著將return語句和defer語句拆解到正確的順序。
第一個例子:
funcf()(r int) {t := 5deferfunc() { t = t + 5 }()return t}
拆解後:
funcf()(r int) {t := 5// 1. 賦值指令 r = t// 2. defer被插入到賦值與返回之間執行,這個例子中返回值r沒被修改過func() { t = t + 5 }// 3. 空的return指令return}
這裡第二步沒有操作返回值r, 因此,main函數中調用f()得到5.
第二個例子:
funcf()(r int) {deferfunc(r int) {r = r + 5 }(r)return1}
拆解後:
funcf()(r int) {// 1. 賦值r = 1// 2. 這裡改的r是之前傳值傳進去的r,不會改變要返回的那個r值func(r int) { r = r + 5 }(r)// 3. 空的returnreturn}
因此,main函數中調用f()得到1。
defer語句的參數
defer語句表達式的值在定義時就已經確定了。下面展示三個函數:
funcf1() {var err errordefer fmt.Println(err)err = errors.New("defer error")return}funcf2() {var err errordeferfunc() { fmt.Println(err) }() err = errors.New("defer error")return}funcf3() {var err errordeferfunc(err error) { fmt.Println(err) }(err) err = errors.New("defer error")return}funcmain() { f1() f2() f3()}
運行結果:
<nil>defer error<nil>
第1,3個函數是因為作為函數參數,定義的時候就會求值,定義的時候err變量的值都是nil, 所以最後列印的時候都是nil. 第2個函數的參數其實也是會在定義的時候求值,只不過,第2個例子中是一個閉包,它引用的變量err在執行的時候最終變成 defer error了。關於閉包在本文後面有介紹。
第3個函數的錯誤還比較容易犯,在生產環境中,很容易寫出這樣的錯誤代碼。最後defer語句沒有起到作用。
閉包是什麼?
閉包是由函數及其相關引用環境組合而成的實體,即:
閉包=函數+引用環境
一般的函數都有函數名,但是匿名函數就沒有。匿名函數不能獨立存在,但可以直接調用或者賦值於某個變量。匿名函數也被稱為閉包,一個閉包繼承了函數聲明時的作用域。在Golang中,所有的匿名函數都是閉包。
有個不太恰當的例子,可以把閉包看成是一個類,一個閉包函數調用就是實例化一個類。閉包在運行時可以有多個實例,它會將同一個作用域裡的變量和常量捕獲下來,無論閉包在什麼地方被調用(實例化)時,都可以使用這些變量和常量。而且,閉包捕獲的變量和常量是引用傳遞,不是值傳遞。
舉個簡單的例子:
funcmain() {var a = Accumulator()fmt.Printf("%d\n", a(1)) fmt.Printf("%d\n", a(10)) fmt.Printf("%d\n", a(100)) fmt.Println("----")var b = Accumulator() fmt.Printf("%d\n", b(1)) fmt.Printf("%d\n", b(10)) fmt.Printf("%d\n", b(100))}funcAccumulator()func(int)int {var x intreturnfunc(delta int)int { fmt.Printf("(%+v, %+v) - ", &x, x) x += deltareturn x }}
執行結果:
(0xc420014070, 0) - 1(0xc420014070, 1) - 11(0xc420014070, 11) - 111----(0xc4200140b8, 0) - 1(0xc4200140b8, 1) - 11(0xc4200140b8, 11) - 111
閉包引用了x變量,a,b可看作2個不同的實例,實例之間互不影響。實例內部,x變量是同一個地址,因此具有「累加效應」。
defer配合recover
Golang被詬病比較多的就是它的error, 經常是各種error滿天飛。編程的時候總是會返回一個error, 留給調用者處理。如果是那種致命的錯誤,比如程序執行初始化的時候出問題,直接panic掉,省得上線運行後出更大的問題。
但是有些時候,我們需要從異常中恢復。比如伺服器程序遇到嚴重問題,產生了panic, 這時我們至少可以在程序崩潰前做一些「掃尾工作」,如關閉客戶端的連接,防止客戶端一直等待等等。
panic會停掉當前正在執行的程序,不只是當前協程。在這之前,它會有序地執行完當前協程defer列表裡的語句,其它協程裡掛的defer語句不作保證。因此,我們經常在defer裡掛一個recover語句,防止程序直接掛掉,這起到了 try...catch的效果。
注意,recover()函數只在defer的上下文中才有效(且只有通過在defer中用匿名函數調用才有效),直接調用的話,只會返回 nil.
funcmain() {defer fmt.Println("defer main")var user = os.Getenv("USER_")gofunc() {deferfunc() {fmt.Println("defer caller")if err := recover(); err != nil { fmt.Println("recover success. err: ", err) } }()func() {deferfunc() { fmt.Println("defer here") }()if user == "" {panic("should set user env.") }// 此處不會執行 fmt.Println("after panic") }() }() time.Sleep(100) fmt.Println("end of main function")}
上面的panic最終會被recover捕獲到。這樣的處理方式在一個http server的主流程常常會被用到。一次偶然的請求可能會觸發某個bug, 這時用recover捕獲panic, 穩住主流程,不影響其他請求。
程式設計師通過監控獲知此次panic的發生,按時間點定位到日誌相應位置,找到發生panic的原因,三下五除二,修復上線。一看四周,大家都埋頭幹自己的事,簡直完美:偷偷修復了一個bug,沒有發現!嘿嘿!
後記
defer非常好用,一般情況下不會有什麼問題。但是只有深入理解了defer的原理才會避開它的溫柔陷阱。掌握了它的原理後,就會寫出易懂易維護的代碼。
作者:饒全成,中科院計算所碩士,滴滴出行後端研發工程師。
聲明:本文為作者投稿,版權歸其個人所有。免責聲明:文章廣告為微信自動匹配,與本平臺無關,如遇假冒偽劣請聯繫微信進行舉報。
【End】