Go设计取舍之二: maps.Keys和Values为什么返回迭代器

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.Keysmaps.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,再增加 KeysIterKeysSeq

邮件列表给出的答案,比"迭代器更快"复杂得多。

先澄清一件事:标准库没有破坏兼容

讨论中最先需要纠正的,是"标准库把已有 API 改掉了"这个印象。

返回切片的 KeysValues 位于:

text 复制代码
golang.org/x/exp/maps

x/exp 是实验模块。它存在的目的之一,就是允许 API 在设计成熟前发生变化。实验包不受 Go 1 兼容性承诺保护。

标准库 maps 在 Go 1.21 中出现时,并没有立刻加入 KeysValues。相关函数被暂时移除,等待 range-over-function 和 iter 设计稳定。到了 Go 1.23,标准库才正式加入返回 iter.Seqmaps.Keysmaps.Values

所以,更准确的描述是:

实验包曾提供一个返回切片的版本;标准库后来选择了一个不同、被认为更适合长期维护的 API。

这不是标准库修改了已经发布的函数签名,而是实验结果没有原样进入标准库。

迁移其实只需要一层 slices.Collect

如果旧代码确实需要切片,可以这样迁移:

go 复制代码
keys := slices.Collect(maps.Keys(m))
values := slices.Collect(maps.Values(m))

这层组合看上去比原来的 maps.Keys(m) 啰嗦,但它把两个动作拆开了:

  1. maps.Keys(m) 描述如何产生键;
  2. 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))

虽然稍长,却同时提供了惰性版本和切片版本。相比之下,如果标准库同时维护 KeysKeysSeqKeysSlice,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 设计中经常存在的冲突:

  • 最简单的名字应该给最常用的操作;
  • 还是应该给最基础、最可组合的操作?

迭代器一定更快吗

讨论进行到后半段,话题从命名转向了性能。

返回切片至少需要:

  1. 分配一个切片;
  2. 遍历 map;
  3. 把所有键或值写入切片;
  4. 后续使用结束后由 GC 回收。

迭代器可以直接把值交给消费者:

go 复制代码
for k := range maps.Keys(m) {
    use(k)
}

即使 map 很小,频繁创建短命切片也可能增加分配和 GC 压力。Axel 在邮件里提到,他曾在 CPU 密集型程序中把切片遍历改为迭代器,获得明显的端到端提升。

不过,不能因此得出"所有迭代器都比切片快"的结论。

range over function 需要编译器生成控制逻辑,以处理:

  • yield 返回 false 后停止;
  • breakcontinuereturn
  • 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 利用实验包试错,等迭代器模型成熟后,选择了一个不同的长期方向。

这个方向是否值得,需要由多年真实代码来检验。但至少,它让我们看到标准库设计背后的约束远不止"少写几行代码"。

参考链接

相关推荐
热心市民lcj1 小时前
Spring Boot 整合 Caffeine 本地缓存实战
spring boot·后端·缓存
Revolution611 小时前
Nest.js 是什么:怎样用它写出第一个后端接口
后端·node.js·nestjs
aiopencode1 小时前
SwiftUI Introspect生产环境完全指南:为什么它是安全可靠的选择
后端·ios
shengjk11 小时前
x86架构发展史:从8086到x86-64,一文看懂40多年CPU指令集如何改变世界
后端
JackSparrow4143 小时前
前端安全之JS混淆+请求加密+请求签名以提升爬虫难度
前端·javascript·后端·爬虫·python·安全
geovindu3 小时前
go:loghelper
开发语言·后端·golang
小满zs4 小时前
Go语言第四章(类型转换)
后端·go
人间凡尔赛5 小时前
告别冷启动!WebAssembly + Spin 实战:Serverless 延迟从 1 秒降到 1 毫秒
后端·云原生·serverless·webassembly·spin
陈随易12 小时前
bm2,MoonBit实现的pm2替代品
前端·后端·程序员