瞭解 Go 中的 defer


簡介

Go 有許多其他編程語言中常見的控制流關鍵字,如 ifswitchfor 等。有一個關鍵詞在大多數其他編程語言中都沒有,那就是  defer ,雖然它不太常見,但你很快就會發現它在你的程序中是多麼有用。

defer 語句的主要用途之一是清理資源,如打開的文件、網絡連接和 數據庫句柄 [1]。當你的程序使用完這些資源後,關閉它們很重要,以避免耗盡程序的限制,並允許其他程序訪問這些資源。defer 通過保持關閉文件 / 資源的調用與打開調用保持一致,使我們的代碼更加簡潔,不易出錯。

在這篇文章中,我們將學習如何正確使用 defer 語句來清理資源,以及使用 defer 時常犯的幾個錯誤。

什麼是 defer 語句

defer 語句將 defer 關鍵字後面的 函數_(點擊查看哦)_調用添加到一個棧中。當該語句所在的函數返回時,將執行堆棧中所有的函數調用。由於這些調用位於堆棧上,因此將按照後進先出的順序進行調用。

讓我們看看 defer 是如何工作的,打印出一些文本:

main.go

package main

import "fmt"

func main() {
        defer fmt.Println("Bye")
        fmt.Println("Hi")
}

main 函數中,我們有兩條語句。第一條語句以 defer 關鍵字開始,後面是 print 語句,打印出 Bye。下一行打印出 Hi

如果我們運行該程序,我們將看到以下輸出:

Hi
Bye

請注意,Hi 被首先打印出來。這是因爲以 defer 爲前綴的語句直到該函數結束前,都不會被調用。

讓我們再看看這個程序,這次我們將添加一些註釋來幫助說明正在發生的事情:

main.go

package main

import "fmt"

func main() {
        // defer statement is executed, and places
        // fmt.Println("Bye") on a list to be executed prior to the function returning
        defer fmt.Println("Bye")

        // The next line is executed immediately
        fmt.Println("Hi")

        // fmt.Println*("Bye") is now invoked, as we are at the end of the function scope
}

理解 defer 的關鍵是,當 defer 語句被執行時,延遲函數的參數被立即評估。當 defer 執行時,它把後面的語句放在一個列表中,在函數返回之前被調用。

雖然這段代碼說明了 defer 的運行順序,但這並不是編寫 Go 程序時的典型使用方式。我們更可能使用 defer 來清理資源,例如文件句柄。接下來讓我們看看如何做到這一點。

使用 defer 來清理資源

使用 defer 來清理資源在 Go 中是非常常見的。讓我們先看看一個將字符串寫入文件的程序,但沒有使用 defer 來處理資源清理的問題:

main.go

package main

import (
        "io"
        "log"
        "os"
)

func main() {
        if err := write("readme.txt""This is a readme file"); err != nil {
                log.Fatal("failed to write file:", err)
        }
}

func write(fileName string, text string) error {
        file, err := os.Create(fileName)
        if err != nil {
                return err
        }
        _, err = io.WriteString(file, text)
        if err != nil {
                return err
        }
        file.Close()
        return nil
}

在這個程序中,有一個叫做 write 的函數,它將首先嚐試創建一個文件。如果它有錯誤,它將返回錯誤並退出函數。接下來,它試圖將字符串 This is a readme file 寫到指定文件中。如果它收到一個錯誤,它將返回錯誤並退出該函數。然後,該函數將嘗試關閉該文件並將資源釋放回系統。最後,該函數返回 nil 以表示該函數的執行沒有錯誤。

雖然這段代碼可以工作,但有一個細微的錯誤。如果對 io.WriteString 的調用失敗,該函數將在沒有關閉文件並將資源釋放回系統的情況下返回。

我們可以通過添加另一個 file.Close() 語句來解決這個問題,在沒有 defer 的語言中,你可能會這樣解決:

main.go

package main

import (
        "io"
        "log"
        "os"
)

func main() {
        if err := write("readme.txt""This is a readme file"); err != nil {
                log.Fatal("failed to write file:", err)
        }
}

func write(fileName string, text string) error {
        file, err := os.Create(fileName)
        if err != nil {
                return err
        }
        _, err = io.WriteString(file, text)
        if err != nil {
                file.Close()
                return err
        }
        file.Close()
        return nil
}

現在,即使調用 io.WriteString 失敗了,我們仍然會關閉該文件。雖然這是一個相對容易發現和修復的錯誤,但對於一個更復雜的函數來說,它可能會被遺漏。

我們可以使用 defer 語句來確保在執行過程中無論採取何種分支,我們都會調用 Close() ,而不是增加對 file.Close() 的第二次調用。

下面是使用 defer 關鍵字的版本:

main.go

package main

import (
        "io"
        "log"
        "os"
)

func main() {
        if err := write("readme.txt""This is a readme file"); err != nil {
                log.Fatal("failed to write file:", err)
        }
}

func write(fileName string, text string) error {
        file, err := os.Create(fileName)
        if err != nil {
                return err
        }
        defer file.Close()
        _, err = io.WriteString(file, text)
        if err != nil {
                return err
        }
        return nil
}

這一次我們添加了這行代碼,defer file.Close()。這告訴編譯器,它應該在退出函數 write 之前執行 file.Close

現在我們已經確保,即使我們在未來添加更多的代碼並創建另一個退出該函數的分支,我們也會一直清理並關閉該文件。

然而,我們通過添加 defer 引入了另一個錯誤。我們不再檢查可能從 Close 方法返回的潛在錯誤。這是因爲當我們使用 defer 時,沒有辦法將任何返回值傳回給我們的函數。

