Golang 之輕鬆化解 defer 的溫柔陷阱

2020-12-11 CSDN

作者 | 饒全成

責編 | 胡巍巍

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】

相關焦點

  • Go 語言之 defer 的前世今生 - CSDN
    Jan 27, 2009. https://github.com/golang/go/commit/4a903e0b32be5a590880ceb7379e68790602c29d[Thompson, 2009] Ken Thompson. defer.
  • Defer還是不Defer?這一屆留學生的靈魂考題怎麼答?
    顧名思義,defer指延遲、推遲的意思。 今年由於疫情,不少學校出臺了defer政策,這意味著你可以向學校申請defer。 也就是如果你已經拿到offer,但是可能趕不上term1,就需要和學校申請suspend,把自己的enrolment延期(defer)到Term 2。
  • 為什麼golang語言會變得越來越流行
    那麼為什麼這麼多公司選擇了go語言,為什麼這麼多開發者選擇了go語言,golang變得越來越流行的原因到底是什麼?簡潔性我們知道python如此流行的一方面是它有著豐富的擴展庫,幾乎我們平時常用的功能,都有非常強大的第三方擴展庫供我們使用,另一方面就是它的語法簡潔,對比於java的代碼,同樣的功能,python使用的代碼相比之要少得太多了。
  • Golang多級內存池設計與實現
    defer func() { e := recover() if e == nil { return } if panicErr, ok := e.
  • 比得兔化解陷阱 觀眾笑出眼淚
    原標題:比得兔化解陷阱 觀眾笑出眼淚兔子們與麥格雷戈的鬥智鬥勇讓觀眾看得很開心。威爾·古勒執導的真人動畫電影《比得兔》將於本周五上映。影片近日發布終極預告和終極海報,超高智商的兔界流量擔當比得兔和麥格雷戈正面開「槓」,上演高壓電陷阱局中局,一連串讓人爆笑又過癮的操作堪稱解壓利器,輕鬆治癒不開心。
  • 前端JS開發人轉Golang初步
    雙方垃圾收集器除了上面提到的相同點外,但是兩者還有很大差異的:golang 的GC非常快,沒有滯後。JavaScript運行基於一個主線程(事件循環)和其他幾個執行外部IO的線程。golang不支持OOPGolang使用結構,不支持面向對象。這在oo大行其道的今天,可以說是一股清流。當然oo如果不講形式,光實現其思想,比如繼承,多態等話,golang也是有方法可以實現的。
  • 學習Async,Defer 和動態腳本,本文就夠了!
    幸運的是,這裡有兩個 <script> 特性(attribute)可以為我們解決這個問題:defer 和 async。 deferdefer 特性告訴瀏覽器不要等待腳本。相反,瀏覽器將繼續處理 HTML,構建 DOM。腳本會「在後臺」下載,然後等 DOM 構建完成後,腳本才會執行。
  • 乾貨系列:Golang快速入門,golang速查表
    lt;- 1ch <- 2ch <- 3close(ch)遍歷一個通道,直到它關閉for i := range ch { ···}Closed if ok == falsev, ok := <- ch錯誤控制Deferfunc main() { defer
  • 渣男泡吧必點的5種雞尾酒,款款都是「溫柔陷阱」,四洛克弱爆了
    5種雞尾酒,可別小看這些雞尾酒,每一款都是「溫柔陷阱」,顏值高,好入口,酒精度也高! 一、長島冰茶 Long Island Ice Tea 色澤像紅茶,口感酸甜的雞尾酒,其實是傳說中的長島冰茶,是酒吧裡最暢銷的雞尾酒之一,也是渣男必點的雞尾酒之一,由於口感十分好,喝起來像飲料,典型的「溫柔陷阱
  • 英國18所高校出臺網課政策,9月的你選網課還是defer?
    關於defer:LSE目前不接受任何defer申請。關於defer:對於本科生,KCL表示不一定能批准所有學生的defer申請。對於研究生,KCL不接受defer申請。關於defer:杜倫大學允許學生申請defer,將根據具體情況考慮延期要求。
  • Defer入學之後,我該做什麼?
    但更多的同學則不希望「在中國過成美國時差」,晝夜顛倒上網課,因此選擇了defer——延期入學。只不過各校、各項目的要求不同,各位同學各自的考量也不同,所以延期的時間長短不一,有的選擇了半年,有的則選擇了一年。
  • Golang unsafe.Pointer使用原則以及 uintptr 隱藏的坑
    編譯器在編譯代碼時候會做逃逸分析,關於逃逸分析細節可以參考:golang 逃逸分析與棧、堆分配分析[^1]逃逸分析在編譯階段可以確定一個對象是分配在堆上還是協程棧上。如果編譯器覺察到一個內存塊在運行時將會被多個協程訪問,或者不能輕鬆地斷定此內存塊是否只會被一個協程訪問,則此內存塊將會被開闢在堆上。也就是說,編譯器將採取保守但安全的策略,使得某些可以安全地被開闢在棧上的內存塊也有可能會被開闢在堆上。go支持協程棧是為了提升性能。
  • 美國留學早申請落幕,說好大家都defer,你卻偷偷拿offer?
    令人意外的是,今年的申請競爭一點都不比往年輕鬆,明明說好大家都defer,還是有不少人背地裡偷偷拿到了Dream School 的offer!由於美國疫情實在不甚樂觀,好幾個留學群裡的小夥伴都紛紛吐槽,決心defer一下再申請!
  • 賓大/達特茅斯/耶魯藤校攜手放榜defer一半!|美國大學放榜
    猶豫不決的他們給一半的學生送上了defer!而整體而言,史密斯學院與波莫納學院相比之下就顯得慷慨了不少。下面我們來一起看看這五所大學的放榜情況吧!耶魯大學坐落在美國東部康乃狄克州沿海紐哈芬市的耶魯大學是常青藤聯盟之首的學校,US News排名全美第四。
  • 如何有效化解敦煌陷阱廁所事件燃爆的負面輿情
    就像敦煌陷阱廁所事件,8月份的事,為什麼非要等到負面輿情燃爆以後才去處理呢?到底是哪個環節出了問題?敦煌陷阱廁所事件,對甘肅旅遊業、對甘肅形象的影響之大,不亞於當年天價蝦對青島的影響。這都多少年過去了,因負面輿情影響,青島的旅遊業仍未恢復當年的景氣。
  • 我功夫特牛布置陷阱秘籍怎麼用 布置陷阱秘籍詳解
    18183首頁 我功夫特牛 我功夫特牛布置陷阱秘籍怎麼用 布置陷阱秘籍詳解 我功夫特牛布置陷阱秘籍怎麼用 布置陷阱秘籍詳解
  • 2021留學申請被Defer和Waitlist了怎麼辦?
    congratulations讓人激動,但defer和waitlist卻使人心痛,面對心痛的結果你該如何面對接下來的情況呢?01收到offer之後應該做什麼?但是如果你想嘗試,不要向學校強調你多優秀,可以試著把申請材料中的不足之處進行補充說明,證明你在申請期間通過自身努力得到了補足,體現你的成長和進步。再加上你如此熱愛這所學校的原因,說明學校哪方面和你特別契合(結合學校特點來補充)。
  • 6個簡例帶你玩轉Golang指針
    所以你可以在上面的例子中省略指針p中的類型聲明,直接簡寫為:var p = &x擼代碼之例2 :package mainimport "fmt"func main() {chongchong *pp = ", *pp)fmt.Println("chongchong **pp = ", **pp)}結果輸出為Go中沒有指針算術老司機,都知道,可以在C/C ++中隊指針做計算,但是golang
  • 《熊出沒》光頭強設下溫柔的陷阱,熊大熊二落入圈套,全部被抓
    【光頭強設下溫柔的陷阱,熊大熊落入圈套,全部被抓】在第40集《溫柔的陷阱》裡,光頭強偷偷跑到森林裡砍樹,熊大熊二現身阻止了他。光頭強帶著滿身的傷回到了家,李老闆給他打電話,讓他在三天內完成工作,並把熊大熊二抓回來。