maps.Grow 进了 Go 1.28 草案:给 map 预留容量,少搬几次 bucket
状态:提案已接受,目标版本 Go 1.28(计划 2027 年 2 月发布)。当前发布版还没有这个函数,落地前以官方文档为准。
- Go 1.28 发布说明(编写中):https://go.dev/doc/go1.28
- 提案与讨论:https://github.com/golang/go/issues
现状:map 只有创建时的一个 hint
make(map[K]V, n) 里的 n 只在建 map 的那一刻起作用,而且它说的是「最终大概装多少个」,不是「还要再塞多少个」。之后再往里写,运行时只在插入路径上按负载因子判断要不要扩容,扩容就得把旧 bucket 里的键值对搬到新 bucket(渐进式 rehash)。
slice 有等价物:make([]T, 0, n) 一次分配好底层数组,之后 append 只要不超容量就不搬数据。map 没有对应的东西——已经存在的 map,你没办法告诉运行时「我接下来还要插 5000 个,先把地方腾出来」。
maps.Grow 的语义
func Grow[M ~map[K]V, K comparable, V any](m M, n int)
- 参数
n是「还将插入的条目数」,按这个数量预留空间,不是把 map 容量钉死在某个上限。 - 重复调用是追加语义:
Grow(m, 100)之后再Grow(m, 100),相当于按再插 200 个来准备。 - 内部按几何级增长预留,避免一次预留过多,代价是预留量与实际需求之间仍有偏差。
- 传入负数或 0 是安全的空操作(这一点要等正式文档确认)。
它要解决的是「逐次扩容 + rehash」这个循环,而不是改变 map 的查找复杂度。
典型场景
把一份已有的 map 合并进另一个 map 时,目标 map 往往不是刚建的,或者要接多个来源:
- 复制
http.Header:来源常常是外部输入(请求头、代理透传),键的数量事先不知道,但len(src)是知道的。 - 合并配置 map:base config 叠加 override,写之前先按
len(override)预留。 - JSON 解码后的 map 再加工:
map[string]any解出来之后做字段改写或合并,同样可以先看一眼源 map 的长度。
模式都一样:先 Grow(dst, len(src)),再循环写。
可运行示例
maps.Grow 现在还不能用,所以示例里用了一个占位函数 grow,函数体是空的。等 Go 1.28 落地,把函数体换成 maps.Grow(m, n) 就行,调用点不用动。
package main
import (
"fmt"
"net/http"
"sort"
)
// grow 是 maps.Grow 的临时替身。
// Go 1.28 之后把函数体换成 maps.Grow(m, n),签名保持不变。
func grow[M ~map[K]V, K comparable, V any](m M, n int) {
_ = m
_ = n // 当前标准库没有预留手段,只能等插入时自动扩容
}
// MergeHeader 把 src 合并进 dst。
// dst 可能是调用方传进来的、已经有内容的 map,make 的容量参数帮不上忙。
func MergeHeader(dst, src http.Header) {
grow(dst, len(src))
for k, vs := range src {
dst[k] = append(dst[k], vs...)
}
}
// MergeHeaderPrealloc 是对照组:只有在新建 map 时才能用 len 估一次。
func MergeHeaderPrealloc(src http.Header) http.Header {
dst := make(http.Header, len(src))
for k, vs := range src {
dst[k] = append(dst[k], vs...)
}
return dst
}
func main() {
src := http.Header{
"Content-Type": {"application/json"},
"Cache-Control": {"no-store"},
"X-Request-Id": {"abc123"},
}
// 场景一:目标 map 已经存在,容量只能靠运行时自己长
dst := http.Header{"Accept": {"text/html"}}
MergeHeader(dst, src)
// 场景二:新建时按 len(src) 估个下界
fresh := MergeHeaderPrealloc(src)
for _, m := range []http.Header{dst, fresh} {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %v\n", k, m[k])
}
fmt.Println("---")
}
}
现在能做的只有两件事:建 map 时用 len(src) 当 hint,以及尽量把 map 一次建好再填。前者对「已存在的 map」无效,后者在合并场景里往往做不到。Grow 补的就是这个缺口。实际能省多少,取决于键的数量和分布,需自行实测。
什么时候没用
- 单次小 map:写完就扔、之后不再扩容的 map,预留没有意义。
- 写入量在一个 bucket 以内(8 个键值对):本来就不会触发扩容,
Grow只是多一次调用。 - map 复用:如果你已经在清空后重用同一个 map(
clear(m)),bucket 数组还在,容量没丢,再Grow是白忙。这种场景下重用比重建更划算。
代价
预留是单向的:map 不会因为删除元素而自动缩小,bucket 数组会一直留在那儿。按 len(src) 预留还算克制;如果按「未来可能的最大值」预留,峰值内存会明显上去,尤其是 map 里 value 比较大的时候。估算宁小勿大,不够就让运行时自己长。
Go 1.28 草案里的其他条目
同一份发布说明(仍在编写)还列了这些:
go vet新增两条检查:未检查的bufio.Scanner错误、未检查的sql.Rows错误。FlagSet.All迭代器。url.MustParse。- 子进程的 Landlock 支持。
另外,Go 1.27.4 与 1.26.11 计划于 11 月 10 日发布,包含标准库与工具链的安全修复,覆盖 12 个 CVE。