2024 年 12 月 24 日,笔者在
golang-nuts提出了一个问题:为什么实验包exp里的maps.Keys返回切片,而标准库里的同名函数却返回迭代器?这场讨论持续到 12 月 31 日,共有 18 封邮件。讨论入口:Is it necessary to change the behavior of maps.Keys and maps.Values?。
maps.Keys 为什么不再返回切片?一场标准库 API 讨论
第一次使用 golang.org/x/exp/maps 时,很喜欢 maps.Keys 和 maps.Values。
go
keys := maps.Keys(m)
values := maps.Values(m)
它们直接返回切片,省掉了重复的 for range + append。后来我在较新的 Go 版本里改用标准库 maps,原来的代码却编译不过了。
标准库中的签名是:
go
type Seq[V any] func(yield func(V) bool)
func Keys[Map ~map[K]V, K comparable, V any](m Map "Map ~map[K]V, K comparable, V any") iter.Seq[K]
func Values[Map ~map[K]V, K comparable, V any](m Map "Map ~map[K]V, K comparable, V any") iter.Seq[V]
也就是说,maps.Keys(m) 不再是 []K,而是一个惰性迭代器。
我当时的第一反应是:既然旧名字已经被用过,为什么不保留返回切片的 Keys,再增加 KeysIter 或 KeysSeq?
邮件列表给出的答案,比"迭代器更快"复杂得多。
先澄清一件事:标准库没有破坏兼容
讨论中最先需要纠正的,是"标准库把已有 API 改掉了"这个印象。
返回切片的 Keys 和 Values 位于:
text
golang.org/x/exp/maps
x/exp 是实验模块。它存在的目的之一,就是允许 API 在设计成熟前发生变化。实验包不受 Go 1 兼容性承诺保护。
标准库 maps 在 Go 1.21 中出现时,并没有立刻加入 Keys 和 Values。相关函数被暂时移除,等待 range-over-function 和 iter 设计稳定。到了 Go 1.23,标准库才正式加入返回 iter.Seq 的 maps.Keys 和 maps.Values。
所以,更准确的描述是:
实验包曾提供一个返回切片的版本;标准库后来选择了一个不同、被认为更适合长期维护的 API。
这不是标准库修改了已经发布的函数签名,而是实验结果没有原样进入标准库。
迁移其实只需要一层 slices.Collect
如果旧代码确实需要切片,可以这样迁移:
go
keys := slices.Collect(maps.Keys(m))
values := slices.Collect(maps.Values(m))
这层组合看上去比原来的 maps.Keys(m) 啰嗦,但它把两个动作拆开了:
maps.Keys(m)描述如何产生键;slices.Collect(...)决定把这些值收集成切片。
如果只是遍历,根本不需要分配切片:
go
for k := range maps.Keys(m) {
fmt.Println(k)
}
如果需要排序:
go
keys := slices.Sorted(maps.Keys(m))
如果只是找到第一个符合条件的键,也可以提前停止,不必先构造完整切片。
这就是迭代器设计最直接的收益:生产值和保存值不再绑定。
为什么最短的名字给了迭代器
讨论里真正有争议的不是"要不要迭代器",而是命名。
一种观点认为,返回切片的版本更符合多数人的直觉:
go
keys := maps.Keys(m) // 看起来应该是 []K
如果返回的是序列,最好明确叫:
go
maps.KeysSeq(m)
maps.KeysIter(m)
另一种观点则认为,最常用、组合能力最强的版本应该得到最简单的名字。需要切片时,可以用 slices.Collect 组合出来;反过来,如果 Keys 固定返回切片,想避免分配就必须再增加一个新 API。
Axel Wagner 在回复中给出了一个很有代表性的判断:
go
slices.Collect(maps.Keys(m))
虽然稍长,却同时提供了惰性版本和切片版本。相比之下,如果标准库同时维护 Keys、KeysSeq、KeysSlice,API 面积会越来越大。
Go 标准库一直很在意这一点。增加一个导出函数很容易,删除却几乎不可能。每个名字一旦进入标准库,就需要长期维护文档、语义和兼容性。
"自然名字"其实没有那么自然
讨论后来转向了一个很有意思的问题:为什么大家会觉得不带后缀的 Keys 天然应该返回切片?
一部分原因来自历史。x/exp/maps.Keys 曾经返回切片,使用过它的人已经形成了习惯。
如果历史顺序反过来------Go 先有迭代器,再出现 maps.Keys------很多人可能会觉得返回序列再自然不过。
这也是 Axel 所说的"历史偶然":实验包出现时,Go 还没有现在的迭代器模型,所以只能先返回切片。标准库有机会重新选择时,Go 团队决定把更一般的惰性形式放在基础位置。
但反对者也有合理担忧:API 命名不仅取决于理论上的通用性,也取决于既有经验和一致性。标准库里已经有不少通过后缀区分行为的函数:
go
slices.Sort
slices.SortFunc
maps.Equal
maps.EqualFunc
bytes.Buffer.Write
bytes.Buffer.WriteString
因此,KeysSeq 并不是一个完全违背 Go 风格的名字。
这场争论没有产生一个让所有人都满意的答案。它呈现的是 API 设计中经常存在的冲突:
- 最简单的名字应该给最常用的操作;
- 还是应该给最基础、最可组合的操作?
迭代器一定更快吗
讨论进行到后半段,话题从命名转向了性能。
返回切片至少需要:
- 分配一个切片;
- 遍历 map;
- 把所有键或值写入切片;
- 后续使用结束后由 GC 回收。
迭代器可以直接把值交给消费者:
go
for k := range maps.Keys(m) {
use(k)
}
即使 map 很小,频繁创建短命切片也可能增加分配和 GC 压力。Axel 在邮件里提到,他曾在 CPU 密集型程序中把切片遍历改为迭代器,获得明显的端到端提升。
不过,不能因此得出"所有迭代器都比切片快"的结论。
range over function 需要编译器生成控制逻辑,以处理:
yield返回 false 后停止;break、continue和return;- panic;
- 迭代器是否错误地并发调用 yield;
- 控制变量的生命周期。
某些中间迭代器和 iter.Pull 也存在额外成本。实际性能取决于:
- 数据规模;
- 是否只遍历一次;
- 是否需要随机访问;
- 是否需要排序;
- 组合了多少层迭代器;
- 编译器能否内联并消除分配。
如果最终一定需要保存所有键,切片仍然是合适的数据结构。
slices.Collect 会不会太慢
线程中有人引用了 Go issue #68261,指出通用 Collect 在不知道元素数量时,可能多次扩容。对于 map 来说,我们其实知道最终长度是 len(m),理论上可以一次性预分配。
手写版本很简单:
go
func KeysSlice[K comparable, V any](m map[K]V "K comparable, V any") []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
这是不是说明标准库应该直接提供 KeysSlice?
未必。
一种可能是让编译器识别:
go
slices.Collect(maps.Keys(m))
并把它优化成预分配循环。另一种可能是未来增加能携带长度提示的收集 API。标准库 API 和编译器优化之间需要权衡:不一定要为了当前编译器尚未完成的优化,永久增加一个导出函数。
当然,在性能敏感路径上,不能靠"编译器以后也许会优化"作决定。实际项目应当跑 benchmark,必要时保留一个小型、明确的 KeysSlice 辅助函数。
什么情况下应该用哪一种
只遍历,不保存:
go
for k := range maps.Keys(m) {
use(k)
}
需要排序后的键:
go
keys := slices.Sorted(maps.Keys(m))
需要切片,但不在热点路径:
go
keys := slices.Collect(maps.Keys(m))
热点路径且确定需要切片:
go
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
需要多次遍历、索引访问或缓存结果时,切片往往更合适。只进行一次流水式处理时,迭代器通常更自然。
我后来怎样看这次变化
最初提出问题时,关注点主要是兼容性和命名:既然以前叫 Keys,为什么不把新版本叫 KeysIter?
讨论之后,我觉得这件事至少可以分成三层。
第一,x/exp 不是稳定标准库。依赖实验 API 时,需要接受设计被推翻。
第二,返回迭代器让 maps.Keys 成为更基础的操作。切片可以收集出来,排序也可以组合出来,而不需要每次都分配。
第三,命名争论仍然成立。一个 API 在理论上更一般,不代表它对所有使用者都更直观。标准库设计既要考虑组合性,也要考虑历史经验和学习成本。
现在不会再把它简单概括为"Go 团队把一个好用的 API 改坏了"。更准确的说法是:Go 利用实验包试错,等迭代器模型成熟后,选择了一个不同的长期方向。
这个方向是否值得,需要由多年真实代码来检验。但至少,它让我们看到标准库设计背后的约束远不止"少写几行代码"。