本文永久链接https://tonybai.com/2026/09/02/fluent-python-author-go-collection-is-comming

大家好,我是Tony Bai。

【导读】

从 2018 年“手搓集合”的血泪史,到即将随 Go 1.28 登场的 container/set、container/hash——《Fluent Python》作者 Luciano Ramalho 在 GopherUK 2026 大会上,讲清楚了 Go 语言这次集合(Set)“转正”背后的完整设计逻辑,以及它对 AI 编程时代意味着什么。

Go 语言诞生已经十六年了,但直到今天,它的标准库里依然没有一个真正的集合(Set)类型。

想要判断“元素是否属于某个集合”,想要求交集、并集、差集,Go 程序员的标准答案永远是:自己拿 map[T]struct{} 或者 map[T]bool 糊一个出来。

在 GopherUK 2026 大会上,《Fluent Python》一书的作者 Luciano Ramalho 带来了一场名为《Sets in Modern Go》的演讲。这不是他第一次讲这个话题——早在 2018 年的 GopherCon Brazil,他就做过一场叫"Practus"(Set Practice 的谐音梗)的演讲,专门吐槽 Go 里没有集合这件事。

而这一次,他带来的是一次“官方回应”:Go Collections 工作组已经在 2025 年底成立,一份编号 #80590 的提案正在把集合、有序 Map、新版堆等一整套集合家族,正式设计进 Go 1.28。

【文章要点】

  • Go 十六年来没有内建集合类型(Set),开发者只能用 map[T]struct{} 手搓
  • 提案 #80590 一次性带来 7 项新增:Hasher 接口、container/hash.Map/Set、新版 heap、container/set.Set、container/mapset、有序 Map
  • 新的 Set 类型直接就是 map[E]struct{} 的具名类型,可以无缝转换
  • 新增的 Hasher 接口,第一次让“不可比较”的类型也能安全地放进集合和 Map
  • 这套 API 的设计语言,恰好也是给 AI 编程助手下指令的最佳词汇


一段真实的代码,暴露了 Go 的老毛病

Ramalho 在演讲一开始,就举了一个几乎所有后端程序员都写过的场景:

如果一个商品描述里,包含了搜索关键词里的所有词,就把这个商品展示出来。

在 Go 引入泛型之前(也就是 2018 年那个版本的演讲里),这种需求通常被写成两层嵌套循环:外层遍历查询词,内层在商品描述里逐个查找,中间夹杂着 break、两个 return、两个 if。代码不长,但圈复杂度不低,很容易写错。

Ramalho 半开玩笑地说,现在这种代码大概率会交给 AI 编程助手来写——但问题是,“agent 写的代码,你终究还是要读的”。而如果你能一眼看出这其实就是一次子集判断(query 是不是 description 的子集),那这段代码本质上只是集合代数里的一个操作符。

再举一个例子:把“之前收藏、但已经在购物车里的商品”从收藏列表里去掉,这就是一次标准的差集运算。

这正是这场演讲的核心论点:一旦语言里有了这些抽象,你写的代码会更少,AI 帮你写的代码也会更少,而你需要读的代码同样会更少。

集合代数速览:逻辑运算的另一张脸

Ramalho 引用了一句他很喜欢的话:“还没有哪一个数学分支能够抵抗被形式化为集合论。” 计算机科学作为数学的一个分支,自然也不例外。

集合运算和逻辑运算其实是同一件事的两种说法:

  • 交集(intersection)对应逻辑上的“合取”(and):x 属于 A∩B,等价于 x 属于 A 且 x 属于 B
  • 并集(union)对应逻辑上的“析取”(or)
  • 差集(difference)没有对应的常见运算符
  • 对称差(symmetric difference)对应异或(xor)

有意思的是,Ramalho 特别提到了《Go 程序设计语言》(Donovan & Kernighan 那本“圣经”)里用位图(bitset)实现整数集合的经典段落——把整数 17 表示成第 17 位是否为 1,于是交并差对称差就都变成了 CPU 原生支持、速度极快的位运算:与、或、异或。

他感叹说,这本书写作的年代 Go 还没有迭代器(iterator),如今完全可以用更优雅的方式重新实现一遍。

十六年之痒:从“瘦 API”到冯诺依曼瓶颈

Ramalho 提到,他在 ThoughtWorks 工作时观察到一个现象:大部分工程师并不习惯用“集合”的思路去解决问题。他把原因归结为,团队常用的 Java 和当年的 JavaScript,都只提供了非常“瘦”(lean)的集合 API——大部分方法只是在做增删改查这类“记账”操作,而不是真正的集合代数。

这类只会一次处理一个元素的编程风格,被他借用了图灵奖得主 John Backus 在 1977 年获奖演说里的说法,称为“冯诺依曼瓶颈”(von Neumann bottleneck):代码像 CPU 单核那样一次只搬一个数据,而不是像 GPU 那样成批量地操作数组或集合。

2018 年的 Go,在这方面比 Java 和 JavaScript 还要“惨”——因为没有泛型,连“瘦 API”都谈不上,开发者只能各显神通,用代码生成等各种奇技淫巧来实现类型专属的集合。

