《Go 语言编程入门》3.2 map 与集合惯用法

本节给 TaskAPI 加上 map[int64]Task 索引,让按 ID 查找从遍历降为常数时间:从字面量与 make 起步,讲透 comma-ok 惯用法与「读不存在的键返回零值」这个经典陷阱,再说明键的约束、遍历顺序随机、用 map 做集合,最后用 maps 包与双结构索引组装出内存版存储。

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 与字节 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练