Interface là thứ làm nên tính đa hình (polymorphism) của Go, và cũng là nguồn của một trong những bug khó hiểu nhất trong ngôn ngữ: "tôi trả về nil mà người gọi kiểm != nil lại thấy true". Để hiểu bug đó — và hiểu chi phí thật của interface — ta phải nhìn vào cấu trúc bên trong: interface là một cặp hai con trỏ. Bài này mổ xẻ iface, eface, itab và đo trực tiếp ba hành vi quan trọng, gồm cả bẫy typed nil.

Cấu trúc: cặp hai con trỏ 16 byte

Interface trên máy 64-bit luôn là 16 byte = hai từ máy. Có hai loại:

  • iface (interface có phương thức, như io.Reader): {itab, data}. itab (interface table) là con trỏ tới một bảng chứa thông tin kiểu và con trỏ tới các phương thức của kiểu cụ thể cho interface này.
  • eface (interface rỗng = any / interface{}): {type, data}. Chỉ có con trỏ thông tin kiểu (không cần bảng phương thức vì interface rỗng không có phương thức).

Trong cả hai, data là con trỏ tới giá trị cụ thể đang được giữ.

Ảnh chụp đoạn mã Go nền tối minh hoạ interface internals iface eface itab và bẫy typed nil, interface là một cặp hai con trỏ một trỏ tới thông tin kiểu và bảng phương thức một trỏ tới dữ liệu, iface interface có phương thức 16 byte 2 từ máy itab con trỏ tới bảng thông tin kiểu cộng con trỏ các phương thức data con trỏ tới giá trị cụ thể, eface interface rỗng any 16 byte 2 từ máy type con trỏ tới thông tin kiểu không có bảng phương thức data con trỏ tới giá trị, type Kêu interface Kêu string var i Kêu bằng địa chỉ Chó Rex itab bằng type cộng method data trỏ Chó, gọi method qua interface đi gián tiếp qua itab tìm con trỏ phương thức dynamic dispatch chậm hơn gọi trực tiếp, bẫy typed nil một con trỏ nil gán vào interface khác nil vì interface giữ cả itab kiểu Chó lẫn data nil interface bằng nil chỉ khi cả hai word đều nil con trỏ nil qua interface itab có kiểu nên interface khác nil bug kinh điển hàm trả T nil qua kiểu trả interface người gọi kiểm khác nil thấy true

Hình 1: iface {itab, data} có bảng phương thức, eface {type, data} chỉ có kiểu. Gọi method đi gián tiếp qua itab. Bẫy typed nil khi con trỏ nil gán vào interface.

Đo thật: kích thước, typed nil, dispatch

Đo ba hành vi cốt lõi trên container Go 1.23:

Ảnh chụp bảng kết quả đo thật nền tối interface internals Go 1.23 arm64, phần 1 kích thước cặp hai con trỏ iface có method bằng 16 byte 2 word itab cộng data eface any bằng 16 byte 2 word type cộng data i itab 0xf5708 bảng phương thức cộng type data 0x cf28 trỏ Chó, phần 2 bẫy typed nil con trỏ nil trong interface khác nil var conTro Chó bằng nil var iface Kêu bằng conTro conTro bằng nil true iface bằng nil false iface khác nil dù data là nil vì itab vẫn có kiểu Chó, phần 3 dynamic dispatch gọi method qua interface BenchmarkTrucTiep 0.23 ns/op compiler inline được BenchmarkDispatchDong 1.89 ns/op slice nhiều kiểu dispatch thật qua interface chậm khoảng 8 lần khi compiler không devirtualize được phải đi gián tiếp qua itab tìm con trỏ phương thức không inline

Hình 2: (1) iface và eface đều 16 byte. (2) Typed nil: conTro==nil true nhưng iface==nil false! (3) Dispatch: trực tiếp 0,23 ns vs qua interface 1,89 ns — chậm ~8 lần khi compiler không devirtualize.

1. Kích thước 16 byte. Cả iface (có method) và eface (any) đều 16 byte. itab của biến i trỏ tới địa chỉ bảng phương thức, data trỏ tới giá trị Chó.

2. Bẫy typed nil. Đây là bug kinh điển. Một con trỏ *Chó = nil gán vào interface: conTro == nil là true, nhưng iface == nil là false! Vì sao? Interface == nil chỉ khi cả hai word đều nil. Khi bạn gán một con trỏ nil (dù data là nil), itab vẫn được đặt (kiểu *Chó) — nên interface có một word khác nil, tức != nil.

