3.2 map 与集合惯用法
上一节的任务列表是有序的,但查找一个 ID 为 42 的任务得从头遍历,复杂度是 O(n)。任务一多,toggle 和后续的「查看详情」都会变慢。Go 的 map 是基于哈希表的键值容器,把查找降到平均 O(1)。本节就把 map[int64]Task 加进 TaskAPI,同时把 map 那些容易踩的坑一次讲清。
本节把 TaskAPI 推进到「内存版存储具备索引」:在
[]Task之外再维护一份map[int64]int(ID 到切片下标的映射),按 ID 查找不再需要遍历;同时用map[int64]Task演示 map 的增删改查。到本节结束,add/toggle都能做到常数时间定位。
3.2.1 字面量与 make
map 的零值是 nil,nil map 可读不可写——往里写会 panic。所以声明后要么用字面量初始化,要么用 make:
byID := map[int64]Task{
1: {ID: 1, Title: "写第一章", Done: true},
2: {ID: 2, Title: "写第二章"},
}
empty := make(map[int64]Task) // 空 map,可写
sized := make(map[int64]Task, 16) // 预分配容量,减少扩容
字面量里 1: {ID: 1, ...} 的简写形式省掉了重复的类型名,可读性很好。make 的第二个参数是容量提示,和切片的 cap 类似——它不是长度上限,map 会按需增长,但提前给个估计值能减少 rehash。
一个常见错误:
var m map[int64]Task
m[1] = Task{} // panic: assignment to entry in nil map
var 声明的 map 是 nil,读没问题(返回零值),写直接 panic。记住「nil map 只读」。
3.2.2 读:comma-ok 惯用法
从 map 取值有两个形态:
t := byID[2] // 只拿值,键不存在时得到零值
t, ok := byID[2] // comma-ok:第二个值报告键是否存在
第一种形态的陷阱在于:无法区分「键不存在」和「键存在但值恰好是零值」。
if t, ok := byID[2]; ok {
fmt.Println("命中:", t.Title)
}
t, ok := byID[99]
fmt.Printf("未命中: zero=%+v ok=%v\n", t, ok)
命中: 写第二章
未命中: zero={ID:0 Title: Done:false} ok=false
查询不存在的键 99 时,t 是 Task 的零值、ok 是 false。所以只要需要「判断是否存在」,就一定要用 comma-ok 形态,绝不能靠「值是不是零值」来推断。这条规则在值类型是 int、bool、string 时尤其重要——零值恰好也是合法数据的情况太常见了。
3.2.3 写、更新与 delete
写和更新是同一个操作:键已存在则覆盖,不存在则插入。
byID[3] = Task{ID: 3, Title: "写第三章"} // 插入
byID[3] = Task{ID: 3, Title: "写第三章(修订)", Done: true} // 覆盖
fmt.Println("len:", len(byID))
len: 3
删除用 delete,删除不存在的键不会报错,是安全的空操作:
delete(byID, 1)
if _, ok := byID[1]; !ok {
fmt.Println("ID=1 已删除")
}
ID=1 已删除
这里有个必须记住的限制:map 的元素不能直接改字段。
byID[2].Done = true // 编译错误:cannot assign to struct field byID[2].Done in map
实测的报错是 cannot assign to struct field byID[2].Done in map。原因是 map 的元素在哈希表里不保证地址稳定,Go 干脆禁止你取它的地址,也就禁止了原地修改。正确做法是「读出来、改、写回去」:
cur := byID[2]
cur.Done = true
byID[2] = cur
fmt.Println("改后:", byID[2].Done)
改后: true
这就是为什么 TaskAPI 的索引选择 map[int64]int(ID → 切片下标)而不是 map[int64]Task:值存在切片里,下标稳定,改字段只需 tasks[i].Done = !tasks[i].Done,一步到位,不需要「读改写」三步。
3.2.4 遍历顺序是随机的
range 一个 map 时,顺序是不确定的,而且每次运行都可能不同:
m := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
for i := 0; i < 3; i++ {
out := []int{}
for k := range m {
out = append(out, k)
}
fmt.Println(out)
}
[4 1 2 3]
[2 3 4 1]
[3 4 1 2]
同一个 map,连续三次遍历得到三种顺序。这是 Go 刻意引入的随机化,目的是让程序不能依赖遍历顺序——在早期版本里顺序虽然也不保证,但实践中往往稳定,导致很多人写出了「碰巧能跑」的代码,一换机器就崩。
需要稳定顺序时必须自己排:
order := []int64{}
for id := range byID {
order = append(order, id)
}
slices.Sort(order)
fmt.Println("key 排序后:", order)
key 排序后: [2 3]
这个「取键 → 排序 → 按键访问」的模式在需要稳定输出的地方(日志、JSON、测试断言)会反复出现。
3.2.5 键的约束
map 的键必须是可比较类型。可比较意味着支持 == 运算,具体包括:
| 可作键 | 不可作键 |
|---|---|
| 布尔、数值、字符串 | 切片 []T |
| 指针、channel、接口 | map |
| 只含可比较字段的结构体、数组 | 函数 |
实测的报错很直白:
invalid map key type []int
invalid map key type K // K 里含 []int 字段
注意接口类型虽然可以作键,但运行时如果塞进去一个不可比较的动态值(比如切片),会 panic。用 any 作键时要格外小心。
3.2.6 用 map 做集合
Go 没有内置的 set 类型,惯用 map[T]struct{} 或 map[T]bool 代替。用 struct{} 是因为它零内存(不占空间),比 bool 更省:
seen := map[string]struct{}{}
for _, w := range []string{"go", "map", "go"} {
seen[w] = struct{}{}
}
fmt.Println("去重后大小:", len(seen))
if _, ok := seen["go"]; ok {
fmt.Println("go 已在集合中")
}
判断存在依然用 comma-ok。TaskAPI 后面要用它做「标题去重」或「已处理 ID 集合」,这是非常高频的用法。
3.2.7 maps 包
Go 1.21 起标准库有了 maps 包,常用函数如下:
fmt.Println("keys:", slices.Sorted(maps.Keys(byID)))
fmt.Println("value 个数:", len(slices.Collect(maps.Values(byID))))
clone := maps.Clone(byID)
fmt.Println("clone 相等:", maps.Equal(byID, clone))
keys: [2 3]
value 个数: 2
clone 相等: true
几个要点:
maps.Keys返回的是迭代器(iter.Seq),不是切片,要用slices.Sorted或slices.Collect消费。这是 Go 1.23 引入的迭代器风格的统一设计。maps.Clone做浅拷贝——键和值被复制,但若值本身含指针,指向的对象仍共享。maps.Equal逐键比较,两个 nil map 相等,nil 与空 map 也相等。
maps.DeleteFunc 按条件批量删除、maps.Copy 合并两个 map 也很常用,值得一查 go doc maps。
3.2.8 双结构索引:[]Task + map[int64]int
现在把索引接进 TaskAPI。核心设计是两个结构各司其职:
[]Task保持插入顺序,负责稳定遍历与输出;map[int64]int存 ID → 下标,负责 O(1) 定位。
package main
import (
"fmt"
"slices"
)
type Task struct {
ID int64
Title string
Done bool
}
type MemStore struct {
tasks []Task
index map[int64]int
}
func NewMemStore() *MemStore {
return &MemStore{
tasks: make([]Task, 0, 16),
index: make(map[int64]int, 16),
}
}
func (s *MemStore) Add(t Task) {
s.index[t.ID] = len(s.tasks)
s.tasks = append(s.tasks, t)
}
func (s *MemStore) Toggle(id int64) bool {
i, ok := s.index[id]
if !ok {
return false
}
s.tasks[i].Done = !s.tasks[i].Done
return true
}
func (s *MemStore) List() []Task {
return slices.Clone(s.tasks)
}
func main() {
store := NewMemStore()
store.Add(Task{ID: 1, Title: "写第一章"})
store.Add(Task{ID: 2, Title: "写第二章"})
store.Add(Task{ID: 3, Title: "写第三章"})
fmt.Println("toggle #2 ->", store.Toggle(2))
fmt.Println("toggle #99 ->", store.Toggle(99))
fmt.Println("列表:", store.List())
}
toggle #2 -> true
toggle #99 -> false
列表: [{1 写第一章 false} {2 写第二章 true} {3 写第三章 false}]
Add 里 s.index[t.ID] = len(s.tasks) 必须在 append 之前求值——len(s.tasks) 是追加前的长度,正好是新元素的下标。Toggle 通过索引直接拿到切片下标,改字段一步到位,避开了「map 元素不能改字段」的限制。List 用 slices.Clone 返回副本,防止调用方拿到内部切片后从外部改坏数据(呼应 3.1 数组、切片与扩容
里讲的共享底层数组问题)。
注意这里用了 *MemStore 接收者,因为 Add 要修改内部状态——第 4 章会正式讲方法接收者与值/指针语义的区别。
小结
- map 的零值是 nil,nil map 只读,写入会 panic;用字面量或
make初始化。 - 读用 comma-ok 形态
v, ok := m[k]才能区分「不存在」与「零值」;删除用delete,删不存在的键是安全的。 - map 元素不能原地改字段(报错
cannot assign to struct field ... in map),要「读出来、改、写回去」;这也是索引选map[int64]int而非map[int64]Task的原因。 range遍历 map 顺序随机,需要稳定顺序时取键排序再访问。- 键必须可比较:切片、map、函数不可作键;含不可比较字段的结构体也不可。
- 集合惯用
map[T]struct{}(零内存)或map[T]bool,判断存在仍用 comma-ok。 maps.Keys/Values返回迭代器,配合slices.Sorted/Collect使用;maps.Clone是浅拷贝。- 双结构
[]Task+map[int64]int兼顾顺序与 O(1) 定位,是内存版存储的合理骨架。
索引解决了「按 ID 找任务」,但任务标题是中文,len()、截断、大小写处理都还埋在字节层面。下一节 3.3 字符串、rune 与字节
会把这些坑逐个拆开,让 TaskAPI 能正确处理中文标题。想复习切片的扩容与共享问题,见 3.1 数组、切片与扩容
。
阅读导航:上一节:3.1 数组、切片与扩容 · 下一节:3.3 字符串、rune 与字节 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。