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

2021-01-13 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延遲函數
    可能是為了簡化類似代碼的邏輯,Go 語言引入了 defer 關鍵字,創造了"延遲函數"的概念.無 defer 的文件拷貝有 defer 的文件拷貝上述示例代碼簡單展示了 defer 關鍵字的基本使用方式,顯著的好處在於 Open/Close 是一對操作,不會因為寫到最後而忘記 Close 操作,而且連續依賴時也能正常保證延遲時機.
  • api框架 web 最好的go_golang api框架 - CSDN
    曾經我以為Python世界裡的框架已經夠多了,後來發現相比golang簡直小巫見大巫。golang提供的net/http庫已經很好了,對於http的協議的實現非常好,基於此再造框架,也不會是難事,因此生態中出現了很多框架。既然構造框架的門檻變低了,那麼低門檻同樣也會帶來質量參差不齊的框架。
  • 被「煮死」在「溫柔陷阱」裡的「青蛙」們
    因為,它掉進了人類所設置的「溫柔的陷阱」裡啦!而比青蛙不知聰明多少多少倍的人類又何嘗不是在不知不覺中被一次又一次、一種又一種的「溫柔」煮得遍體鱗傷,甚至支離破碎呢?在微信或是qq上加好友,然後摸清底數,開始「溫柔」轟炸。早問好晚問安中途噓寒問暖;白天想你不能安心工作,晚上想你無法安然入眠;你開心我就高興,你煩惱我就寬慰;你閒時我甜言蜜語,你忙時我善解人意。只要不是我出錢,你有多少問題,我都是你最最忠實的聽眾和排憂解難的朋友。這是「養熟」。熟了,離不開了,甚至已是男女朋友建立戀愛關係了,雖然連面都沒有見過。孤寂的男女,誰能逃得過「溫柔的陷阱」?
  • AI足球大數據爬蟲分析(golang)
    程序採用golang開發,項目模塊化結構清晰完整,非常容易入手並進行二次開發分析. AI球探為程序全自動處理,全程無人為參與幹預足球分析預測程序. 避免了人為分析的主觀性及不穩定因素. 程序根據各大指數多維度數據,結合作者多年足球分析經驗,精雕細琢, 集天地之靈氣,汲日月之精華,歷時七七四十九天,經Bug九九八十一個,編碼而成.
  • 展現植物界「溫柔陷阱」辰山植物園食蟲植物展揭幕
    原標題:展現植物界「溫柔陷阱」辰山食蟲植物展揭幕  植物界中有這麼一類植物獵手,它們擁有魔幻般的地獄牢籠或溫柔陷阱,能捕食昆蟲或小動物。這兩天在辰山植物園展覽溫室珍奇植物館正在舉辦一場獨特的食蟲植物專類展,讓參觀者大開眼界。
  • 當優秀的你被defer「雪藏」,轉正攻略助你逆襲G5藤校
    可以預料到在EA/ED放榜之後,有很多同學會「不幸」遇到他們申請的第一道坎——defer延期。01 什麼是defer延期?比起「錄取/拒絕」的直接乾脆,被夢校defer對於申請的同學而言可能更加煎熬。Defer 這種結果只會出現在Early Action(早行動)和 Early Decision (早決定)階段。
  • 美國留學早申請落幕,說好大家都defer,你卻偷偷拿offer?
    令人意外的是,今年的申請競爭一點都不比往年輕鬆,明明說好大家都defer,還是有不少人背地裡偷偷拿到了Dream School 的offer!由於美國疫情實在不甚樂觀,好幾個留學群裡的小夥伴都紛紛吐槽,決心defer一下再申請!
  • 英國18所高校出臺網課政策,9月的你選網課還是defer?
    關於defer:對於本科生,UCL允許學生延期至2021年入學,並表示會儘可能地批准學生的defer申請。申請截止時間為註冊之前。對於研究生,UCL表示大多數專業願意考慮學生的defer申請。申請截止時間為英國時間2020年8月21日23:59。
  • Defer入學之後,我該做什麼?
    但更多的同學則不希望「在中國過成美國時差」,晝夜顛倒上網課,因此選擇了defer——延期入學。只不過各校、各項目的要求不同,各位同學各自的考量也不同,所以延期的時間長短不一,有的選擇了半年,有的則選擇了一年。無論延期時間長短,選擇延期的同學們都得到了一段難得的空檔期。那麼,在這段時間裡,我們應該做些什麼呢?
  • 展現植物界「溫柔陷阱」辰山植物園食蟲植物展揭幕-植物,瓶子,食蟲...
    原標題:展現植物界「溫柔陷阱」辰山食蟲植物展揭幕  植物界中有這麼一類植物獵手,它們擁有魔幻般的地獄牢籠或溫柔陷阱,能捕食昆蟲或小動物。這兩天在辰山植物園展覽溫室珍奇植物館正在舉辦一場獨特的食蟲植物專類展,讓參觀者大開眼界。
  • 21fall留美申請大戰落下帷幕:說好大家都defer,你卻偷偷拿...
    令人意外的是,今年的申請競爭一點都不比往年輕鬆,明明說好大家都defer,還是有不少人背地裡偷偷拿到了Dream School 的offer!由於美國疫情實在不甚樂觀,好幾個留學群裡的小夥伴都紛紛吐槽,決心defer一下再申請!
  • 超50%的學生EA被Defer,想申夢校還有沒有救?
    根據今年部分學校公開的早申數據來看,有超過半數的早申學生,都收到了Defer通知,比如耶魯大學,在早申階段收到了7,939份申請,而只錄取了837人,拒絕了2,997人,剩下的3,999人都被defer了,超過50%。再比如麻省理工學院,今年有15036名學生選擇了MIT的早申,最終錄取719人,推遲了10656名申請人,接近71%。
  • 《流放之路》2.5女巫陷阱流BD 元素使冰霜陷阱
    導 讀 流放之路2.5女巫陷阱流BD,元素使冰霜陷阱BD加點推薦,輕鬆過T16,成型輕鬆,還在考慮元素使
  • 避開這六個減肥反彈的陷阱,拉力繩配合幾個動作,輕鬆保持好身材
    03陷阱3只吃低熱量食物,減肥反彈率70%就像是陷阱1一樣,單純的攝入部分食物會造成身體營養不均衡,很容易造成反彈。06陷阱6劇烈運動,減肥反彈率30%劇烈運動之後會產生強烈的飢餓感,這時攝入食物很容易被吸收,所以很容易復胖。想要防止復胖只需要一些輕鬆的運動來消耗多餘的脂肪即可,比如做一些瑜伽或者普拉提。
  • 張開溫柔陷阱的食蟲植物,什麼都吃?
    因為我愛的深沉,其實,這是它們的捕蟲陷阱。以匙葉茅膏菜為例,它們勺狀葉片特化成了食蟲葉,上部長滿了長短不一的「毛髮」,看著像把靜電梳子,這是它們製造陷阱的關鍵工具——腺毛。圖片:avery每一片食蟲葉都是一個精巧的陷阱。
  • 2021辛丑年化解犯太歲
    害太歲,馬,影響:易中陷阱,小人擋道,口舌官非,人際關係受損。衝太歲,羊,影響:易有傷災,傷害,衝撞,運勢欠佳,易衝走吉運。化解:在2021農曆立春日,應及時恭請太歲錦囊和本命佛去闢邪化解。
  • 從生命靈數來看,你應該避開哪些愛情陷阱!
    生活裡處處有陷阱,愛情裡也不例外,所以避開陷阱成為了一大剛需。那麼會遇到怎樣的陷阱?知曉源頭才懂如何躲避。下面就從生命靈數的角度來為大家揭秘!生命靈數1感情裡的事情,別以為有了感情就只能感性而丟了理性,這是你常遇到的愛情陷阱。一味投入其中而不思考後果,是沒有頭腦的未成年人才會做的事情。
  • 那些溫柔至極的寶藏文案
    ☆她真的好不注重細節,毀了我好多的溫柔。 ☆溫柔是世間寶藏,我永遠屈服於溫柔 ,永遠長不大,你做什麼都是對的 ☆見過花開的人,便會懂得風的溫柔。
  • 黑色四葉草108話:水之戰姬空中舞動奇蹟,佐拉陷阱魔法大放異彩
    「水之戰姬空中舞動奇蹟」當然了,再上一集中,女主諾艾兒已然和精靈過上了招,雖然目前是4V1的戰況,但因為諾艾兒的兩位兄長重傷倒地,而同行的銀色大鷲騎士團團長諾吉爾也因為保護族弟而受傷,作為目前唯一毫髮無傷的人來說,女主的主角光環瞬間被激活。但就在女主凝神集聚魔力準備釋放大招的時候,一件讓人不可思議的事情發生了。
  • 「美麗陷阱」之柑橘鳳蝶
    如果用兩個詞來詮釋柑橘鳳蝶的話,那就是「美麗可愛」和「溫柔陷阱」,它既有化蛹成蝶後「蝶戀花」般的美麗,又有幼蟲期「小綠豬」似的憨厚可愛