Go 泛型集合入门:自己写 Set 和 Stack 时要注意什么

用 Set 和 Stack 两个小集合讲 Go 泛型的实际写法,包括 comparable 约束、零值可用、方法设计和测试。

Go 有了泛型以后,很多人第一反应是写集合库。虽然实际项目里不要急着造大而全的数据结构库,但写一个小 SetStack 很适合理解泛型:类型参数怎么写,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 近年的标准库已经提供了 slicesmapscmp 等泛型工具。你写集合类型前,先看看标准库能不能解决问题。比如排序切片:

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 变成另一种语言。它最适合去掉真实重复,同时保留清楚的类型。先写普通代码,重复出现后再抽象,通常是更稳的路线。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页