Bài trước ta đo được generics nhanh hơn interface any khoảng 25% cho phép tổng. Nhưng đó là một góc hẹp. Câu hỏi kỹ sư thực sự phải trả lời là: generics cài đặt thế nào ở tầng mã máy, và khi nào cái giá của nó lộ ra? Câu trả lời nằm ở hai khái niệm Go dùng để dịch generics: GC shape stenciling và dictionary. Hiểu hai thứ này, bạn thôi hỏi "generics hay interface nhanh hơn" một cách chung chung và bắt đầu đoán đúng cái nào thắng trong từng tình huống.
Go dịch generics thế nào
Có hai thái cực khi cài đặt generics. C++ dùng monomorphization: mỗi kiểu cụ thể sinh một bản mã hoàn toàn riêng — nhanh nhất nhưng binary phình to. Java dùng type erasure: một bản mã duy nhất, mọi thứ là Object, boxing khắp nơi — gọn nhưng chậm. Go chọn đường giữa gọi là GC shape stenciling: các kiểu có cùng "hình dạng bộ nhớ" (GC shape) dùng chung một bản mã (stencil), và runtime truyền vào một dictionary để phân biệt kiểu cụ thể khi cần.
"GC shape" là cách bộ thu gom rác nhìn một kiểu: nó lớn bao nhiêu byte, và những byte nào là con trỏ. Mọi kiểu con trỏ (*int, *string, *User) có cùng shape — 8 byte, toàn bộ là một con trỏ — nên chúng gộp chung một stencil. Còn int và float64 có shape khác nhau (không con trỏ, nhưng compiler sinh mã số học khác) nên mỗi kiểu một stencil riêng.
func Nhat[T any](xs []T) T { return xs[0] }
Nhat([]int{...}) // stencil riêng cho int
Nhat([]float64{...}) // stencil riêng cho float64
Nhat([]*int{...}) // *int ┐ chung một stencil
Nhat([]*string{...}) // *string ┘ go.shape.*uint8

Hình 1: Ba đường thực thi — generic value, interface qua itab, generic method qua dictionary. Với Nhat, kiểu giá trị sinh stencil riêng còn mọi con trỏ gộp vào go.shape.*uint8; runtime truyền dictionary để biết kiểu thật.
Bằng chứng: soi bảng ký hiệu
Đây không phải lý thuyết — nó nằm ngay trong binary. Biên dịch Nhat gọi với bốn kiểu (int, float64, *int, *string) rồi soi bằng go tool nm:
# Stencil mã máy — 4 kiểu nhưng CHỈ 3 bản:
main.Nhat[go.shape.*uint8] // *int VÀ *string chung một bản
main.Nhat[go.shape.float64]
main.Nhat[go.shape.int]
# Dictionary — 4 bản, mỗi kiểu cụ thể một cái:
main..dict.Nhat[*int] main..dict.Nhat[*string]
main..dict.Nhat[float64] main..dict.Nhat[int]
Bốn kiểu chỉ sinh ba stencil vì *int và *string cùng shape go.shape.*uint8. Nhưng có bốn dictionary — mỗi kiểu cụ thể một cái. Dictionary là bảng mà runtime truyền vào lúc gọi, chứa thông tin kiểu thật (con trỏ tới *int thật, *string thật) để stencil dùng chung biết đang làm việc với kiểu nào khi cần. Đây chính là cái giá của kiểu con trỏ: một lớp gián tiếp qua dictionary mà kiểu giá trị (sinh mã riêng) không phải trả.
Đo thật: bốn đường thực thi
Benchmark bốn cách, cùng go-lab (Go 1.23, arm64):
func CongGen[T So](a, b T) T { return a + b } // generic value
var h Hinh = HinhVuong{2}; h.DienTich() // interface dispatch
func GopGen[T Cong[T]](xs []T) T { /* acc.Cong(x) */ } // generic method (dict)
func GopIface(xs []Congg) Congg { /* x.CongI(...) */ } // interface method

