Generics trong Go: type parameter qua ví dụ

1. Tổng quan

Từ Go 1.18, bạn có thể viết hàm và kiểu dữ liệu với type parameter. Nhờ vậy, bạn không phải chép lại cùng một logic cho int, float64, string, và cũng không phải ép kiểu từ interface{}.

Bài này đi từ vấn đề ban đầu đến hàm generic, constraint và ký hiệu ~. Sau đó là kiểu generic như Stack[T] và Cache[K, V], cách viết test, cùng các lỗi hay gặp. Đọc xong, bạn sẽ biết khi nào nên dùng generics và khi nào một interface thông thường là đủ.

Mọi ví dụ dùng Go 1.22 trở lên. Package cmp có từ Go 1.21. File go.mod tối thiểu:

module example.com/shop

go 1.22

Không cần thư viện ngoài. Mọi thứ trong bài nằm trong standard library.

2. Vấn đề trước khi có generics

Giả sử bạn cần tính tổng số lượng hàng (int) và tổng tiền (float64). Trước Go 1.18, bạn thường viết hai hàm giống hệt nhau:

func SumInts(xs []int) int {
	var total int
	for _, x := range xs {
		total += x
	}
	return total
}

func SumFloats(xs []float64) float64 {
	var total float64
	for _, x := range xs {
		total += x
	}
	return total
}

Cách còn lại là nhận []interface{} rồi dùng type switch. Cách này mất type safety. Lỗi chỉ lộ ra lúc runtime, và bạn phải ép kiểu ở mọi nơi gọi hàm.

Generics giải quyết đúng chỗ này: viết logic một lần, compiler vẫn kiểm tra kiểu cho bạn.

3. Hàm generic đầu tiên

3.1. Cú pháp type parameter

Type parameter được khai báo trong ngoặc vuông, ngay sau tên hàm:

package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](xs []T) T {
	var total T
	for _, x := range xs {
		total += x
	}
	return total
}

func main() {
	quantities := []int{2, 1, 5}
	prices := []float64{120.5, 99.9, 15}

	fmt.Println(Sum(quantities))
	fmt.Println(Sum(prices))
}

Giải thích từng phần:

  • [T Number] khai báo type parameter T. T phải thỏa constraint Number.
  • Number là một interface chứa type set: int, int64, float64 và các kiểu dẫn xuất từ chúng (ý nghĩa của ~ nằm ở mục 4.3).
  • var total T tạo zero value của T, tức 0 hoặc 0.0.
  • Toán tử += hợp lệ vì mọi kiểu trong type set đều hỗ trợ phép cộng.

Kết quả:

8
235.4

3.2. Type inference

Bạn có để ý là ta gọi Sum(quantities) chứ không phải Sum[int](quantities)? Compiler tự suy ra T từ đối số. Đó là type inference.

Bạn chỉ cần ghi tường minh khi compiler không đủ thông tin để suy luận, ví dụ khi type parameter chỉ xuất hiện ở kiểu trả về:

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Println(Zero[string]() == "") // true
}

3.3. Nhiều type parameter: Map và Filter

Đây là hai hàm tiện ích phổ biến nhất. Ta áp dụng chúng lên danh sách Order:

package main

import "fmt"

type Order struct {
	ID       string
	Customer string
	Total    float64
}

func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, 0, len(xs))
	for _, x := range xs {
		out = append(out, f(x))
	}
	return out
}

func Filter[T any](xs []T, keep func(T) bool) []T {
	var out []T
	for _, x := range xs {
		if keep(x) {
			out = append(out, x)
		}
	}
	return out
}

func main() {
	orders := []Order{
		{"A01", "Lan", 250_000},
		{"A02", "Minh", 1_200_000},
		{"A03", "Lan", 890_000},
	}

	big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
	ids := Map(big, func(o Order) string { return o.ID })

	fmt.Println(ids)
}
  • Map[T, U any] có hai type parameter, nên kiểu đầu vào và đầu ra có thể khác nhau.
  • any là alias của interface{}. Nó cho phép mọi kiểu, nhưng bạn không gọi được toán tử nào trên giá trị đó ngoài gán và truyền đi.
  • Compiler suy ra T = Order và U = string từ hàm func(o Order) string.