3. Dynamic dispatch chậm hơn. Gọi method qua interface (khi compiler không biết kiểu cụ thể) phải đi gián tiếp qua itab để tìm con trỏ phương thức. Đo thật: gọi trực tiếp 0,23 ns (được inline), gọi qua interface động 1,89 ns — chậm ~8 lần. Nhưng khi kiểu cụ thể biết lúc biên dịch (biến local kiểu cụ thể), compiler devirtualize (biến lời gọi interface thành lời gọi trực tiếp) và gần như miễn phí.

Vì sao typed nil gây bug

Đây là bug thực tế phổ biến nhất từ interface. Xét:

func timLoi() *MyError {  // trả con trỏ cụ thể
    return nil            // không có lỗi
}
func xuLy() error {       // KIỂU TRẢ LÀ INTERFACE
    return timLoi()       // gán *MyError(nil) vào error
}
// người gọi:
if err := xuLy(); err != nil {  // TRUE! dù không có lỗi thật
    // chạy nhầm vào đây
}

timLoi() trả *MyError(nil), nhưng khi gán vào kiểu trả error (interface), interface có itab = kiểu *MyError nên != nil. Người gọi kiểm err != nil thấy true và xử lý một "lỗi" không tồn tại. Cách sửa: hàm trả interface nên trả nil tường minh (return nil), không trả con trỏ cụ thể nil.

Ứng dụng thực tế

Luôn trả nil tường minh cho kiểu trả interface. Nếu hàm trả error (hoặc bất kỳ interface nào), khi không có gì thì return nil, đừng trả một biến con trỏ cụ thể có thể nil. Đây là quy tắc phòng bug typed nil số một.

Interface có chi phí dispatch — cân nhắc ở đường siêu nóng. Gọi method qua interface chậm ~8 lần gọi trực tiếp khi không devirtualize được. Với vòng lặp cực nóng gọi hàng tỉ lần, dùng kiểu cụ thể (generics từ Go 1.18 giúp giữ kiểu tĩnh) thay interface có thể nhanh hơn. Nhưng với đa số code, 1,9 ns không đáng lo — interface đáng giá vì tính linh hoạt.

any (eface) và interface có method (iface) khác cấu trúc nhưng cùng kích thước. Cả hai 16 byte. Khi bạn thiết kế API nhận any, nó dùng eface; nhận một interface cụ thể dùng iface với itab. Type assertion (x.(T)) kiểm con trỏ kiểu trong cả hai.

Đánh đổi cần cân nhắc

Interface đổi tính linh hoạt lấy một tầng gián tiếp. Cặp hai con trỏ + dispatch qua itab là cái giá của đa hình động. Đáng giá cho thiết kế linh hoạt (mock, plugin, đa hình), nhưng không miễn phí. Generics (Go 1.18+) cho đa hình tĩnh không có chi phí dispatch — cân nhắc khi hiệu năng quan trọng và không cần linh hoạt lúc chạy.

Typed nil là hệ quả tất yếu của thiết kế, không phải bug của Go. Interface phải phân biệt "không giữ gì" (nil interface) với "giữ một con trỏ nil của kiểu X". Hai thứ này khác nhau về ngữ nghĩa. Bug đến từ lập trình viên nhầm hai khái niệm, không từ runtime. Hiểu cấu trúc là tránh được.

Chi phí dispatch phụ thuộc devirtualization. Đo cho thấy khi compiler biết kiểu, dispatch gần như miễn phí; khi không, ~8 lần chậm hơn. Đừng giả định interface luôn chậm hay luôn nhanh — phụ thuộc compiler có suy ra được kiểu cụ thể không. Đo trên code thật.

Ba ý mang về

  1. Interface là cặp hai con trỏ 16 byte: iface {itab, data} (có bảng phương thức + kiểu), eface {type, data} (any, chỉ kiểu) — data trỏ tới giá trị, itab/type trỏ tới thông tin kiểu.
  2. Bẫy typed nil: đo thật, con trỏ nil gán vào interface cho interface != nil vì itab vẫn có kiểu — bug kinh điển khi trả con trỏ cụ thể nil qua kiểu trả interface; sửa bằng return nil tường minh.
  3. Dynamic dispatch chậm ~8 lần khi không devirtualize: đo thật 1,89 ns qua interface vs 0,23 ns trực tiếp (đi gián tiếp qua itab) — nhưng compiler devirtualize khi biết kiểu cụ thể, khi đó gần như miễn phí; generics cho đa hình tĩnh không chi phí dispatch.

Phần sau ta đo chi phí ẩn khác của interface: Phần sau mổ xẻ chi phí boxing khi gán giá trị vào interface — vì sao gán một int vào any có thể cấp phát heap, khi nào Go tối ưu được, và cách tránh.