本文 AI 產出,尚未審核

Golang – 控制流程

主題:defer 延遲執行


簡介

在 Go 語言中,defer 是一個非常強大且常被低估的關鍵字。它允許我們把 某段程式碼 延遲到函式結束時才執行,無論是正常返回還是因為 panic 而提前結束。這種機制讓資源釋放、錯誤處理、日誌紀錄等工作變得簡潔且不易遺漏。

對於剛踏入 Go 世界的開發者而言,掌握 defer 不僅能寫出更安全的程式碼,還能提升程式的可讀性與維護性。本文將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你深入了解 defer 的使用時機與技巧,並提供實務上常見的應用情境。


核心概念

1. defer 的執行時機

  • 延遲執行defer 後面的函式呼叫會在當前函式返回之前執行。即使函式因 panic 終止,defer 仍會被執行(除非程式直接 os.Exit)。
  • 先進後出(LIFO):多個 defer 會以後進先出的順序執行,這對於多層資源釋放非常有用。
func demo() {
    fmt.Println("開始")
    defer fmt.Println("第一個 defer")
    defer fmt.Println("第二個 defer")
    fmt.Println("結束")
}

執行結果:

開始
結束
第二個 defer
第一個 defer

2. defer 的語法與參數求值

defer 後面的函式呼叫立即求值(包括參數),但函式本身的執行會被延遲。

func main() {
    i := 0
    defer fmt.Println("defer i =", i) // 參數在此時求值,i 為 0
    i = 5
    fmt.Println("main i =", i)        // i 為 5
}

輸出:

main i = 5
defer i = 0

3. 常見的 defer 用途

用途 說明
檔案關閉 (file.Close()) 確保檔案在讀寫完畢後一定被關閉
解鎖互斥鎖 (mu.Unlock()) 防止忘記在所有分支都呼叫 Unlock
回收資源 (db.Close(), tx.Rollback()) 讓資源釋放與錯誤處理保持一致
記錄日誌 (log.Println("結束")) 在函式退出時自動寫入結束訊息
恢復 panic (recover()) 捕捉 panic,避免程式崩潰

程式碼範例

以下提供 5 個實用範例,每個範例皆附上註解說明,幫助你快速掌握 defer 的實作方式。

範例 1:安全關閉檔案

package main

import (
    "bufio"
    "fmt"
    "os"
)

func readFirstLine(path string) (string, error) {
    // 開檔
    f, err := os.Open(path)
    if err != nil {
        return "", err
    }
    // **確保檔案一定會被關閉**
    defer f.Close()

    scanner := bufio.NewScanner(f)
    if scanner.Scan() {
        return scanner.Text(), nil
    }
    return "", scanner.Err()
}

func main() {
    line, err := readFirstLine("example.txt")
    if err != nil {
        fmt.Println("讀取失敗:", err)
        return
    }
    fmt.Println("第一行內容:", line)
}

重點:即使 scanner.Scan() 失敗或 return 之前發生 panic,defer f.Close() 仍會執行,避免檔案描述符泄漏。

範例 2:互斥鎖的自動解鎖

package main

import (
    "fmt"
    "sync"
)

var (
    counter int
    mu      sync.Mutex
)

func inc() {
    // **先加鎖,再使用 defer 解鎖**
    mu.Lock()
    defer mu.Unlock() // 確保任何路徑都會釋放鎖

    counter++
    fmt.Println("counter =", counter)
}

func main() {
    for i := 0; i < 5; i++ {
        go inc()
    }
    // 等待所有 goroutine 完成(簡易方式)
    fmt.Scanln()
}

技巧:將 Unlock 放在 defer 中,可避免在錯誤路徑忘記解鎖導致死鎖。

範例 3:資料庫交易的自動回滾與提交

package main

import (
    "database/sql"
    "fmt"
    _ "github.com/mattn/go-sqlite3"
)

func transfer(db *sql.DB, from, to int, amount float64) (err error) {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    // **交易結束前一定要呼叫 Commit 或 Rollback**
    defer func() {
        if p := recover(); p != nil {
            // 捕捉 panic,回滾交易
            _ = tx.Rollback()
            panic(p) // 重新拋出
        } else if err != nil {
            // 若有錯誤,回滾
            _ = tx.Rollback()
        } else {
            // 無錯誤則提交
            err = tx.Commit()
        }
    }()

    // 轉出
    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from)
    if err != nil {
        return err
    }
    // 轉入
    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to)
    return err
}

說明defer 包裹的匿名函式同時處理 panic 恢復錯誤回滾成功提交,讓交易程式碼保持簡潔。

範例 4:測試函式的執行時間(Profiling)

package main

import (
    "fmt"
    "time"
)

func slowOperation() {
    // 記錄開始時間
    start := time.Now()
    // **在函式結束時印出耗時**
    defer func() {
        fmt.Printf("slowOperation 耗時 %v\n", time.Since(start))
    }()

    // 模擬耗時工作
    time.Sleep(150 * time.Millisecond)
}