Kết quả:

[A02 A03]

4. Constraint: giới hạn những gì T làm được

Constraint quyết định bạn được làm gì với giá trị kiểu T bên trong hàm.

4.1. comparable

comparable là constraint có sẵn. Nó cho phép dùng == và !=, và đây cũng là điều kiện để T làm key của map:

func Unique[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	var out []T
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]

Struct Order ở trên cũng là comparable, vì mọi field của nó đều so sánh được. Struct chứa slice, map hoặc func thì không thỏa comparable, và compiler sẽ báo lỗi.

4.2. cmp.Ordered

Khi cần so sánh lớn hơn hoặc nhỏ hơn, hãy dùng cmp.Ordered từ standard library:

import "cmp"

func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
	var best T
	if len(xs) == 0 {
		return best, false
	}
	best = xs[0]
	for _, x := range xs[1:] {
		if key(x) > key(best) {
			best = x
		}
	}
	return best, true
}

// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
  • K cmp.Ordered cho phép dùng > trên giá trị khóa.
  • Hàm trả về (T, bool) để xử lý slice rỗng mà không cần panic.

Lưu ý: Với slice số đơn giản, bạn không cần tự viết. Go 1.21 đã có hàm built-in max/min, và package slices có sẵn slices.Max, slices.Contains, slices.Sort. Hãy kiểm tra slices và maps trước khi tự viết tiện ích generic.

4.3. Ký hiệu ~ (tilde)

Trong domain thật, bạn hay định nghĩa kiểu riêng cho tiền:

type VND int64

func main() {
	payments := []VND{150_000, 350_000}
	fmt.Println(Sum(payments)) // 500000
}

Dòng này compile được là nhờ ~int64 trong Number. Ký hiệu ~int64 nghĩa là “mọi kiểu có underlying type là int64”. Nếu bạn viết int64 không có ~, thì VND không thỏa constraint, và compiler báo:

VND does not satisfy Number (possibly missing ~ for int64 in Number)

Quy tắc đơn giản: khi viết constraint cho các kiểu cơ bản, hầu như lúc nào bạn cũng nên dùng ~.

4.4. Constraint kết hợp method

Constraint cũng có thể yêu cầu method, giống interface thông thường:

type Identifiable interface {
	comparable
	Key() string
}

Kiểu nào muốn thỏa Identifiable thì vừa phải so sánh được, vừa phải có method Key() string.

5. Kiểu generic

5.1. Stack[T]

Type parameter không chỉ dùng cho hàm mà còn cho struct:

type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func (s *Stack[T]) Len() int { return len(s.items) }

Cách dùng:

var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})

o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
  • Receiver ghi là *Stack[T], phải kèm [T].
  • Khi khai báo biến, bạn phải chỉ rõ kiểu: Stack[Order]. Không có inference ở chỗ này.
  • Zero value của Stack[Order] dùng được ngay, vì append hoạt động tốt trên slice nil.

5.2. Cache[K, V] an toàn cho nhiều goroutine

Ví dụ sát thực tế hơn: một cache in-memory dùng chung cho Customer và Order.

package cache

import "sync"

type Cache[K comparable, V any] struct {
	mu   sync.RWMutex
	data map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{data: make(map[K]V)}
}

func (c *Cache[K, V]) Put(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}
type Customer struct {
	ID   int
	Name string
}

customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})

orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})

c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true

Một implementation phục vụ hai loại dữ liệu. customers.Get trả về đúng Customer, không cần type assertion. Nếu bạn lỡ Put một Order vào customers, code sẽ không compile.

6. Viết test cho code generic

