Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

Go 1.28 泛型容器实战:Set、Map、Heap 选型与性能对比

Go 1.28 把泛型容器提案 #80590 带进了标准库:container/setcontainer/hashcontainer/orderedcontainer/heap/v2。关于这些子提案的设计动机与 binary method problem,社区已有深入解读。本文换一个视角:工程落地时,这些新容器到底该怎么选?什么时候用 set.Set[T],什么时候还得回到 map[T]struct{}ordered.Map 真的能替代 map + sort 吗?

文章基于 Go 1.28 当前实现,给出选型决策树、可复现的 benchmark 以及迁移建议。

一、四件套定位

核心抽象底层实现适用场景
container/setSet[T comparable]透明 map[T]struct{}仅需去重与成员检查
container/hashSet[T] / Map[K,V]自定义 Hash 函数 + 开放寻址需要稳定哈希或非 comparable 键
container/orderedMap[K,V]平衡二叉搜索树需要按 key 顺序遍历
container/heap/v2Heap[T]切片 + 泛型约束优先队列,且需要索引追踪

注:container/set 的“透明表示”是指 Set[T] 本质上就是 map[T]struct{} 的类型别名或等价封装,零值可用,与 map 内存布局一致。

二、选型决策树

text
文字版架构图:Go 1.28 容器选型决策树

                        是否需要容器?

            ┌─────────────────┼─────────────────┐
            │                 │                 │
        仅需去重/        需要有序访问          需要优先队列
        成员检查              │                 │
            │          ┌──────┴──────┐          │
            ▼          │             │          ▼
       comparable?   key 可比较?    自定义序?   是否需要 Update?
            │          │             │          │
       ┌────┴────┐    │             │     ┌────┴────┐
       │         │    ▼             ▼     │         │
      是        否  ordered.Map    sort.Slice   是        否
       │         │                              │         │
       ▼         ▼                              ▼         ▼
  set.Set[T]  hash.Set[T]                  heap/v2    container/heap

2.1 去重:set.Set[T] vs map[T]struct{}

如果只需要去重,set.Set[T]map[T]struct{} 在运行时几乎没有区别。set 包的价值在于:

  • 意图明确:s.Contains(x)_, ok := m[x] 更可读。
  • API 更完整:AddRemoveUnionIntersectionDifference 等集合操作。
  • 与 map 互转零成本:set.Of(1, 2, 3) 等价于 map[int]struct{}{1:{}, 2:{}, 3:{}}
go
package main

import (
	"fmt"
	"container/set"
)

func main() {
	s := set.Of("golang", "python", "rust")
	s.Add("php")
	fmt.Println(s.Contains("python")) // true

	// 与 map 零成本互转
	m := map[string]struct{}{"a": {}, "b": {}}
	s2 := set.From(m)
	_ = s2
}

2.2 自定义哈希:hash.Set[T]

Go 的 map 要求 key 必须 comparable,且哈希函数由运行时决定。如果你需要:

  • 对不可比较类型(如包含 slice 的结构体)做集合。
  • 跨进程/跨版本保持哈希结果稳定(用于分片、缓存键等)。

就用 container/hash

go
package main

import (
	"container/hash"
	"fmt"
)

type Record struct {
	ID   int
	Tags []string // slice 不可比较,但可以被自定义哈希
}

func hashRecord(r Record) uint64 {
	h := hash.New64()
	h.WriteUint64(uint64(r.ID))
	for _, t := range r.Tags {
		h.WriteString(t)
	}
	return h.Sum64()
}

func eqRecord(a, b Record) bool {
	if a.ID != b.ID || len(a.Tags) != len(b.Tags) {
		return false
	}
	for i := range a.Tags {
		if a.Tags[i] != b.Tags[i] {
			return false
		}
	}
	return true
}

func main() {
	s := hash.NewSet(hash.HashFunc[Record]{Hash: hashRecord, Equal: eqRecord})
	s.Add(Record{ID: 1, Tags: []string{"go", "backend"}})
	fmt.Println(s.Contains(Record{ID: 1, Tags: []string{"go", "backend"}})) // true
}

2.3 有序 Map:ordered.Map[K,V]

Go 原生的 map 遍历顺序随机。常见 workaround 是:

go
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { ... }

如果“按 key 顺序访问”是高频路径,ordered.Map[K,V] 把这种模式内建到数据结构中,单次插入 O(log n),遍历时直接按序输出,避免额外切片和排序。

go
package main

import (
	"container/ordered"
	"fmt"
)

func main() {
	om := ordered.NewMap[string, int]()
	om.Set("z", 1)
	om.Set("a", 2)
	om.Set("m", 3)

	om.Ascend(func(k string, v int) bool {
		fmt.Println(k, v)
		return true
	})
	// 输出:a 2, m 3, z 1
}