在 Go 中,在不影響程序行爲的情況下多次調用 Close() 被認爲是一種安全和公認的做法。如果 Close() 要返回一個錯誤,它將在第一次被調用時返回。這使得我們可以在函數的成功執行路徑中明確地調用它。

讓我們看看我們如何既能 deferClose 的調用,又能在遇到錯誤時報告錯誤。

main.go

package main

import (
        "io"
        "log"
        "os"
)

func main() {
        if err := write("readme.txt""This is a readme file"); err != nil {
                log.Fatal("failed to write file:", err)
        }
}

func write(fileName string, text string) error {
        file, err := os.Create(fileName)
        if err != nil {
                return err
        }
        defer file.Close()
        _, err = io.WriteString(file, text)
        if err != nil {
                return err
        }

        return file.Close()
}

這個程序中唯一的變化是最後一行,我們返回 file.Close()。如果對 Close 的調用導致錯誤,現在將按照預期返回給調用函數。請記住,我們的 defer file.Close() 語句也將在 return 語句之後運行。這意味着 file.Close() 有可能被調用兩次。雖然這並不理想,但這是可以接受的做法,因爲它不應該對你的程序產生任何副作用。

然而,如果我們在函數的早期收到一個錯誤,例如當我們調用 WriteString 時,函數將返回該錯誤,並且也將嘗試調用 file.Close,因爲它被推遲了。儘管 file.Close 也可能(而且很可能)返回一個錯誤,但這不再是我們關心的事情,因爲我們收到的錯誤更有可能告訴我們一開始就出了什麼問題。

到目前爲止,我們已經看到我們如何使用一個 defer 來確保我們正確地清理我們的資源。接下來我們將看到如何使用多個 defer 語句來清理多個資源。

多個 defer 語句

在一個函數中擁有多個 defer 語句是很正常的。讓我們創建一個只有 defer 語句的程序,看看當我們引入多個 defer 時,會發生什麼情況:

main.go

package main

import "fmt"

func main() {
        defer fmt.Println("one")
        defer fmt.Println("two")
        defer fmt.Println("three")
}

如果我們運行該程序,我們將收到以下輸出結果:

three
two
one

注意,順序與我們調用 defer 語句的順序相反。這是因爲每個被調用的延遲語句都是堆疊在前一個語句之上的,然後在函數退出範圍時反向調用(後進先出)。

在一個函數中,你可以根據需要有儘可能多的 defer 調用,但重要的是要記住它們都將以相反的順序被調用。

現在我們瞭解了多個延遲的執行順序,讓我們看看如何使用多個延遲來清理多個資源。我們將創建一個程序,打開一個文件,向其寫入內容,然後再次打開,將內容複製到另一個文件。

main.go

package main

import (
        "fmt"
        "io"
        "log"
        "os"
)

func main() {
        if err := write("sample.txt""This file contains some sample text."); err != nil {
                log.Fatal("failed to create file")
        }

        if err := fileCopy("sample.txt""sample-copy.txt"); err != nil {
                log.Fatal("failed to copy file: %s")
        }
}

func write(fileName string, text string) error {
        file, err := os.Create(fileName)
        if err != nil {
                return err
        }
        defer file.Close()
        _, err = io.WriteString(file, text)
        if err != nil {
                return err
        }

        return file.Close()
}

func fileCopy(source string, destination string) error {
        src, err := os.Open(source)
        if err != nil {
                return err
        }
        defer src.Close()

        dst, err := os.Create(destination)
        if err != nil {
                return err
        }
        defer dst.Close()

        n, err := io.Copy(dst, src)
        if err != nil {
                return err
        }
        fmt.Printf("Copied %d bytes from %s to %s\n", n, source, destination)

        if err := src.Close(); err != nil {
                return err
        }

        return dst.Close()
}

我們添加了一個新的函數,叫做 fileCopy。在這個函數中,我們首先打開我們要複製的源文件。我們檢查我們是否收到了一個打開文件的錯誤。如果是的話,我們 return 錯誤並退出該函數。否則,我們 defer 關閉我們剛剛打開的源文件。

接下來我們創建目標文件。再次,我們檢查我們是否收到了創建文件的錯誤。如果是的話,我們 return 該錯誤並退出該函數。否則,我們也 defer 目標文件的 Close()。我們現在有兩個 defer 函數,當函數退出其作用域時將被調用。

現在我們已經打開了兩個文件,我們將Copy() 數據從源文件到目標文件。如果成功的話,我們將嘗試關閉兩個文件。如果我們在試圖關閉任何一個文件時收到錯誤,我們將 return 錯誤並退出函數作用域。

注意,我們爲每個文件明確地調用 Close(),儘管 defer 也將調用 Close()。這是爲了確保如果關閉文件時出現錯誤,我們會報告這個錯誤。這也確保瞭如果因爲任何原因函數提前退出,例如我們在兩個文件之間複製失敗,每個文件仍將嘗試從延遲調用中正確關閉。

總結

在這篇文章中,我們瞭解了 defer 語句,以及如何使用它來確保我們在程序中正確清理系統資源。正確地清理系統資源將使你的程序使用更少的內存,表現更好。要了解更多關於 defer 的使用,請閱讀 處理恐慌_(點擊查看)_的文章,或者探索我們整個 《如何在 Go 中編碼系列》[2]。

相關鏈接:

[1] https://en.wikipedia.org/wiki/Handle_(computing)

[2] https://gocn.github.io/How-To-Code-in-Go/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Fuc4557t3OWvJ3S2oHi1cA