Go dùng package testing chuẩn. Table-driven test vẫn hoạt động bình thường với hàm generic. Mỗi bảng test ứng với một kiểu cụ thể:

package main

import "testing"

func TestSum(t *testing.T) {
	ints := []struct {
		name string
		in   []int
		want int
	}{
		{"empty", nil, 0},
		{"positive", []int{2, 1, 5}, 8},
		{"mixed", []int{10, -3}, 7},
	}
	for _, tc := range ints {
		t.Run(tc.name, func(t *testing.T) {
			if got := Sum(tc.in); got != tc.want {
				t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}

	if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
		t.Errorf("Sum VND = %d, want 500000", got)
	}
}

func TestStackPopEmpty(t *testing.T) {
	var s Stack[string]
	v, ok := s.Pop()
	if ok || v != "" {
		t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
	}
}

Chạy test:

go test ./... -v
=== RUN   TestSum
=== RUN   TestSum/empty
=== RUN   TestSum/positive
=== RUN   TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN   TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS

Hãy test ít nhất một kiểu dẫn xuất như VND. Đây là cách nhanh nhất để phát hiện constraint thiếu ~.

7. Lỗi hay gặp và khi nào không nên dùng

Method không có type parameter riêng. Đoạn sau không compile:

// lỗi: methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }

Hãy viết thành hàm top-level: func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].

Không dùng type switch trực tiếp trên T. Muốn rẽ nhánh theo kiểu, bạn phải chuyển qua any trước: switch v := any(x).(type) { ... }. Nếu bạn thấy mình làm việc này thường xuyên, có lẽ generics không phải công cụ phù hợp.

Constraint có type set không dùng làm kiểu biến được. var n Number sẽ báo lỗi. Interface chứa | hoặc ~ chỉ dùng được ở vị trí constraint.

comparable với interface có thể panic. Nếu K là một kiểu interface và giá trị động bên trong là slice, phép so sánh hoặc dùng làm key map sẽ panic lúc runtime.

Lưu ý: Generics không tự động nhanh hơn interface. Compiler Go dùng kỹ thuật “GC shape stenciling” kèm dictionary. Các kiểu con trỏ khác nhau có thể dùng chung một bản code, và lời gọi method qua constraint vẫn có chi phí gián tiếp. Nếu hiệu năng quan trọng, hãy đo bằng benchmark thay vì đoán.

Khi nào KHÔNG nên dùng generics:

  • Khi logic phụ thuộc vào hành vi (method), không phụ thuộc vào kiểu. Ví dụ PaymentService nhận một Notifier có method Send. Interface thường gọn và dễ đọc hơn.
  • Khi chỉ có một kiểu cụ thể dùng hàm đó. Đừng generic hóa “phòng khi sau này cần”.
  • Khi slices, maps, cmp đã có sẵn hàm bạn cần.

Một quy tắc dễ nhớ: nếu bạn đang viết cùng một đoạn code cho nhiều kiểu, và chỉ khác nhau ở tên kiểu, thì đó là lúc dùng generics.

8. Kết luận

Bài viết đã đi qua các phần chính của generics trong Go:

  • Khai báo type parameter [T C] và để compiler tự suy ra kiểu.
  • Dùng các constraint any, comparable, cmp.Ordered, và tự định nghĩa constraint với ~ để hỗ trợ kiểu dẫn xuất như VND.
  • Xây dựng kiểu generic Stack[T] và Cache[K, V] an toàn cho nhiều goroutine.
  • Viết table-driven test cho hàm generic.
  • Nhận diện các giới hạn: method không có type parameter riêng, constraint không phải kiểu biến, và generics không thay thế interface.

Hãy bắt đầu từ chỗ code bị lặp lại thật sự trong project của bạn. Viết generic ở mức tối thiểu, ưu tiên standard library, và luôn kèm test cho ít nhất hai kiểu khác nhau.

golanggenericstype parameterconstraint