Vấn Đề: Địa Ngục Copy-Paste Trong Ngôn Ngữ Kiểu Tĩnh
Hãy hình dung một codebase Go điển hình. Có một hàm tìm giá trị nhỏ nhất trong slice số nguyên. Rồi ai đó cần làm tương tự với float64. Rồi lại int64. Đột nhiên bạn có MinInt, MinFloat64, MinInt64 — ba hàm làm đúng một việc nhưng với kiểu dữ liệu khác nhau.
Trước Go 1.18, các cách giải quyết đều khá tệ. Bạn hoặc là nhân bản code, hoặc dùng interface{} (tức any ngày xưa), đồng nghĩa với việc mất hết tính an toàn kiểu dữ liệu và phải rải type assertion khắp nơi trong codebase. Cả hai hướng đều khiến việc bảo trì khó hơn và dễ phát sinh lỗi hơn.
Vấn đề cốt lõi: hệ thống kiểu của Go, dù rất tốt về sự rõ ràng và an toàn, lại không có cách nào để diễn đạt “hàm này hoạt động với bất kỳ kiểu số nào.” Generics, ra mắt vào tháng 3 năm 2022 cùng với Go 1.18, đã giải quyết điều đó.
Các Khái Niệm Cốt Lõi Bạn Cần Biết
Type Parameters
Generics trong Go sử dụng type parameters — các placeholder khai báo trong dấu ngoặc vuông, được điền bằng kiểu thực khi gọi hàm.
func Min[T int | float64](a, b T) T {
if a < b {
return a
}
return b
}
Phần [T int | float64] có nghĩa là: “T có thể là int hoặc float64.” Gọi Min(3, 5) và Go tự suy ra rằng T là int. Không cần chỉ định kiểu tường minh.
Constraints: Trái Tim Của Go Generics
Một constraint định nghĩa những kiểu nào một type parameter có thể nhận. Đơn giản nhất là any — không giới hạn, tương đương với interface{}. Các constraint hữu ích hơn được xây dựng từ interface.
Go 1.21 bổ sung các package thư viện chuẩn cmp và slices với các constraint có sẵn. Để tự định nghĩa, bạn khai báo một interface:
type Number interface {
int | int8 | int16 | int32 | int64 |
float32 | float64
}
func Sum[T Number](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
Giờ đây Sum hoạt động với bất kỳ kiểu số nào. Truyền vào []int, []float64, hay []int32 — đều chạy được.
Toán Tử ~ Tilde: Bao Phủ Các Kiểu Tùy Chỉnh
Đây là điều dễ gây nhầm lẫn. Với một kiểu tùy chỉnh như type Celsius float64, constraint float64 đơn thuần sẽ không khớp với nó. Bạn cần dùng tilde:
type Temperature interface {
~float32 | ~float64
}
~float64 có nghĩa là “bất kỳ kiểu nào có underlying type là float64.” Điều này bao gồm cả float64 và mọi kiểu có tên được xây dựng trên nó — như Celsius.
Nhiều Type Parameters
Hàm có thể khai báo nhiều hơn một type parameter. Điều này hữu ích khi xây dựng các phép biến đổi kiểu map:
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
Biến đổi slice từ kiểu bất kỳ sang slice của kiểu bất kỳ khác. Không cần ép kiểu interface, không cần reflection.
Thực Hành: Xây Dựng Thư Viện Tiện Ích Tái Sử Dụng
Các package tiện ích nội bộ là nơi generics phát huy hiệu quả nhất. Thay vì mỗi nhóm lại tự cài lại các helper xử lý slice hay kiểu collection, bạn viết một lần rồi dùng chung. Dưới đây là một thư viện nhỏ nhưng thực tế để minh họa.
Hàm Filter Tổng Quát
package sliceutil
// Filter trả về slice mới chỉ chứa các phần tử mà
// hàm predicate trả về true.
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
Cách dùng:
evens := sliceutil.Filter([]int{1, 2, 3, 4, 5, 6}, func(n int) bool {
return n%2 == 0
})
// evens = [2, 4, 6]
long := sliceutil.Filter([]string{"go", "rust", "python", "c"}, func(s string) bool {
return len(s) > 2
})
// long = ["rust", "python"]
Một hàm xử lý được cả hai trường hợp. Không có type assertion. Không có runtime panic do ép kiểu sai.
Kiểu Set Tổng Quát
Trước khi có generics, hầu hết các nhóm hoặc dùng trực tiếp map[string]struct{} (chỉ dùng được với string) hoặc viết riêng một implementation Set cho từng kiểu (tốn công và dễ trùng lặp). Với generics, viết một lần là xong:
package collections
type Set[T comparable] struct {
items map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{items: make(map[T]struct{})}
}
func (s *Set[T]) Add(item T) {
s.items[item] = struct{}{}
}
func (s *Set[T]) Contains(item T) bool {
_, ok := s.items[item]
return ok
}
func (s *Set[T]) Remove(item T) {
delete(s.items, item)
}
func (s *Set[T]) Size() int {
return len(s.items)
}
// Union trả về set mới chứa tất cả phần tử từ cả hai set.
func Union[T comparable](a, b *Set[T]) *Set[T] {
result := NewSet[T]()
for k := range a.items {
result.Add(k)
}
for k := range b.items {
result.Add(k)
}
return result
}
Constraint comparable là built-in trong Go — nghĩa là kiểu đó hỗ trợ == và !=, điều kiện bắt buộc để dùng làm map key. Hoạt động với bất kỳ kiểu comparable nào:
intSet := collections.NewSet[int]()
intSet.Add(1)
intSet.Add(2)
intSet.Add(1)
fmt.Println(intSet.Size()) // 2
strSet := collections.NewSet[string]()
strSet.Add("go")
strSet.Add("rust")
fmt.Println(strSet.Contains("go")) // true
Cache Với Thời Gian Hết Hạn
Nâng cao hơn với một generic in-memory cache hỗ trợ TTL:
package cache
import (
"sync"
"time"
)
type entry[V any] struct {
value V
expiresAt time.Time
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]entry[V]
}
func New[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{items: make(map[K]entry[V])}
}
func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = entry[V]{value: value, expiresAt: time.Now().Add(ttl)}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok || time.Now().After(e.expiresAt) {
var zero V
return zero, false
}
return e.value, true
}
Cách dùng hoàn toàn có kiểu dữ liệu rõ ràng — không ép kiểu, không để lộ interface{} ra ngoài:
// Cache ánh xạ key kiểu string sang struct User
userCache := cache.New[string, User]()
userCache.Set("u:123", user, 5*time.Minute)
if u, ok := userCache.Get("u:123"); ok {
fmt.Println(u.Name)
}
Khi Nào Không Nên Dùng Generics
Biết khi nào nên bỏ qua generics cũng quan trọng không kém biết cách dùng chúng. Tránh dùng generics khi:
- Hàm chỉ làm việc với một kiểu cụ thể — hãy dùng thẳng kiểu đó luôn.
- Bạn thêm type parameter chỉ để tránh viết một hàm thứ hai — đôi khi hai hàm cụ thể rõ ràng hơn một hàm generic.
- Logic bên trong khác nhau đáng kể tùy theo kiểu — đó không phải là bài toán của generics, mà là của đa hình thông qua interface.
Một nguyên tắc hữu ích: nếu bạn có thể diễn đạt hành vi bằng interface thông thường và method dispatch, hãy làm vậy. Dùng đến generics khi cấu trúc của thuật toán giống nhau và chỉ có kiểu dữ liệu là thay đổi.
Tổng Kết
Khi bạn đã chuyển đổi được vài hàm tiện ích copy-paste sang phiên bản generic, sự khác biệt sẽ rõ ràng ngay. Ít trùng lặp hơn. Không có type assertion ẩn chứa nguy cơ runtime panic. Thư viện dùng chung mà các nhóm thực sự tái sử dụng thay vì âm thầm copy vào package riêng của mình.
Hãy bắt đầu từ những thứ nhỏ. Viết lại các helper bị trùng lặp nhiều nhất. Xây dựng một vài typed collection. Một khi bạn nắm được cách thiết kế constraint, bạn sẽ bắt đầu nhận ra các cơ hội tự nhiên trong toàn bộ codebase.
Nhóm phát triển Go đã cố ý giữ generics ở mức bảo thủ — đơn giản hơn C++ templates, ít “ma thuật” hơn trait system của Rust. Điều đó có nghĩa là ít sức mạnh hơn ở các trường hợp cực đoan, nhưng cũng có nghĩa là thành viên mới trong nhóm có thể đọc code generic mà không cần vượt qua một đường cong học tập dốc. Với đại đa số code Go trong môi trường production, đó chính xác là sự đánh đổi phù hợp.

