Hình dung một hàm generic như một cái khuôn có chừa sẵn một ô trống cho kiểu. Bạn cắt khuôn đúng một lần, rồi dập nó lên nhiều vật liệu khác nhau — int, float64, cả kiểu bạn tự đặt. Constraint là tờ quy cách đính kèm, liệt kê đúng những vật liệu vừa khuôn. Go sống mười ba năm không có cái khuôn này. 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 — cái ô trống và tờ quy cách của nó. 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ó. Tờ quy cách không nói "con cháu của nhà Số", nó nói thẳng "int, int64, float64 — đúng ba loại này".
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. Dấu ngã chính là dòng chữ nhỏ trên tờ quy cách: "kể cả vật liệu bạn đã dán nhãn riêng nhưng ruột vẫn là loại này".
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++.
Nếu muốn khắc cái bẫy phổ biến nhất vào đầu trong ba mươi giây, bỏ dấu ngã đi và để trình biên dịch dạy bạn:
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 — và bạn sẽ không bao giờ quên dấu ngã nữa.
Mẫu số chung
Generics nhìn thì là một tính năng, nhưng thật ra là hai quyết định độc lập mặc chung một cái áo — và gần như mọi bất ngờ giữa các ngôn ngữ đều truy về việc chúng chọn khác nhau ở một trong hai.
Quyết định thứ nhất: làm sao nói T được phép là gì? Ba trường phái. Cây kế thừa danh định: Java <T extends Number>, C# — "con cháu của lớp này". Hành vi / trait: Rust T: Add, typeclass của Haskell Num a => — "miễn làm được việc này". Tập kiểu cụ thể: chính là Go, liệt kê thẳng các kiểu. Điều thú vị là Go cố ý tách đôi: interface lo hành vi, còn constraint kiểu-tập lo "chia sẻ cấu trúc / cùng toán tử" — đúng cái ranh giới ở mục "khi nào không nên dùng".
Quyết định thứ hai: sinh mã riêng cho mỗi kiểu, hay xoá về một bản? Monomorphization — C++ template và Rust sinh một bản mã cho mỗi kiểu: nhanh, nhưng phình nhị phân. Type erasure — Java xoá kiểu về một bản duy nhất, phải đóng hộp và mất thông tin kiểu lúc chạy. Go đi đường lai (stencil theo hình dạng GC cộng từ điển), nên lời khuyên "hãy đo, đừng tưởng miễn phí như C++" ở trên chính là hệ quả trực tiếp của việc nó không monomorphize hết.
Sợi chỉ chung đáng mang theo: khi gặp generics của một ngôn ngữ mới, hỏi đúng hai câu — nó ràng buộc kiểu theo kiểu nào (kế thừa, trait, hay tập kiểu), và nó hiện thực bằng nhân bản hay xoá kiểu. Trả lời được hai câu đó là bạn đoán trước được cả điểm mạnh lẫn cái bẫy của nó, từ ~ của Go tới lỗi erasure của Java tới phình mã của C++.
Ngày mai: cái bẫy lớn nhất của Go — interface nil khác con trỏ nil.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau báo lỗi biên dịch gì, và tại sao?
type ID int
func Tong[T int](ds []T) T {
var s T
for _, v := range ds { s += v }
return s
}
func main() {
Tong([]ID{1, 2})
}
Đáp án
Lỗi: ID does not satisfy int (possibly missing ~ for int in int). Constraint viết là int trần (không có dấu ngã ~) chỉ chấp nhận đúng kiểu int, không chấp nhận ID dù kiểu nền của ID là int. Muốn ID thoả mãn thì phải viết constraint là ~int, nghĩa là "mọi kiểu có kiểu nền là int".
Bài 2 (sửa lỗi). Sửa constraint dưới đây để hàm Tong dùng được cho cả int, float64, và các kiểu tự định nghĩa có kiểu nền là một trong hai loại đó (ví dụ type Diem float64).
type So interface{ int | float64 }
func Tong[T So](ds []T) T {
var s T
for _, v := range ds { s += v }
return s
}
Đáp án
type So interface{ ~int | ~float64 }
Thêm dấu ngã ~ trước mỗi kiểu trong constraint. Vì Go rất hay định nghĩa kiểu riêng cho rõ nghĩa (type Diem float64), gần như luôn nên viết ~ — quên nó khiến hàm generic tự nhiên không dùng được cho nửa số kiểu tự định nghĩa trong dự án.
Bài 3 (vận dụng thực tế). Bạn cần viết một hàm Map[T, U any](ds []T, f func(T) U) []U để biến đổi một slice sang kiểu khác. Trước Go 1.18 (không có generics), hàm này phải viết thế nào, và generics giải quyết được vấn đề gì ở đây?
Đáp án
Trước 1.18, hàm phải nhận và trả []any:
func Map(ds []any, f func(any) any) []any
và người gọi phải tự ép kiểu ở cả hai đầu (đưa phần tử cụ thể vào any, rồi ép ngược lại từ any ra kiểu cụ thể sau khi nhận kết quả) — vừa mất an toàn kiểu lúc biên dịch, vừa dễ panic nếu ép sai. Với generics:
func Map[T, U any](ds []T, f func(T) U) []U {
out := make([]U, len(ds))
for i, v := range ds { out[i] = f(v) }
return out
}
Trình biên dịch kiểm tra kiểu đầy đủ ở cả đầu vào lẫn đầu ra, không cần ép kiểu thủ công.
Bài 4 (bẫy/đánh đổi). Giải thích vì sao đoạn mã sau không viết được trong Go, và nêu hạn chế cụ thể của generics được nhắc trong bài liên quan tới nó.
type Stack[T any] struct{ items []T }
func (s *Stack[T]) MapTo[U any](f func(T) U) *Stack[U] {
// ...
}
Đáp án
Go không cho phép method có tham số kiểu (type parameter) riêng của chính nó — chỉ kiểu mới có tham số kiểu. Stack[T] có tham số kiểu T ở cấp kiểu, nhưng method MapTo không thể tự khai thêm [U any] cho riêng nó. Đây là hạn chế thật của Go và thường làm người quen generics/template ở Java hoặc C++ bất ngờ. Muốn có hành vi tương tự, phải viết MapTo như một hàm độc lập (không phải method) nhận Stack[T] làm tham số.
Bài 5 (đọc hiểu — khi nào không nên dùng generics). Bài viết nêu ba chỗ không nên dùng generics. Cho tình huống sau: bạn có một hàm chỉ từng và sẽ luôn được gọi với đúng một kiểu cụ thể (Report) duy nhất, nhưng đồng nghiệp muốn viết nó thành generic "phòng khi sau này cần thêm kiểu khác". Theo bài viết, lời khuyên đúng là gì?
Đáp án
Không nên viết generic trong trường hợp này — "chỉ có một kiểu dùng thật" là một trong ba chỗ không nên dùng generics theo bài. Viết generic "phòng khi sau này cần" làm mã phức tạp hơn mà không đổi lấy gì cụ thể ngay lúc này. Quan trọng hơn, bài viết chỉ rõ: thêm generic sau này không phá vỡ gì cả, nên có thể giữ hàm cụ thể (nhận thẳng Report) và chỉ generic hoá khi thực sự có nhu cầu thứ hai xuất hiện.