Go 有了泛型以后,很多人第一反应是写集合库。虽然实际项目里不要急着造大而全的数据结构库,但写一个小 Set 和 Stack 很适合理解泛型:类型参数怎么写,comparable 是什么,方法接收者怎么设计,零值能不能直接用。
本文不追求覆盖所有集合,只用两个例子讲入门时最常见的判断。
Set 的基本结构
Set 表示不重复集合。Go 里通常用 map 实现:
type Set[T comparable] struct {
items map[T]struct{}
}
为什么是 T comparable?因为 map 的 key 必须可比较。字符串、整数、指针、结构体中所有字段可比较时都可以作为 key;切片、map、函数不可以。
构造函数:
func NewSet[T comparable](values ...T) Set[T] {
s := Set[T]{items: make(map[T]struct{}, len(values))}
for _, v := range values {
s.Add(v)
}
return s
}
添加和判断:
func (s Set[T]) Add(v T) {
if s.items == nil {
s.items = make(map[T]struct{})
}
s.items[v] = struct{}{}
}
func (s Set[T]) Has(v T) bool {
_, ok := s.items[v]
return ok
}
这里有个问题:Add 用值接收者时,如果 items 是 nil,s.items = make(...) 只改了副本,外部 set 仍然 nil。为了让零值可用,Add 应该用指针接收者:
func (s *Set[T]) Add(v T) {
if s.items == nil {
s.items = make(map[T]struct{})
}
s.items[v] = struct{}{}
}
这是初学者很容易忽略的地方。map 本身是引用类型,但结构体字段赋值仍然受接收者影响。
删除和长度
func (s *Set[T]) Delete(v T) {
if s.items == nil {
return
}
delete(s.items, v)
}
func (s Set[T]) Len() int {
return len(s.items)
}
Len 可以用值接收者,因为它不修改结构。Delete 用指针接收者保持风格一致,也方便以后扩展。
转成切片:
func (s Set[T]) Values() []T {
values := make([]T, 0, len(s.items))
for v := range s.items {
values = append(values, v)
}
return values
}
注意 map 遍历顺序不稳定。Values 返回的顺序不能依赖。如果调用方需要排序,就让调用方排序,或者提供专门的 SortedValues。
Stack 的基本结构
Stack 后进先出:
type Stack[T any] struct {
items []T
}
这里用 any,因为栈里的元素不需要作为 map key,不要求可比较。
方法:
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
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)
}
Pop 返回 (T, bool),而不是空栈时 panic。这样调用方可以自然处理:
v, ok := stack.Pop()
if !ok {
return errors.New("empty stack")
}
对于入门集合,少用 panic。panic 适合不可恢复的程序错误,不适合作为普通控制流。
零值可用
好的小类型通常希望零值可用:
var s Set[string]
s.Add("go")
var stack Stack[int]
stack.Push(1)
这会让调用方少记一个构造函数。当然,构造函数仍然有价值,比如预分配容量:
func NewStack[T any](capacity int) Stack[T] {
return Stack[T]{items: make([]T, 0, capacity)}
}
设计 API 时可以两者兼顾:零值可用,构造函数提供优化或初始值。
测试泛型类型
测试 Set:
func TestSet(t *testing.T) {
var s Set[string]
s.Add("go")
s.Add("go")
if !s.Has("go") {
t.Fatal("expected value")
}
if s.Len() != 1 {
t.Fatalf("len = %d", s.Len())
}
}
测试 Stack:
func TestStackPop(t *testing.T) {
var s Stack[int]
s.Push(1)
s.Push(2)
got, ok := s.Pop()
if !ok || got != 2 {
t.Fatalf("Pop() = %d, %v", got, ok)
}
}
泛型代码不需要为每种类型都测一遍。选一两个代表类型即可。真正要测的是行为:去重、后进先出、空集合处理、零值可用。
不要过度抽象
写完 Set 和 Stack 后,很容易继续写 List、Queue、Tree、Optional、Result。学习可以,项目里要克制。Go 标准库和普通切片、map 已经能解决很多问题。泛型集合应该在重复代码真的明显时再引入。
比如只在一个函数里需要去重:
seen := map[string]struct{}{}
这比引入一个通用 Set 更直接。如果多个包都在重复写同样逻辑,再考虑抽出来。
和标准库 slices、maps 配合
Go 近年的标准库已经提供了 slices、maps、cmp 等泛型工具。你写集合类型前,先看看标准库能不能解决问题。比如排序切片:
values := []int{3, 1, 2}
slices.Sort(values)
复制 map:
clone := maps.Clone(original)
这些工具覆盖了很多常见操作。自定义 Set 的价值在于表达业务意图,比如“这里就是不重复集合”,而不是为了替代所有 map 用法。很多时候,map[string]struct{} 加几行局部代码就足够清楚。
如果你真的把 Set 放进公共包,文档要说明顺序不稳定、是否并发安全、零值是否可用。上面的实现不是并发安全的,多个 goroutine 同时 Add 仍然需要外部加锁。泛型解决的是类型复用,不自动解决并发问题。
如果确实需要并发安全集合,可以在外层组合 mutex,而不是把所有场景都强行做成带锁版本。无锁版本更轻,带锁版本更稳,二者面对的是不同需求。
这个边界要写进注释。
小结
Go 泛型适合写小而明确的通用类型。Set[T comparable] 展示了约束的意义,Stack[T any] 展示了不需要约束时的写法。方法接收者要根据是否修改结构选择,尤其要注意零值可用时的初始化问题。
泛型不是为了让 Go 变成另一种语言。它最适合去掉真实重复,同时保留清楚的类型。先写普通代码,重复出现后再抽象,通常是更稳的路线。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。