Generics vào Go từ 1.18, nhưng cách nó định nghĩa "kiểu nào được nhận" khác hẳn ngôn ngữ khác. Không có extends, không có ràng buộc kế thừa. Thay vào đó Go dùng một khái niệm gọn và chính xác: type set. Một constraint không mô tả "kiểu này có phương thức gì" mà mô tả "tập kiểu nào được phép thế vào". Hiểu type set là hiểu toàn bộ generics của Go. Bài này đo thật ba điều: type set hoạt động ra sao, vi phạm constraint xảy ra lúc nào, và generics đắt hơn hay rẻ hơn interface.

Constraint là một type set

Trong Go, mọi interface đều định nghĩa một type set — tập các kiểu thỏa mãn nó. Interface cũ (io.Reader) có type set là "mọi kiểu có phương thức Read". Generics mở rộng cú pháp interface để liệt kê kiểu trực tiếp:

type Số interface {
	~int | ~int64 | ~float64
}

func Tong[T Số](xs []T) T {
	var s T
	for _, x := range xs {
		s += x
	}
	return s
}

~int | ~int64 | ~float64 là một type set: tập gồm mọi kiểu có underlying là int, int64 hoặc float64. Dấu | là hợp (union). Dấu ~ là mấu chốt: ~int không chỉ là int mà là mọi kiểu có underlying type là int — kể cả kiểu bạn tự định nghĩa như type MyInt int. Không có ~, chỉ đúng int mới lọt.

Ảnh chụp đoạn mã Go nền tối minh hoạ type set và constraint trong generics, type Số interface gồm ~int hợp ~int64 hợp ~float64 là một type set tập kiểu được phép thế vào, func Tong T Số nhận slice xs cộng dồn trả về T một hàm chạy cho nhiều kiểu, dấu gạch đứng là hợp union dấu ngã là mọi kiểu có underlying type đó gồm cả kiểu tự định nghĩa MyInt, func Max T Ordered so sánh với constraint Ordered gồm ~int ~int64 ~float64 ~string, phép toán trong hàm generic phải hợp lệ cho MỌI kiểu trong type set cộng dùng được vì cả bốn kiểu đều cộng được, vi phạm constraint truyền chuỗi vào type set không có string là lỗi biên dịch string does not satisfy Só2 string missing, khác reflect bắt lỗi lúc chạy generics bắt lỗi lúc biên dịch giữ kiểu tĩnh an toàn tuyệt đối

Hình 1: Constraint Số là một type set gồm ~int | ~int64 | ~float64; hàm Tong[T Số] chạy cho mọi kiểu trong tập. Dấu ~ cho phép cả kiểu tự định nghĩa; phép += hợp lệ vì mọi kiểu trong set đều cộng được.

Quy tắc thân hàm generic: mọi phép toán bên trong phải hợp lệ cho tất cả kiểu trong type set. s += x biên dịch được vì cả int, int64, float64 đều hỗ trợ +. Nếu type set gồm cả ~bool, compiler từ chối += ngay — vì bool không cộng được. Type set không chỉ giới hạn đầu vào, nó còn quyết định thân hàm được viết gì.

Đo thật: type set chạy, và vi phạm là lỗi biên dịch

Chạy Tong với ba kiểu khác nhau và một Max dùng constraint Ordered (thêm ~string):

type MyInt int
type Ordered interface{ ~int | ~int64 | ~float64 | ~string }

func Max[T Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}
// Tong([]int{1,2,3})            -> 6
// Tong([]float64{1.5, 2.5})     -> 4
// Tong([]MyInt{10,20})          -> 30   (nhờ ~int)
// Max(3, 7) -> 7 ;  Max("a","b") -> b

Ảnh chụp bảng kết quả đo thật nền tối type set hoạt động vi phạm là lỗi biên dịch generics nhanh hơn interface, go run cộng go test bench Go 1.23 arm64 10 core, Tong int bằng 6 Tong float64 bằng 4 Tong MyInt bằng 30 nhờ ~int cho phép kiểu tự định nghĩa Max int bằng 7 Max string bằng b constraint Ordered gồm cả ~string, vi phạm constraint là lỗi biên dịch không phải panic truyền T chuỗi chuoi với constraint ~int hợp ~float64 báo string does not satisfy Só2 string missing khác reflect panic lúc chạy constraint bị compiler bắt lúc biên dịch an toàn kiểu tuyệt đối bug lộ lúc build không phải lúc production, generics vs interface cùng phép tổng 1000 phần tử Generics SumGen T Num 240 nano giây 0 cấp phát Interface SumIface slice any cộng type assert 301,7 nano giây 0 cấp phát generics nhanh hơn khoảng 25 phần trăm vì giữ kiểu tĩnh không type assert mỗi phần tử

Hình 2: Tong chạy đúng cho int, float64 và MyInt (nhờ ~int); Max dùng được cả string. Vi phạm constraint cho lỗi biên dịch string does not satisfy — khác reflect panic lúc chạy. Generics nhanh hơn interface any khoảng 25%.

