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。 |
最佳實踐
資源釋放首選
defer- 檔案、網路連線、資料庫連線、互斥鎖等,都應在取得後立即
defer釋放,保證無論路徑如何都會執行。
- 檔案、網路連線、資料庫連線、互斥鎖等,都應在取得後立即
保持
defer簡潔defer裡的程式碼盡量保持單行、無副作用,避免在defer中做大量計算或 I/O。
使用匿名函式捕捉迴圈變數
for i := 0; i < n; i++ { i := i // 重新宣告,形成新的作用域 defer func() { fmt.Println(i) }() }在效能關鍵路徑衡量成本
- 使用
go test -bench或pprof檢測defer帶來的開銷,必要時改寫為手動釋放。
- 使用
結合
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,保持交易一致性。 |
| 測試套件的資源清理 | 在 TestXxx 中 defer 釋放測試資料庫、關閉模擬伺服器,避免測試之間互相干擾。 |
| 長時間執行的背景工作 | 在 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 行為與 立即求值 特性是設計時必須深刻理解的兩大要點。
- 在日常開發中,將所有需要在函式結束時執行的動作(如
Close、Unlock、Rollback)以defer包裝,可大幅降低遺漏的風險。 - 同時,注意 效能影響、迴圈使用方式與 panic 恢復,遵守最佳實踐,才能在大型系統中發揮
defer的最大價值。
透過本文的概念說明、實作範例與實務建議,你應該已能在自己的 Go 程式中自信地使用 defer,寫出更安全、可讀、易維護的程式碼。祝開發順利! 🚀