func main() {
    slowOperation()
}

應用:在效能測試或除錯時,利用 defer 輕鬆測量函式執行時間,且不會因為提前 return 而遺漏統計。

範例 5:多層 defer 的 LIFO 行為

package main

import "fmt"

func nestedDefer() {
    fmt.Println("開始執行")
    for i := 1; i <= 3; i++ {
        defer fmt.Printf("defer %d 執行\n", i)
    }
    fmt.Println("結束執行")
}

func main() {
    nestedDefer()
}

輸出:

開始執行
結束執行
defer 3 執行
defer 2 執行
defer 1 執行

觀察defer後進先出的順序執行,這在需要「先釋放最內層資源、再釋放外層資源」的情境中特別有用。


常見陷阱與最佳實踐

陷阱 說明 解決方案
延遲求值誤解 defer 參數在宣告時即被求值,若使用迴圈或變數變化,結果可能不是預期。 使用 匿名函式 包住變數,或直接在 defer 中呼叫函式而非傳值。
大量 defer 造成效能下降 每個 defer 會在執行時產生額外的堆疊與函式呼叫,極端情況下會影響效能。 在熱點路徑(如每毫秒呼叫上千次)盡量避免使用 defer,改用手動釋放。
在迴圈中使用 defer 產生資源泄漏 若在迴圈內 defer 大量開啟檔案或鎖,會等到函式結束才釋放,導致同時打開太多資源。 在迴圈內使用 立即釋放(手動 Close)或將迴圈抽成子函式,使 defer 在每次呼叫結束時即執行。
panic 後的資源未釋放 defer 內部本身也拋出 panic,可能導致後續 defer 無法執行。 defer 中使用 recover 捕捉 panic,或確保 defer 本身不會產生錯誤。
使用 os.Exit 直接退出 os.Exit 會立即終止程式,所有已註冊的 defer 不會被執行 若需要清理資源,先呼叫必要的清理函式,再執行 os.Exit

最佳實踐

  1. 資源釋放首選 defer

    • 檔案、網路連線、資料庫連線、互斥鎖等,都應在取得後立即 defer 釋放,保證無論路徑如何都會執行。
  2. 保持 defer 簡潔

    • defer 裡的程式碼盡量保持單行、無副作用,避免在 defer 中做大量計算或 I/O。
  3. 使用匿名函式捕捉迴圈變數

    for i := 0; i < n; i++ {
        i := i // 重新宣告,形成新的作用域
        defer func() { fmt.Println(i) }()
    }
    
  4. 在效能關鍵路徑衡量成本

    • 使用 go test -benchpprof 檢測 defer 帶來的開銷,必要時改寫為手動釋放。
  5. 結合 recover 實作安全的 API

    • 在公開函式最外層使用 defer func(){ if r:=recover(); r!=nil { … } }(),保證即使內部 panic 也不會讓整個服務崩潰。

實際應用場景

場景 為何使用 defer
HTTP 伺服器的請求處理 handler 開頭 defer 記錄請求結束時間、回收 body、釋放資料庫連線。
CLI 工具的臨時檔案 產生臨時檔案後立即 defer os.Remove(tmp),確保即使程式異常退出也不留下垃圾檔案。
多層交易(nested transaction) 每層交易使用 defer 進行 Rollback,最外層根據錯誤決定是否 Commit,保持交易一致性。
測試套件的資源清理 TestXxxdefer 釋放測試資料庫、關閉模擬伺服器,避免測試之間互相干擾。
長時間執行的背景工作 在 goroutine 開頭 defer 捕捉 panic,寫入 log 並安全退出,防止單一工作崩潰整個服務。

範例:HTTP Handler

func helloHandler(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    defer func() {
        fmt.Printf("[INFO] %s %s %v\n", r.Method, r.URL.Path, time.Since(start))
    }()

    // 確保 request body 被關閉
    defer r.Body.Close()

    // 假設有資料庫連線
    db := getDB()
    defer db.Close()

    fmt.Fprintln(w, "Hello, World!")
}

總結

  • defer資源釋放、錯誤處理與日誌紀錄 變得安全且易於維護。
  • 它的 LIFO 行為與 立即求值 特性是設計時必須深刻理解的兩大要點。
  • 在日常開發中,將所有需要在函式結束時執行的動作(如 CloseUnlockRollback)以 defer 包裝,可大幅降低遺漏的風險。
  • 同時,注意 效能影響、迴圈使用方式與 panic 恢復,遵守最佳實踐,才能在大型系統中發揮 defer 的最大價值。

透過本文的概念說明、實作範例與實務建議,你應該已能在自己的 Go 程式中自信地使用 defer,寫出更安全、可讀、易維護的程式碼。祝開發順利! 🚀