2.4 优先队列:heap/v2 与索引追踪

container/heap 在 Go 1.0 就已存在,但老版本要求元素实现 heap.Interface,操作复杂。heap/v2 的改进:

  • 使用泛型约束,不再需要 Interface
  • 提供 Handle 机制,可 O(log n) 更新/删除任意元素。
  • 零值可用。
go
package main

import (
	"container/heap/v2"
	"fmt"
)

type Task struct {
	Name     string
	Priority int
}

func main() {
	h := heap.OfFunc(func(a, b Task) bool {
		return a.Priority > b.Priority // 大顶堆
	})

	h.Push(Task{Name: "cleanup", Priority: 1})
	h.Push(Task{Name: "critical", Priority: 10})
	h.Push(Task{Name: "normal", Priority: 5})

	// 更新某个任务的优先级(O(log n))
	handle := h.Lookup(func(t Task) bool { return t.Name == "normal" })
	h.Update(handle, Task{Name: "normal", Priority: 15})

	for h.Len() > 0 {
		t := h.Pop()
		fmt.Println(t.Name, t.Priority)
	}
	// 输出:normal 15, critical 10, cleanup 1
}

三、Benchmark:什么时候值得换容器?

下面的基准测试对比了三种常见场景:

go
package bench

import (
	"container/set"
	"container/ordered"
	"testing"
)

const N = 1_000_000

func BenchmarkMapInsert(b *testing.B) {
	keys := make([]string, N)
	for i := range keys { keys[i] = fmt.Sprintf("key-%09d", i) }
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		m := make(map[string]struct{}, N)
		for _, k := range keys { m[k] = struct{}{} }
	}
}

func BenchmarkSetInsert(b *testing.B) {
	keys := make([]string, N)
	for i := range keys { keys[i] = fmt.Sprintf("key-%09d", i) }
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		s := set.New[string](N)
		for _, k := range keys { s.Add(k) }
	}
}

func BenchmarkSortedKeysMap(b *testing.B) {
	m := make(map[string]int, N)
	for i := 0; i < N; i++ { m[fmt.Sprintf("key-%09d", i)] = i }
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		keys := make([]string, 0, N)
		for k := range m { keys = append(keys, k) }
		sort.Strings(keys)
	}
}

func BenchmarkOrderedMapAscend(b *testing.B) {
	om := ordered.NewMap[string, int]()
	for i := 0; i < N; i++ { om.Set(fmt.Sprintf("key-%09d", i), i) }
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		om.Ascend(func(k string, v int) bool { return true })
	}
}

在 AMD64 / Linux 环境下,预期结果大致如下:

测试项每次操作耗时说明
MapInsert~35 ms原生 map,分配一次
SetInsert~35 ms与 map 一致,set 封装无额外开销
SortedKeysMap~120 ms遍历 + 切片 + 快排
OrderedMapAscend~25 ms直接中序遍历,无排序开销

结论:

  • 仅做去重:用 set.Set[T],性能与 map 持平,可读性更好。
  • 高频有序访问:用 ordered.Map[K,V],比 map+sort 快数倍。
  • 单次遍历后丢弃:map+sort 仍更简单。

四、迁移建议

4.1 最小侵入迁移:从 map[T]struct{}set.Set[T]

因为 set.Set[T]map[T]struct{} 是透明互转的,迁移可以逐步进行:

go
// 旧代码
seen := make(map[string]struct{})
seen["foo"] = struct{}{}
if _, ok := seen["foo"]; ok { ... }

// 新代码
seen := set.New[string]()
seen.Add("foo")
if seen.Contains("foo") { ... }

4.2 不要滥用 ordered.Map

ordered.Map 的插入和删除都是 O(log n),在“只需要偶发排序”的场景下,其单次写入成本高于原生 map。只有在“按序遍历是热路径”时才值得替换。

4.3 hashhash/v2 的边界

如果 key 本身就是 comparable 的,优先用原生 map 或 set;只有需要自定义哈希或不可比较 key 时才启用 container/hash。自定义哈希函数写错会带来安全或正确性问题,属于高级用法。

五、总结

Go 1.28 泛型容器不是要让所有代码都换上新包,而是让不同场景有“语义正确”的选择:

  • 去重用 set,与 map 同速且更易读。
  • 有序访问用 ordered.Map,避免反复排序。
  • 自定义哈希用 container/hash
  • 优先队列用 heap/v2,利用索引追踪降低更新成本。

理解每种容器的底层实现与性能特征,才能在工程上做出最小代价的正确选择。

参考资料

上次更新于: