Hình dung một interface như một kiện hàng có nhãn người gửi. Một interface nil thật là khi không có kiện nào cả — không nhãn, không ruột. Còn khi bạn bỏ một con trỏ nil vào interface, bạn đưa ra một kiện rỗng ruột nhưng vẫn dán nhãn "*Meo". Hỏi i == nil là hỏi "có phải không có kiện nào không?" — mà kiện thì có đấy, chỉ rỗng thôi, nên câu trả lời là không. Đây là cái bẫy được nhắc nhiều nhất trong cộng đồng Go, và nó gây ra lỗi thật trong mã sản xuất. Bài này tái hiện nó và giải thích cơ chế.
Hiện tượng
var p *Meo = nil
var i Noi = p
p == nil : true
i == nil : false <<< dù i chứa đúng con trỏ nil đó
Con trỏ là nil. Gán nó vào interface. Interface không nil.
Vì sao
Bài 14 đã đo: interface chiếm 16 byte, và đó là hai con trỏ — một trỏ tới thông tin kiểu, một trỏ tới dữ liệu. Đúng cái nhãn và cái ruột của kiện hàng.
interface nil thật -> (kiểu = nil, giá trị = nil)
i sau khi gán p -> (kiểu = *Meo, giá trị = nil)
i == nil chỉ đúng khi cả hai ô đều nil. Sau khi gán p, ô kiểu chứa *Meo — nên interface khác nil, dù dữ liệu bên trong là nil. Cái nhãn đã dán là đủ để kiện hàng "tồn tại".
Nói cách khác: interface nhớ kiểu của thứ bạn bỏ vào, kể cả khi thứ đó rỗng.
Chỗ nó thật sự cắn: trả về error
Đây mới là dạng gặp trong mã thật, và nó tệ hơn nhiều:
type MyErr struct{}
func (m *MyErr) Error() string { return "loi" }
func traVeLoi(hong bool) error {
var e *MyErr // con trỏ, giá trị nil
if hong {
e = &MyErr{}
}
return e // trả về CON TRỎ, tự động bọc thành error
}
traVeLoi(false) -> err == nil ? false <<< BẪY
errors.Is(err, nil) false
Hàm chạy đúng đường "không có lỗi", trả về một con trỏ nil — và người gọi kiểm if err != nil thì thấy có lỗi.
Và nó khó tìm vì fmt.Println(err) in ra <nil> — bạn hé nhìn vào ruột kiện thấy trống không, tưởng "không có lỗi", nhưng err != nil vẫn đúng vì cái kiện vẫn nằm đó.
Ba cách phòng
Khai kiểu trả về là error, không phải kiểu cụ thể:
func traVeLoi(hong bool) error {
if hong {
return &MyErr{}
}
return nil // nil THUẦN, không qua biến con trỏ
}
Đây là cách chuẩn. Trả nil trực tiếp thì cả hai ô đều nil — trao ra "không có gì" theo đúng nghĩa đen.
Đừng dùng biến con trỏ kiểu cụ thể làm nơi chứa lỗi. Mẫu sai điển hình:
var e *MyErr // <- nguồn gốc vấn đề
if ... { e = &MyErr{} }
return e
Nếu buộc phải, hãy kiểm trước khi trả:
if e != nil { return e }
return nil
Đừng khai hàm trả về kiểu lỗi cụ thể:
func f() *MyErr // <- người gọi gán vào error là dính bẫy ngay
Luôn trả error. Đây là một trong số ít chỗ Go khuyến khích trả interface thay vì struct, và lý do chính là cái bẫy này.
Nó không chỉ xảy ra với error
Bất kỳ interface nào cũng dính:
func layWriter(co bool) io.Writer {
var w *bytes.Buffer
if co { w = &bytes.Buffer{} }
return w // cùng vấn đề
}
Nên quy tắc tổng quát: khi trả về interface, hãy trả nil tường minh chứ đừng trả một biến con trỏ có thể nil.
Cách kiểm khi đã lỡ
Nếu bạn nhận một interface từ mã người khác và nghi ngờ:
fmt.Printf("%T %v\n", i, i == nil)
%T in ra kiểu thật — đọc cái nhãn trên kiện. Thấy *Meo mà i == nil là false trong khi giá trị in ra <nil> — bạn vừa bắt được nó.
Trong mã sản xuất thì dùng phản chiếu, nhưng nó chậm và xấu:
reflect.ValueOf(i).IsNil()
Cần tới nó thường là dấu hiệu nên sửa chỗ tạo ra interface thay vì chỗ nhận nó.
Vì sao Go không sửa
Câu hỏi hợp lý: sao không cho i == nil trả về true khi giá trị bên trong nil?
Vì như thế sẽ mất khả năng phân biệt "không có gì" với "có một *Meo rỗng". Có mã dựa vào việc gọi method trên con trỏ nil — điều này hợp lệ trong Go nếu method không truy cập trường:
func (m *Meo) Noi() string { return "meo" } // không đụng m
var p *Meo
p.Noi() // chạy bình thường
Nên hành vi hiện tại là hệ quả của một quyết định thiết kế khác, không phải lỗi. Nhóm Go coi đây là chỗ tài liệu phải cảnh báo, và go vet có một số kiểm tra cho trường hợp rõ ràng nhất.
Muốn khắc cái mâu thuẫn này vào đầu trong ba mươi giây, in cả hai thứ trên cùng một dòng:
func f() error { var e *MyErr; return e }
fmt.Println(f() == nil, f())
In ra false <nil>. Một dòng bảo "khác nil", ngay bên cạnh lại in ra <nil>. Đó là lý do quy tắc "luôn return nil tường minh" tồn tại trong mọi hướng dẫn Go.
Mẫu số chung
Gốc rễ cái bẫy này là một ý tưởng rộng hơn nhiều: khoảnh khắc một cái hộp bắt đầu ghi siêu dữ liệu về thứ nó đựng — ở đây là cái nhãn kiểu — thì "hộp rỗng" và "không có hộp" tách thành hai trạng thái khác nhau, và một phép == nil ngây thơ chỉ soát được một. Go chỉ là nơi nó hiện ra rõ nhất vì interface là một "con trỏ béo" mang cả tag kiểu lẫn dữ liệu.
Cùng họ với nó ở khắp nơi:
- Java: một
Integercó thểnullhoặc0; tự mở hộp (int x = nullInteger) là nổNullPointerException— lại là "có hộp nhưng ruột rỗng". - Chuyện "vắng mặt / null / có giá trị" là ba trạng thái ở JSON và ở
NULLcủa SQL — đúng cái thang ba bậc tôi đã nói trong bàiencoding/json.
Và có một lối thoát mà cả ngành đang hội tụ về: mô hình hóa "sự vắng mặt" thành một kiểu riêng, chứ đừng dùng một con trỏ null có gắn nhãn. Option/None của Rust, Maybe/Nothing của Haskell, kiểu T? của Kotlin — những ngôn ngữ này không dính đúng cái bẫy này, vì "không có gì" là một nhánh dữ liệu riêng biệt chứ không phải một con trỏ rỗng đội lốt. Đây cũng chính là hậu duệ bậc hai của "sai lầm tỉ đô" (null reference) mà Tony Hoare nhận là của mình — Go không chỉ có null, nó có null-kèm-nhãn-kiểu.
Sợi chỉ chung đáng mang theo: một giá trị "rỗng" và "không có giá trị" là hai thứ khác nhau bất cứ khi nào cái rỗng vẫn mang theo một kiểu — nên hãy tạo ra dạng đúng-nghĩa-không-có-gì ngay tại nguồn (trả nil/None tường minh), thay vì tin một phép so sánh ở hạ nguồn sẽ phân biệt hộ bạn.
Ngày mai: zero value và cách thiết kế API quanh nó.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau in ra gì cho hai dòng p == nil và i == nil?
type Meo struct{}
type Noi interface{ Noi() string }
func (m *Meo) Noi() string { return "meo" }
var p *Meo = nil
var i Noi = p
fmt.Println(p == nil)
fmt.Println(i == nil)
Đáp án
In ra true rồi false. p là con trỏ nil thật sự nên p == nil là true. Nhưng khi gán p vào interface i, i mang một cặp (kiểu = *Meo, giá trị = nil) — khác với interface nil thật (kiểu = nil, giá trị = nil). Vì ô kiểu của i không rỗng (nó chứa *Meo), i == nil chỉ đúng khi cả hai ô đều nil, nên kết quả là false dù giá trị bên trong là nil.
Bài 2 (sửa lỗi). Hàm sau có bẫy khiến API HTTP trả về lỗi 500 cho một request hoàn toàn bình thường. Tìm và sửa lỗi.
type MyErr struct{}
func (m *MyErr) Error() string { return "loi" }
func traVeLoi(hong bool) error {
var e *MyErr
if hong {
e = &MyErr{}
}
return e
}
Đáp án
Bẫy: khi hong == false, e là con trỏ *MyErr nil, nhưng return e bọc nó vào interface error — kết quả là error khác nil (mang cặp kiểu = *MyErr, giá trị = nil) dù không có lỗi thật. Người gọi kiểm if err != nil sẽ thấy "có lỗi" một cách sai lầm. Sửa bằng cách trả nil thuần tường minh thay vì trả biến con trỏ:
func traVeLoi(hong bool) error {
if hong {
return &MyErr{}
}
return nil
}
Bài 3 (vận dụng thực tế). Bạn nhận một giá trị error từ một hàm mà bạn nghi ngờ dính bẫy interface-nil-khác-con-trỏ-nil (ví dụ fmt.Println(err) in ra <nil> nhưng err != nil vẫn đúng). Viết đoạn mã để xác nhận nghi ngờ này bằng cách in ra kiểu thật của err.
Đáp án
fmt.Printf("%T %v\n", err, err == nil)
%T in ra kiểu động thật sự đang nằm trong interface — ví dụ thấy in ra *MyErr mà err == nil lại là false, trong khi fmt.Println(err) chỉ in <nil> (vì Error() gọi trên con trỏ nil vẫn chạy được nếu method không truy cập trường), đó chính là dấu hiệu xác nhận bẫy interface-nil.
Bài 4 (bẫy/đánh đổi — vì sao Go không "sửa"). Bài viết giải thích vì sao Go không làm i == nil trả true khi giá trị bên trong interface là nil. Lý do đó liên quan tới đoạn mã nào sau đây, và giải thích ngắn gọn.
func (m *Meo) Noi() string { return "meo" }
var p *Meo
p.Noi()
Đáp án
Đoạn mã này minh hoạ rằng gọi method trên con trỏ nil là hợp lệ trong Go nếu method không truy cập trường của receiver (ở đây Noi() không đụng tới m). Nếu Go làm i == nil trả true khi giá trị động là nil, thì sẽ mất khả năng phân biệt "interface không chứa gì cả" với "interface chứa một *Meo nil nhưng vẫn có thể gọi method hợp lệ trên nó" — hai trạng thái này có ý nghĩa và cách dùng khác nhau, nên hành vi hiện tại là hệ quả của một quyết định thiết kế khác (cho phép gọi method trên con trỏ nil), không phải lỗi.
Bài 5 (đọc hiểu — nhiều loại interface đều dính bẫy). Đoạn mã sau cũng dính đúng bẫy tương tự, không chỉ với error. Giải thích và sửa.
func layWriter(co bool) io.Writer {
var w *bytes.Buffer
if co {
w = &bytes.Buffer{}
}
return w
}
Đáp án
Cũng dính bẫy y hệt: khi co == false, w là con trỏ *bytes.Buffer nil, nhưng return w bọc nó vào interface io.Writer, tạo ra một interface khác nil (mang kiểu *bytes.Buffer, giá trị nil). Người gọi kiểm if writer != nil sẽ nhầm là có writer hợp lệ. Sửa bằng cách trả nil tường minh:
func layWriter(co bool) io.Writer {
if co {
return &bytes.Buffer{}
}
return nil
}
Quy tắc tổng quát: bất kỳ hàm nào trả về interface đều nên trả nil tường minh, không nên trả một biến con trỏ cụ thể có thể đang là nil.