5.8. Deferred函數

在findLinks的例子中,我們用http.Get的輸出作為html.Parse的輸入。只有url的內容的確是HTML格式的,html.Parse才可以正常工作,但實際上,url指向的內容很豐富,可能是圖片,純文本或是其他。將這些格式的內容傳遞給html.parse,會產生不良後果。

下面的例子獲取HTML頁面並輸出頁面的標題。title函數會檢查服務器返回的Content-Type字段,如果發現頁面不是HTML,將終止函數運行,返回錯誤。

gopl.io/ch5/title1

func title(url string) error {
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    // Check Content-Type is HTML (e.g., "text/html;charset=utf-8").
    ct := resp.Header.Get("Content-Type")
    if ct != "text/html" && !strings.HasPrefix(ct,"text/html;") {
        resp.Body.Close()
        return fmt.Errorf("%s has type %s, not text/html",url, ct)
    }
    doc, err := html.Parse(resp.Body)
    resp.Body.Close()
    if err != nil {
        return fmt.Errorf("parsing %s as HTML: %v", url,err)
    }
    visitNode := func(n *html.Node) {
        if n.Type == html.ElementNode && n.Data == "title"&&n.FirstChild != nil {
            fmt.Println(n.FirstChild.Data)
        }
    }
    forEachNode(doc, visitNode, nil)
    return nil
}

下面展示了運行效果:

$ go build gopl.io/ch5/title1
$ ./title1 http://gopl.io
The Go Programming Language
$ ./title1 https://golang.org/doc/effective_go.html
Effective Go - The Go Programming Language
$ ./title1 https://golang.org/doc/gopher/frontpage.png
title1: https://golang.org/doc/gopher/frontpage.png has type image/png, not text/html

resp.Body.close調用了多次,這是為了確保title在所有執行路徑下(即使函數運行失敗)都關閉了網絡連接。隨著函數變得複雜,需要處理的錯誤也變多,維護清理邏輯變得越來越困難。而Go語言獨有的defer機制可以讓事情變得簡單。

你只需要在調用普通函數或方法前加上關鍵字defer,就完成了defer所需要的語法。當執行到該條語句時,函數和參數表達式得到計算,但直到包含該defer語句的函數執行完畢時,defer後的函數才會被執行,不論包含defer語句的函數是通過return正常結束,還是由於panic導致的異常結束。你可以在一個函數中執行多條defer語句,它們的執行順序與聲明順序相反。

defer語句經常被用於處理成對的操作,如打開、關閉、連接、斷開連接、加鎖、釋放鎖。通過defer機制,不論函數邏輯多複雜,都能保證在任何執行路徑下,資源被釋放。釋放資源的defer應該直接跟在請求資源的語句後。在下面的代碼中,一條defer語句替代了之前的所有resp.Body.Close

gopl.io/ch5/title2

func title(url string) error {
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    ct := resp.Header.Get("Content-Type")
    if ct != "text/html" && !strings.HasPrefix(ct,"text/html;") {
        return fmt.Errorf("%s has type %s, not text/html",url, ct)
    }
    doc, err := html.Parse(resp.Body)
    if err != nil {
        return fmt.Errorf("parsing %s as HTML: %v", url,err)
    }
    // ...print doc's title element…
    return nil
}

在處理其他資源時,也可以採用defer機制,比如對文件的操作:

io/ioutil

package ioutil
func ReadFile(filename string) ([]byte, error) {
    f, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    return ReadAll(f)
}

或是處理互斥鎖(9.2章)

var mu sync.Mutex
var m = make(map[string]int)
func lookup(key string) int {
    mu.Lock()
    defer mu.Unlock()
    return m[key]
}

調試複雜程序時,defer機制也常被用於記錄何時進入和退出函數。下例中的bigSlowOperation函數,直接調用trace記錄函數的被調情況。bigSlowOperation被調時,trace會返回一個函數值,該函數值會在bigSlowOperation退出時被調用。通過這種方式, 我們可以只通過一條語句控制函數的入口和所有的出口,甚至可以記錄函數的運行時間,如例子中的start。需要注意一點:不要忘記defer語句後的圓括號,否則本該在進入時執行的操作會在退出時執行,而本該在退出時執行的,永遠不會被執行。