但风向正在变化。ECMAScript 2025 已经给 JavaScript 的 Set 原型加上了 intersectionuniondifferencesymmetricDifferenceisSubsetOfisSupersetOfisDisjointFrom 等一整套集合代数方法。Ramalho 说,他筹备这场演讲时还不知道 Go 这边也在同步发生类似的事——直到他发现了提案 #80590。

转折点:Go Collections 工作组与提案 #80590

Go Collections 工作组于 2025 年底成立,目标是把常见的集合数据结构带进标准库,延续 Go 一贯的“实用主义 + 简单性”原则。这份提案汇总了七项具体设计:

序号 名称 说明
1 maphash.Hasher 接口 已随 Go 1.27 落地,用于自定义哈希函数与等价关系
2 container/hash.Map[K, V] 基于自定义 Hasher 的哈希 Map
3 container/hash 的 Set 与 hash.Map 同源,支持自定义等价关系的哈希集合
4 新版 container/heap 解决旧版堆开发体验不佳的问题
5 container/set.Set 主打的“标准集合”,底层就是 map[E]struct{}
6 container/mapset 无具名类型的底层函数包,供存量代码复用
7 有序 Map(ordered map) 保留插入顺序的 Map

除此之外,还有三个尚未导出(unexported)、隐藏在 container_test.go 里的抽象接口:AbstractCollectionAbstractSetAbstractMap。它们目前还没有正式文档,官方的态度是“先在标准库内部验证,跑通了再考虑要不要公开”。

需要强调的是:以上这些代码目前仍然躺在 Go 官方的变更列表(CL,change list)里,尚未合并进主干,细节随时可能调整。Ramalho 表示自己是把这些还未合并的 CL 提取出来、放进一个 vendor 目录里跑通的,仅供参考。

拆解 container/set.Set:能当 map 用的“集合”

container/set.Set 是这次提案里最贴近日常使用场景的类型。它有一个很讨巧的设计:它本身就是 map[E]struct{} 的具名类型,因此可以和无名类型 map[E]struct{} 相互赋值,原生的 len(set)_, ok := s[elem]for elem := range set 语法照样能用——学习成本几乎为零。

下面是根据提案思路整理的一段示意代码(API 仍在讨论中,具体签名以官方最终发布为准):

package main

import (
    "fmt"
    "container/set"
)

func main() {
    query := set.Of("go", "generics", "sets")
    description := set.Of("go", "generics", "sets", "hasher", "map")

    // 子集判断:query 里的词是否都出现在 description 中
    if query.Subset(description) {
        fmt.Println("命中")
    }

    favorites := set.Of("A", "B", "C")
    inCart := set.Of("B")

    // 差集:收藏中排除已在购物车的商品
    toShow := favorites.Difference(inCart)
    fmt.Println(toShow)
}

值得一提的两个设计细节:

  • 变异操作返回 bool:凡是会修改接收者本身的方法,都会返回一个布尔值,告诉调用方“这次操作是否真的改变了集合”。这大概率是工作组观察了 Google 内部海量存量代码后总结出的真实需求——很多场景下开发者确实需要知道操作有没有生效。
  • With 后缀是新的命名约定:不带 With 的方法(如 Intersection)会返回一个全新的集合;带 With 后缀的方法(如 IntersectionWith)则会原地修改接收者。这个命名规则被认为会成为 Go 标准库未来集合类 API 的通用约定。

Ramalho 也毫不客气地指出了当前设计里一个尚不自洽的地方:Of(...) 构造器返回的是具名的 Set 类型,但 mapset 包里对应的构造器返回的却是裸的 map[E]struct{}。这种不一致,加上一处看起来像是复制粘贴留下的过时注释,都是“这仍是半成品”的明显信号——他甚至调侃自己专门让 Claude Code 帮忙分析了一下这段代码的可疑之处。

在底层实现上,container/set 的大多数方法其实都只是对内部包 mapset 里对应函数的一行转发调用。比如 Insert 内部要处理一个“哨兵值”问题:因为底层可能是 map[E]struct{},也可能是 map[E]bool,所以 mapset.Insert 用类型断言去判断当前该塞入的是 true 还是空结构体 struct{}{},从而让同一套算法同时兼容两种底层表示。

再比如求交集时,代码会先用一个叫 maps.Same(讨论过程中刚从 identical 改名而来,专门判断两个引用是否指向同一个底层对象,类似 Python 里的 is)做一次快速判断:如果两个集合本来就是同一个对象,直接克隆返回;否则就遍历元素更少的那个集合去做 Contains 检查,从而把复杂度降到最优。

这里也是整场演讲反复强调的性能收益所在:一旦集合的成员检测能做到常数时间(依赖哈希表实现),那么原本双重循环里 O(M×N) 的复杂度,就能直接坍缩成 O(M+N)。

更硬核的设计:F-bounded 多态接口

如果说 container/set.Set 是给日常业务代码用的“傻瓜版”,那三个未导出的抽象接口就是给“集合家族”定规矩的。

AbstractSet 的定义大致长这样(简化示意):