Hình 2: Cộng đơn giản — generic value (0,5925 ns) và interface đơn-hình (0,5284 ns) gần ngang nhau. Gọi method lặp — generic qua dictionary (4,065 ns) nhanh gần 2x interface method (8,108 ns). Tất cả 0 cấp phát.
Hai điều rút ra từ số liệu thật:
Với phép toán đơn giản, cả hai gần ngang nhau (~0,5 ns). CongGen và HinhVuong.DienTich() đều rơi vào vùng compiler tối ưu tốt: generic value được sinh mã trực tiếp, còn interface đơn-hình (chỉ một kiểu cụ thể trong vòng lặp) được devirtualize — compiler nhận ra kiểu cụ thể và gọi thẳng, bỏ qua itab. Ở quy mô này khác biệt là nhiễu.
Với vòng lặp gọi method, generic thắng gần gấp đôi. GopGen (4,065 ns) so với GopIface (8,108 ns). Lý do trung thực: bản interface phải làm o.(DiemI) — một type assertion — cho mỗi lần cộng bên trong vòng, còn bản generic giữ kiểu tĩnh nên không cần. Đây là điểm cần thành thật: so sánh này có lợi cho generic vì bài toán "gộp cùng kiểu" đúng là thế mạnh của nó. Nếu bạn thật sự cần chứa nhiều kiểu khác nhau, interface là bắt buộc và không có gì để so.
Đánh đổi cần cân nhắc
Kiểu con trỏ trong generic không miễn phí. Vì mọi con trỏ chung một stencil và phân biệt qua dictionary, một hàm generic thao tác nhiều trên *T có thêm lớp gián tiếp mà phiên bản viết tay cho kiểu cụ thể không có. Trong vài trường hợp cực nóng, generic trên con trỏ có thể chậm hơn interface được devirtualize tốt. Luôn benchmark chính bài toán của bạn, đừng suy từ "generics luôn nhanh hơn".
Binary lớn hơn theo số shape, không theo số kiểu. Vì kiểu cùng shape chung mã, dùng generic với 10 kiểu con trỏ khác nhau chỉ sinh một stencil — binary không phình như C++ monomorphization. Nhưng mỗi kiểu giá trị riêng (int8, int16, int32, int64, float32, float64...) vẫn sinh bản riêng. Generic trên nhiều kiểu số vẫn làm binary lớn lên.
Interface vẫn là công cụ đúng cho đa hình động thật. Khi kiểu chỉ biết lúc chạy (plugin, decode JSON vào any, danh sách heterogeneous), interface là câu trả lời — generics không giải được bài đó vì nó cần biết kiểu lúc biên dịch. Chọn generics khi tập kiểu đóng và biết trước; chọn interface khi cần mở rộng động.
Ba ý mang về
- Go dịch generics bằng GC shape stenciling: kiểu cùng "hình dạng bộ nhớ" chung một bản mã, phân biệt bằng dictionary lúc chạy — đo thật từ bảng ký hiệu, bốn kiểu (
int,float64,*int,*string) chỉ sinh ba stencil vì hai con trỏ gộp vàogo.shape.*uint8, nhưng có bốn dictionary. - Chi phí thật của generic nằm ở kiểu con trỏ: chúng đi qua một lớp gián tiếp dictionary mà kiểu giá trị (sinh mã riêng) không phải trả — nên "generics luôn nhanh hơn interface" là sai, phải benchmark từng ca.
- Đo thật cho thấy tùy bài toán: phép cộng đơn giản generic value và interface đơn-hình gần ngang (~0,5 ns nhờ devirtualize), nhưng vòng lặp gọi method thì generic (4,065 ns) nhanh gần 2x interface (8,108 ns) vì tránh được type assertion — chọn generics khi biết kiểu, interface khi cần đa hình động.
Phần sau ta chuyển sang một tính năng mới cùng thời generics đang định hình lại cách viết vòng lặp: Phần sau mổ xẻ iterator range-over-func — cơ chế for range trên hàm, chi phí, và khi nào nó thay được channel.