gopl.io/ch5/trace

func bigSlowOperation() {
    defer trace("bigSlowOperation")() // don't forget the extra parentheses
    // ...lots of work…
    time.Sleep(10 * time.Second) // simulate slow operation by sleeping
}
func trace(msg string) func() {
    start := time.Now()
    log.Printf("enter %s", msg)
    return func() { 
        log.Printf("exit %s (%s)", msg,time.Since(start)) 
    }
}

每一次bigSlowOperation被調用,程序都會記錄函數的進入,退出,持續時間。(我們用time.Sleep模擬一個耗時的操作)

$ go build gopl.io/ch5/trace
$ ./trace
2015/11/18 09:53:26 enter bigSlowOperation
2015/11/18 09:53:36 exit bigSlowOperation (10.000589217s)

我們知道,defer語句中的函數會在return語句更新返回值變量後再執行,又因為在函數中定義的匿名函數可以訪問該函數包括返回值變量在內的所有變量,所以,對匿名函數採用defer機制,可以使其觀察函數的返回值。

以double函數為例:

func double(x int) int {
    return x + x
}

我們只需要首先命名double的返回值,再增加defer語句,我們就可以在double每次被調用時,輸出參數以及返回值。

func double(x int) (result int) {
    defer func() { fmt.Printf("double(%d) = %d\n", x,result) }()
    return x + x
}
_ = double(4)
// Output:
// "double(4) = 8"

可能double函數過於簡單,看不出這個小技巧的作用,但對於有許多return語句的函數而言,這個技巧很有用。

被延遲執行的匿名函數甚至可以修改函數返回給調用者的返回值:

func triple(x int) (result int) {
    defer func() { result += x }()
    return double(x)
}
fmt.Println(triple(4)) // "12"

在循環體中的defer語句需要特別注意,因為只有在函數執行完畢後,這些被延遲的函數才會執行。下面的代碼會導致系統的文件描述符耗盡,因為在所有文件都被處理之前,沒有文件會被關閉。

for _, filename := range filenames {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer f.Close() // NOTE: risky; could run out of file descriptors
    // ...process f…
}

一種解決方法是將循環體中的defer語句移至另外一個函數。在每次循環時,調用這個函數。

for _, filename := range filenames {
    if err := doFile(filename); err != nil {
        return err
    }
}
func doFile(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer f.Close()
    // ...process f…
}

下面的代碼是fetch(1.5節)的改進版,我們將http響應信息寫入本地文件而不是從標準輸出流輸出。我們通過path.Base提出url路徑的最後一段作為文件名。

gopl.io/ch5/fetch

// Fetch downloads the URL and returns the
// name and length of the local file.
func fetch(url string) (filename string, n int64, err error) {
    resp, err := http.Get(url)
    if err != nil {
        return "", 0, err
    }
    defer resp.Body.Close()
    local := path.Base(resp.Request.URL.Path)
    if local == "/" {
        local = "index.html"
    }
    f, err := os.Create(local)
    if err != nil {
        return "", 0, err
    }
    n, err = io.Copy(f, resp.Body)
    // Close file, but prefer error from Copy, if any.
    if closeErr := f.Close(); err == nil {
        err = closeErr
    }
    return local, n, err
}

對resp.Body.Close延遲調用我們已經見過了,在此不做解釋。上例中,通過os.Create打開文件進行寫入,在關閉文件時,我們沒有對f.close採用defer機制,因為這會產生一些微妙的錯誤。許多文件系統,尤其是NFS,寫入文件時發生的錯誤會被延遲到文件關閉時反饋。如果沒有檢查文件關閉時的反饋信息,可能會導致數據丟失,而我們還誤以為寫入操作成功。如果io.Copy和f.close都失敗了,我們傾向於將io.Copy的錯誤信息反饋給調用者,因為它先於f.close發生,更有可能接近問題的本質。

練習5.18:不修改fetch的行為,重寫fetch函數,要求使用defer機制關閉文件。