type AbstractSet[E any, S AbstractSet[E, S]] interface {
    AbstractCollection[E, S]
    // ...
}

这种“接口的类型参数里嵌套了自身”的写法,专业名词叫 F-bounded 多态(F-bounded polymorphism)。Ramalho 提到,Java 的《The Java Programming Language》第四版里,James Gosling 等作者对 Enum<E extends Enum<E>> 这种循环定义的评价堪称经典:这大概是开发者会遇到的最令人困惑的泛型定义,但类型理论专家向我们保证它是完全合法且有意义的,所以我们可以不必深究——对此我们深表感激。

看到 Go 的抽象集合接口里出现了几乎一模一样的结构,Ramalho 忍不住笑了:这不是巧合,而是同一类问题的通用解法。

为什么非要这样写?因为集合上的很多操作(比如求交集)返回的还是“同一种集合”,你需要一个类型变量能同时指代“元素类型”和“集合自身的类型”,否则根本没法在接口里表达“这个方法返回的类型必须和调用者是同一个具体类型”这条约束。

AbstractSetAbstractMap 又都内嵌了 AbstractCollection,后者提供了 ContainsAll(用来实现子集/超集判断)、ContainsLen,以及为了让哈希类型集合也能有可预测的打印顺序而实现的 String() 方法(因为哈希表遍历顺序本身是不确定的)。

杀手锏:自定义 Hasher,突破"可比较"限制

container/set.Set 解决的是“有没有”的问题,而 container/hash 这一支解决的是“能不能”的问题。

Go 内建的 map 有一个硬限制:键必须支持 == 比较,并且哈希算法是编译器内置、不可定制的。这意味着你没法把一个包含 slice 字段的结构体直接当 map 的键,也没法轻松实现一个“大小写不敏感”的字符串集合。

maphash.Hasher 接口(已经随 Go 1.27 发布)就是为了打破这个限制而生的。使用方式很直接:

  1. 先满足一条硬性不变量——哈希值相等的对象,必须真的相等(反过来不一定成立,哈希碰撞是允许的);
  2. 实现一个满足 maphash.Hasher 接口的结构体,定义好 HashEqual 两个方法;
  3. 把这个结构体传给 hash.NewMaphash.NewSet 构造器。

一个大小写不敏感字符串集合的示意实现:

type CaseInsensitiveHasher struct{}

func (CaseInsensitiveHasher) Hash(s string) uint64 {
    return maphash.String(strings.ToLower(s))
}

func (CaseInsensitiveHasher) Equal(a, b string) bool {
    return strings.EqualFold(a, b)
}

s := hash.NewSet[string](CaseInsensitiveHasher{})
s.Insert("Go")
s.Contains("go") // true

有意思的是,Ramalho 对着这段官方示例代码提出了一个具体的技术质疑:示例里 Hash 用的是 strings.ToLowerEqual 却用的是 strings.EqualFold——两者本该是同一套大小写归一化逻辑,但 ToLower 只对 ASCII 字符可靠,对带重音符号的语言(比如他的母语葡萄牙语)并不总是正确,而 EqualFold 才是更稳妥的选择。他打算就此向 Go 团队提问。这也从侧面说明,这套 API 眼下确实还处于打磨阶段。

对开发者和 AI 编程助手意味着什么

演讲临近结尾,Ramalho 提出了一个和当下开发方式高度相关的观点:集合代数是一套精确、跨语言、高层次的词汇

他认为,哪怕你用的编程语言本身没有集合类型,只要你能用“交集”、“差集”、“子集”这样的词汇去描述需求,无论是自己写代码,还是把需求交给 AI 编程助手,效果都会更好——因为这套词汇本身就足够精确、足够高层,不需要退回到“遍历、判断、标记”这种命令式的碎碎念。

他还提到,对于刚入门编程的初学者(比如“想学编程的表弟表妹”),如果集合这个概念还太抽象,学一学 SQL 也是不错的路径——因为 SQL 本质上同样是围绕集合和逻辑运算构建的。

小结:仍是提案,但方向已定

回到这次演讲的三条关键结论:

  • 集合代数能为常见的数据处理任务,提供更简单、更快的解法;
  • 即将到来的 Go 1.28 里的集合与其他集合类型,会成为未来通用集合设计的基础范式;
  • 基于 Hasher 的新版 Map 和 Set,让 Go 第一次拥有了真正灵活的自定义等价关系能力。

当然,最需要划重点的一句话是:上面提到的所有 Go 源码目前都还没有合并,随时可能变化。 提案 #80590 目前仍在 Go 官方 issue 区接受社区讨论,具体的 API 形态、方法命名、乃至最终是否搭上 Go 1.28 这班车,都以 Go 官方发布为准。

但无论细节如何变化,方向已经足够清晰:那个“没有集合”的 Go,可能真的要成为历史了。


本文整理自 Luciano Ramalho 在 GopherUK 2026 大会上的演讲《Sets in Modern Go》- https://www.youtube.com/watch?v=F1mH6E8cp0M,相关提案可参考 Go 官方 issue #80590(container/:generic collection types)。文中涉及的 API 均为提案阶段的设计,最终以 Go 官方正式发布为准。


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。