Kết quả: Tong cho int ra 6, float64 ra 4, MyInt ra 30 — đúng ba kiểu qua một hàm. Max so sánh được cả số lẫn chuỗi vì Ordered gồm ~string.

Điểm quan trọng nhất là khi bạn vi phạm constraint. Truyền một chuỗi vào type set chỉ có ~int | ~float64:

type Só2 interface{ ~int | ~float64 }
func F[T Só2](x T) T { return x }
// F("chuoi")  ->  KHÔNG biên dịch

Compiler báo thẳng: string does not satisfy Só2 (string missing in ~int | ~float64). Đây là khác biệt cốt lõi so với reflect: reflect kiểm kiểu lúc chạy và panic khi sai; generics kiểm lúc biên dịch. Bug về kiểu lộ ra ngay lúc go build, không phải lúc code đã chạy trên production. Đó là an toàn kiểu tuyệt đối, không tốn một chu kỳ CPU nào lúc chạy.

Generics đắt hơn interface không?

Câu hỏi thực tế: nếu chỉ cần "một hàm nhiều kiểu", generics có đáng so với []any + type assertion không? Đo thật phép tổng 1.000 phần tử, hai cách:

type Num interface{ ~int | ~int64 | ~float64 }
func SumGen[T Num](xs []T) T { var s T; for _, x := range xs { s += x }; return s }

func SumIface(xs []any) int {
	s := 0
	for _, x := range xs {
		s += x.(int) // type assert MỖI phần tử
	}
	return s
}

Benchmark trong go-lab (Go 1.23, arm64, 10 core):

  • BenchmarkGenerics: 240,0 ns/op, 0 cấp phát.
  • BenchmarkInterface: 301,7 ns/op, 0 cấp phát.

Generics nhanh hơn khoảng 25%. Lý do: SumGen giữ kiểu tĩnh — compiler biết chính xác T là int nên s += x là phép cộng nguyên trực tiếp. SumIface phải làm x.(int) cho từng phần tử: mỗi lần là một phép kiểm itab và trích giá trị. Ở đây cả hai đều 0 cấp phát vì int đủ nhỏ để box không thoát ra heap trong vòng lặp này, nhưng phần type assertion lặp lại vẫn tốn thời gian rõ rệt. Generics thắng cả về tốc độ lẫn an toàn kiểu.

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

Generics không miễn phí về mã máy. Go compiler dùng chiến lược GC shape stenciling: các kiểu có cùng "hình dạng bộ nhớ" (ví dụ mọi con trỏ) dùng chung một bản mã, còn kiểu giá trị khác nhau (int vs float64) sinh bản mã riêng. Điều này khiến binary lớn hơn đôi chút và, với kiểu con trỏ, có một lớp dictionary gián tiếp — không phải lúc nào cũng nhanh như bạn kỳ vọng. Luôn đo, đừng giả định generics luôn thắng.

Interface vẫn đúng chỗ khi cần đa hình động thật. Nếu bạn cần chứa nhiều kiểu khác nhau trong cùng một slice và xử lý theo phương thức (không biết trước kiểu), interface là công cụ đúng. Generics giải bài toán "một thuật toán, nhiều kiểu biết trước lúc biên dịch", không thay thế đa hình động.

Đừng generic hóa quá sớm. Một constraint quá rộng (any) làm hàm chẳng làm được gì hữu ích bên trong; một constraint quá hẹp thì không tái dùng được. Type set nên khớp đúng những phép toán thân hàm cần — không hơn, không kém. Viết hàm cụ thể trước, trừu tượng hóa khi thấy lặp lại thật.

Ba ý mang về

  1. Constraint là một type set: ~int | ~float64 liệt kê tập kiểu được phép; dấu ~ gồm cả kiểu tự định nghĩa (MyInt lọt vào nhờ ~int), và mọi phép toán trong thân hàm phải hợp lệ cho tất cả kiểu trong tập.
  2. Vi phạm constraint là lỗi BIÊN DỊCH, không phải panic lúc chạy — string does not satisfy Só2 xuất hiện lúc go build. Khác hẳn reflect (kiểm lúc chạy), generics cho an toàn kiểu tuyệt đối mà không tốn chu kỳ CPU nào.
  3. Generics nhanh hơn interface any khoảng 25% (đo thật 240 vs 301,7 ns) vì giữ kiểu tĩnh, không type-assert mỗi phần tử — nhưng có chi phí mã máy (GC shape stenciling, dictionary cho kiểu con trỏ), nên vẫn phải đo.

Phần sau ta đi sâu vào cách compiler tự suy ra T mà bạn không cần khai: Phần sau mổ xẻ suy luận kiểu (type inference) trong generics — khi nào Go đoán được kiểu, khi nào bạn buộc phải viết rõ [int], và cơ chế đằng sau.