defer là một trong những ý tưởng hay nhất của Go: đặt việc dọn dẹp ngay cạnh việc cấp phát, thay vì ở cuối hàm. Nhưng nó có ba hành vi mà đọc mã không đoán được.

Cơ bản

f, err := os.Open(ten)
if err != nil { return err }
defer f.Close()

f.Close() chạy khi hàm kết thúc — dù kết thúc bằng return nào, hay bằng panic.

Điểm mạnh là khoảng cách: dòng mở và dòng đóng cạnh nhau, nên đọc là thấy ngay có đóng hay không. Trong Java, try-with-resources giải cùng bài toán nhưng đòi một khối lồng.

Thứ tự LIFO

for i := 1; i <= 3; i++ {
	defer fmt.Printf("defer %d ", i)
}
  defer 3   defer 2   defer 1

Ngăn xếp, không phải hàng đợi. Cái khai sau chạy trước.

Điều này đúng: nếu bạn mở A rồi mở B, thì phải đóng B rồi mới đóng A. LIFO cho bạn thứ tự đó miễn phí.

Bẫy thứ nhất: đối số tính ngay

x := 1
defer fmt.Printf("defer thấy x = %d\n", x)
x = 99
  defer thấy x = 1     (giá trị lúc KHAI)
  x cuối cùng = 99

Đối số của hàm trong defer được tính ngay tại dòng defer, chỉ có lời gọi bị hoãn.

So với closure:

y := 1
defer func() { fmt.Printf("closure thấy y = %d\n", y) }()
y = 99
  closure thấy y = 99   (giá trị lúc CHẠY)

Closure không có đối số, nó đọc biến lúc chạy. Hai dòng trông gần giống nhau, kết quả chênh 98 đơn vị.

Hệ quả thực dụng: mẫu đo thời gian sau đây sai:

defer log.Printf("mất %v", time.Since(t0))   // tính NGAY -> luôn ~0

Phải bọc closure:

defer func() { log.Printf("mất %v", time.Since(t0)) }()

Bẫy thứ hai: defer trong vòng lặp

for _, ten := range danhSach {
	f, _ := os.Open(ten)
	defer f.Close()        // KHÔNG chạy tới hết hàm
}

defer gắn với hàm, không gắn với khối. Vòng lặp một nghìn tệp là một nghìn tệp mở cùng lúc, và bạn sẽ hết file descriptor.

Hai cách sửa:

// 1. tách thành hàm riêng
for _, ten := range danhSach {
	if err := xuLyMot(ten); err != nil { return err }
}
func xuLyMot(ten string) error {
	f, err := os.Open(ten)
	if err != nil { return err }
	defer f.Close()
	...
}

// 2. đóng tường minh, không dùng defer

Cách một tốt hơn vì nó vẫn giữ được bảo đảm khi có panic.

Bẫy thứ ba: defer f.Close() nuốt lỗi

Với tệp mở để đọc thì bỏ qua lỗi đóng là chấp nhận được. Với tệp ghi thì không — dữ liệu có thể còn trong bộ đệm, và Close() là nơi lỗi ghi đĩa lộ ra.

func Ghi(ten string, du []byte) (err error) {
	f, err := os.Create(ten)
	if err != nil { return err }
	defer func() {
		if cerr := f.Close(); cerr != nil && err == nil {
			err = cerr        // chỉ ghi đè khi chưa có lỗi
		}
	}()
	_, err = f.Write(du)
	return err
}

Mẫu này dùng named return để defer sửa được giá trị trả về.

defer sửa được named return

func tang() (kq int) {
	defer func() { kq++ }()
	return 1
}
  tang() trả về 2

return 1 gán kq = 1, rồi defer chạy và tăng lên 2, rồi hàm mới thật sự trả về.

Ứng dụng chính đáng nhất là bọc lỗi ở một chỗ duy nhất:

func bocLoi() (err error) {
	defer func() {
		if err != nil { err = fmt.Errorf("bọc: %w", err) }
	}()
	return errors.New("gốc")
}
  bocLoi() -> bọc: gốc

Thay vì bọc ở từng chỗ return, bạn bọc một lần. Rất gọn cho hàm có nhiều đường thoát.

Nhưng dùng tiết chế: nó làm luồng giá trị trả về khó theo dõi, và người đọc phải nhớ rằng defer chạy sau return.

Chi phí

defer từng đắt trong các bản Go cũ, và có thời người ta tránh nó trong vòng lặp nóng. Từ Go 1.14, phần lớn defer được nội tuyến hoá và chi phí gần bằng một lời gọi hàm thường.

Nên lời khuyên "đừng dùng defer vì chậm" đã lỗi thời. Chỉ đo nếu bạn thật sự ở trong đường chạy hàng triệu lần.

Thử ba mươi giây

func f() {
	t := time.Now()
	defer fmt.Println("mất", time.Since(t))
	time.Sleep(100 * time.Millisecond)
}

In ra một con số cỡ vài trăm nano giây, không phải 100ms. Bọc lại thành defer func(){...}() và chạy lại — lúc đó mới đúng.

Ba mươi giây, và bạn sẽ không bao giờ viết sai mẫu đo thời gian bằng defer nữa.

Ngày mai: tự viết kiểu lỗi cho miền nghiệp vụ.