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

Ảnh chụp đoạn mã Go nền tối minh hoạ generics vs interface chi phí ở stencil và dictionary, ba cách một hàm nhiều kiểu, cách 1 generic trên kiểu giá trị func CongGen T So nhận a b T trả a cộng b compiler biết T lúc biên dịch, cách 2 interface đa hình động qua itab type Hinh interface DienTich trả float64 var h Hinh bằng HinhVuong gọi h.DienTich qua itab, cách 3 generic gọi method qua constraint đi đường dictionary func GopGen T Cong T nhận slice xs cộng dồn acc.Cong x, GC shape stenciling kiểu con trỏ dùng chung mã func Nhat T any nhận slice xs trả xs 0, Nhat của slice int stencil riêng cho int Nhat của slice float64 stencil riêng Nhat của slice con trỏ int và con trỏ string dùng chung một stencil go.shape con trỏ uint8, mọi con trỏ cùng hình dạng bộ nhớ nên một bản mã runtime truyền dictionary để phân biệt kiểu cụ thể, cốt lõi stencil là bản mã máy sinh theo GC shape của T kiểu giá trị mỗi kiểu một stencil pointer mọi con trỏ chung một stencil dictionary bảng runtime phân biệt kiểu thật interface đa hình động gọi method qua itab lúc chạy

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

Ảnh chụp bảng kết quả đo thật nền tối generic method nhanh gần gấp đôi interface con trỏ chung stencil, go test bench benchmem cộng go tool nm Go 1.23 arm64 10 core, benchmark bốn đường thực thi Generic value CongGen phép cộng 0,5925 ns 0 alloc, Interface dispatch itab đơn hình 0,5284 ns 0 alloc, Generic method dictionary GopGen 4,065 ns 0 alloc, Interface method itab cộng type assert 8,108 ns 0 alloc, cộng đơn generic value và interface đơn hình gần ngang nhau khoảng 0,5 ns cả hai inline hoặc devirtualize được, gọi method lặp generic 4,07 ns nhanh khoảng 2 lần interface 8,11 ns vì interface còn phải type assert trong vòng, GC shape stenciling bằng chứng từ bảng ký hiệu stencil mã máy 4 kiểu chỉ 3 bản Nhat go.shape con trỏ uint8 con trỏ int và con trỏ string chung Nhat go.shape float64 riêng Nhat go.shape int riêng, dictionary 4 bản mỗi kiểu cụ thể một cái dict Nhat con trỏ int dict Nhat con trỏ string dict Nhat float64 dict Nhat int, cốt lõi 4 kiểu 3 stencil mọi con trỏ gộp vào go.shape con trỏ uint8 4 dictionary runtime truyền phân biệt kiểu thật kiểu con trỏ có 1 lớp gián tiếp qua dict chậm hơn generic value nhanh nhất compiler biết kiểu tĩnh

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ề

  1. 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ào go.shape.*uint8, nhưng có bốn dictionary.
  2. 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.
  3. Đ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.