Go sống mười ba năm không có generics. Go 1.18 (2022) thêm vào, và cộng đồng vẫn đang tìm ranh giới dùng nó cho đúng.
Cú pháp
func Tong[T So](ds []T) T {
var s T
for _, v := range ds { s += v }
return s
}
[T So] khai một tham số kiểu T bị ràng buộc bởi So. Trong thân hàm, T dùng như một kiểu bình thường — kể cả var s T, và s nhận zero value của T.
Tong([]int) = 6
Tong([]float64) = 4
Không cần viết Tong[int](...) — Go suy ra tham số kiểu từ đối số. Chỉ khi không suy được mới phải khai rõ.
Constraint là interface
type So interface{ ~int | ~int64 | ~float64 }
Constraint chính là interface, nhưng mở rộng thêm cú pháp tập hợp kiểu. Dấu | là "hoặc".
Đây là điểm khác Java quan trọng: ở Java, <T extends Number> giới hạn theo cây kế thừa. Ở Go, constraint liệt kê tập kiểu cụ thể, và nhờ đó trình biên dịch biết được toán tử nào dùng được — + chạy được với T vì mọi kiểu trong tập đều hỗ trợ nó.
Dấu ~ là chi tiết quan trọng nhất
type NgayRieng int // kiểu tự định nghĩa, kiểu nền là int
Tong([]NgayRieng{1, 2})
~int cho phép kiểu tự định nghĩa: 3
~int nghĩa là "mọi kiểu có kiểu nền là int". Bỏ dấu ngã đi, chỉ viết int, thì NgayRieng không thoả mãn constraint — dù nó là int bên dưới.
Và vì Go rất hay định nghĩa kiểu riêng cho rõ nghĩa (type UserID int), gần như luôn nên viết ~. Quên nó là hàm generic của bạn tự nhiên không dùng được cho nửa số kiểu trong dự án.
Constraint có sẵn
import "golang.org/x/exp/constraints"
constraints.Ordered // mọi kiểu dùng được < > <= >=
constraints.Integer
constraints.Float
comparable // dựng sẵn trong ngôn ngữ, cho == và !=
any // không ràng buộc gì
comparable là từ khoá dựng sẵn, dùng cho khoá map và so sánh — nó theo đúng ranh giới "so sánh được" ở bài 5.
Từ Go 1.21, thư viện chuẩn có slices và maps viết bằng generics: slices.Sort, slices.Contains, maps.Keys. Dùng chúng thay vì tự viết.
Nhiều tham số kiểu
func Map[T, U any](ds []T, f func(T) U) []U
Map([]int{1,2}, ...) = [* **]
Đây là ví dụ điển hình cho thấy generics giải quyết được gì: trước 1.18, hàm này phải nhận []any và trả []any, và người gọi phải ép kiểu ở cả hai đầu.
Ba chỗ nên dùng
Cấu trúc dữ liệu chung — stack, queue, cây, cache. Đây là trường hợp rõ ràng nhất và ít gây tranh cãi nhất.
Hàm tiện ích trên slice và map — Map, Filter, Reduce, Keys, Values.
Ràng buộc quan hệ giữa các tham số — khi bạn muốn trình biên dịch bảo đảm hai tham số cùng kiểu.
Ba chỗ không nên dùng
Chỉ có một kiểu dùng thật. Viết generic "phòng khi sau này cần" là mã phức tạp hơn mà không đổi lấy gì. Thêm generic sau không phá vỡ gì cả.
Interface đã đủ. Nếu các kiểu chia sẻ hành vi, dùng interface. Generics hợp khi chúng chia sẻ cấu trúc nhưng không có hành vi chung — như "cộng được".
Method không nhận tham số kiểu riêng. Go không cho method có type parameter của riêng nó; chỉ kiểu mới có. Nên mẫu như func (s *Stack[T]) MapTo[U any](...) là không viết được. Đây là hạn chế thật và hay làm người từ Java bất ngờ.
Cái giá
Go cài đặt generics bằng cách trộn hai chiến lược: sinh mã riêng cho một số nhóm kiểu, và dùng từ điển kiểu lúc chạy cho phần còn lại. Nên nó không nhanh bằng viết tay cho một kiểu cụ thể, và cũng không chậm như dùng any cộng phản chiếu.
Với đa số mã, khác biệt không đáng kể. Nhưng nếu bạn viết thư viện trong đường chạy nóng, hãy đo — đừng giả định generic luôn miễn phí như template của C++.
Thử ba mươi giây
type ID int
func Tong[T int](ds []T) T { ... } // KHÔNG có dấu ngã
Tong([]ID{1, 2})
ID does not satisfy int (possibly missing ~ for int in int)
Thông báo lỗi của Go còn gợi ý luôn cách sửa. Thêm dấu ~ và nó chạy. Ba mươi giây, và bạn sẽ không bao giờ quên dấu ngã nữa.
Ngày mai: cái bẫy lớn nhất của Go — interface nil khác con trỏ nil.