Hình dung defer như việc viết một mẩu giấy nhắc "làm cái này trước khi rời phòng" rồi thả vào một cái hộp chồng lên nhau. Khi rời phòng, bạn lấy các mẩu ra từ trên xuống — mẩu thả sau cùng được đọc trước (đó là thứ tự LIFO). Và đây là chỗ tinh tế nhất: nội dung mẩu nhắc bị chụp ảnh ngay lúc bạn viết, nên nếu viết "x đang là 1" thì dù sau đó x đổi thành 99, mẩu vẫn đọc "1" — trừ khi bạn viết "xem lại bảng trắng", lúc đó nó mới đọc giá trị hiện tại. Giữ hình ảnh cái hộp và tấm ảnh đó, cả ba cái bẫy của defer trở nên hiển nhiên. 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, 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 — mẩu thả sau cùng đọc 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 — tấm ảnh chụp lúc viết mẩu nhắc.
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 — mẩu nhắc ghi "xem lại bảng trắng". 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.
Nếu muốn thấy cái bẫy chụp-ảnh ngay trong một cái chớp mắt, thử đúng 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 — vì time.Since(t) bị chụp ảnh ngay lúc viết defer, khi t vừa mới đặt. 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.
Mẫu số chung
Ý tưởng lõi của defer — ghép việc dọn dẹp với việc cấp phát sao cho nó luôn chạy ở mọi đường thoát, kể cả khi sập — là thứ mọi ngôn ngữ đều hội tụ về, vì cách còn lại (dọn ở cuối hàm, bằng tay, trên từng nhánh return) chính là nơi rò rỉ ẩn náu.
- C++ có RAII: hàm huỷ của đối tượng tự chạy khi ra khỏi tầm vực. Java có
try-with-resources, C# cóusing, Python cówith, Swift và Zig cũng códefer. Mỗi ngôn ngữ một cú pháp, cùng một lời hứa: bạn viết dọn-dẹp ngay cạnh mở-tài-nguyên, runtime lo chuyện gọi nó. - Điểm Go khác thường là
defergắn với hàm, không gắn với khối — và cái bẫy vòng lặp đến đúng từ đó. Thú vị là Zig cố ý làmdefercủa nó gắn với khối, nên không vướng; còn với Go, cách chữa phổ quát là "cho thân vòng lặp một hàm riêng".
Và cái bẫy thứ hai — đối số tính ngay, closure đọc lúc chạy — không phải đặc sản của Go, mà là cùng một lỗi bắt-biến-muộn (late binding) ám ảnh khắp nơi: for (var i…) setTimeout(()=>console.log(i)) của JavaScript in ra toàn số i cuối cùng; vòng lặp tạo closure của Python cũng vậy. Gốc rễ giống hệt: bạn bắt giá trị hay bắt tham chiếu tới biến? Sợi chỉ chung đáng mang theo: ghép dọn-dẹp với cấp-phát là phản xạ đúng ở mọi ngôn ngữ, nhưng luôn phải biết hai điều — cái dọn-dẹp của bạn chạy theo phạm vi nào (hàm hay khối), và nó chụp lại giá trị hay đọc biến lúc chạy. Trả lời sai câu đầu thì rò rỉ trong vòng lặp; trả lời sai câu sau thì mẫu đo thời gian của bạn mãi mãi báo ~0. Kèm một luật nhỏ mà ngôn ngữ nào cũng bắt trả: đóng một tài nguyên đang ghi có thể thất bại — đừng nuốt lỗi đó.
Ngày mai: tự viết kiểu lỗi cho miền nghiệp vụ.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau in ra thứ tự nào?
for i := 1; i <= 3; i++ {
defer fmt.Printf("defer %d ", i)
}
Đáp án
In ra defer 3 defer 2 defer 1. defer hoạt động theo cơ chế ngăn xếp (LIFO) — cái khai sau cùng chạy trước, giống như thả các mẩu giấy nhắc vào một cái hộp chồng lên nhau rồi lấy ra từ trên xuống khi rời phòng. Ngoài ra, đối số của fmt.Printf (giá trị i) được tính ngay tại dòng defer của từng lần lặp, nên mỗi lần defer "chụp ảnh" đúng giá trị i tại thời điểm đó (1, 2, 3), không phải giá trị cuối cùng.
Bài 2 (sửa lỗi). Hàm sau được viết để đo thời gian chạy nhưng luôn in ra một con số gần 0, dù time.Sleep(100 * time.Millisecond) rõ ràng mất 100ms. Tìm lỗi và sửa.
func f() {
t := time.Now()
defer fmt.Println("mất", time.Since(t))
time.Sleep(100 * time.Millisecond)
}
Đáp án
Lỗi: time.Since(t) là một đối số của fmt.Println, nên nó được tính ngay tại dòng defer (ngay sau khi t := time.Now(), gần như bằng 0), chứ không được tính lúc defer thật sự chạy (sau khi Sleep kết thúc). Chỉ lời gọi hàm bị hoãn lại, không phải việc tính đối số. Sửa bằng cách bọc trong một closure không đối số, để nó đọc t lúc chạy:
func f() {
t := time.Now()
defer func() { fmt.Println("mất", time.Since(t)) }()
time.Sleep(100 * time.Millisecond)
}
Bài 3 (đọc hiểu). Hàm sau trả về giá trị gì?
func tang() (kq int) {
defer func() { kq++ }()
return 1
}
Đáp án
Trả về 2. Với named return (kq int), return 1 thực chất gán kq = 1 trước, sau đó defer chạy và tăng kq lên 2, rồi hàm mới thật sự trả giá trị cuối cùng của kq (là 2) ra ngoài. Đây là cơ chế cho phép defer sửa được giá trị trả về, thường dùng để bọc lỗi ở một chỗ duy nhất cho hàm có nhiều đường return.
Bài 4 (vận dụng thực tế). Bạn cần mở và xử lý 500 tệp trong một vòng lặp, nhưng nếu dùng defer f.Close() trực tiếp trong vòng lặp thì sẽ hết file descriptor. Viết lại đoạn mã theo đúng cách sửa mà bài viết khuyến nghị (giữ được bảo đảm đóng file kể cả khi có panic).
Đáp án
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()
// xử lý f ở đây
return nil
}
defer gắn với hàm, không gắn với khối lặp — nếu đặt defer f.Close() ngay trong thân vòng for, nó không chạy cho tới khi toàn bộ hàm chứa vòng lặp kết thúc, khiến 500 tệp mở cùng lúc. Tách thân vòng lặp thành một hàm riêng (xuLyMot) giải quyết vấn đề, vì defer bên trong đó chạy ngay khi xuLyMot trả về ở mỗi lần lặp — và vẫn giữ được bảo đảm đóng file kể cả khi có panic, khác với cách đóng tường minh không dùng defer.
Bài 5 (bẫy/đánh đổi). Với một hàm ghi dữ liệu ra tệp, bài viết khuyên không nên đơn giản dùng defer f.Close() mà bỏ qua lỗi trả về của Close(). Giải thích vì sao, và viết mẫu mã xử lý đúng.
Đáp án
Với tệp mở để ghi, dữ liệu có thể còn nằm trong bộ đệm chưa được flush xuống đĩa, và Close() chính là nơi lỗi ghi đĩa (hết dung lượng, lỗi I/O...) lộ ra. Nếu chỉ gọi defer f.Close() mà không kiểm tra lỗi trả về, một lỗi ghi thất bại thầm lặng sẽ bị bỏ qua — người gọi tưởng đã ghi thành công nhưng thực ra file bị hỏng hoặc thiếu dữ liệu. Với tệp mở để đọc thì bỏ qua lỗi đóng chấp nhận được hơn nhiều.
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 khác quan trọng hơn
}
}()
_, err = f.Write(du)
return err
}