《Go 语言编程入门》9.2 slices/maps/cmp 标准库

本节用三个泛型标准库重写 TaskAPI 的查询:slices 的排序/查找/删除/去重、maps 的 Keys/Clone/Equal、cmp 的 Compare/Or。文章给出三类函数的速查表,实测按标题排序后分页、按条件去重、把 map 索引摊成有序列表三段代码,并点出 SortFunc 不稳定、删除类函数会原地修改这两个高频陷阱。

9.2 slices/maps/cmp 标准库

上一节我们学会了写泛型。但绝大多数时候,你不需要自己写泛型函数——标准库已经把最常用的都写好了,而且写得比手写更稳。slices、maps、cmp 这三个包是 Go 1.21 转正的泛型标准库,它们替代了无数「自己写个 for 循环」的场景。这一节把它们和 TaskAPI 的查询逻辑接起来。

本节给 TaskAPI 的列表查询做重构:用 slices.SortFunc 按标题排序、用 slices.DeleteFunc 过滤已完成任务、用 maps.Keys 把存储索引摊成有序切片,最后交给上一节的 Page[task.Task] 分页。这些函数都基于泛型,正好把第 9 章的两节串成一条线。

9.2.1 三个包的分工

先建立全局印象:

包作用对象典型用途
slices切片 []E排序、查找、删除、去重、比较
maps映射 map[K]V取键/值、克隆、比较、批量删
cmp可比较/可排序值比较、取小、返回首个非零值

它们全部是泛型 API,调用时类型由实参推断,和上一节的 Sum、Max 一样自然。一个心智模型:「我要对切片/映射做点什么」——先查这三个包,再考虑自己写循环。

9.2.2 slices 速查表

slices 的函数很多,按用途归类:

类别函数说明
排序Sort、SortFunc、SortStableFunc、IsSorted就地排序,Func 版自定义比较
查找Contains、Index、IndexFunc、BinarySearch返回是否包含 / 下标
删除Delete、DeleteFunc就地删除并返回新切片
去重Compact、CompactFunc去掉相邻重复元素
增改Insert、Replace、Clone、Grow插入、替换、克隆、预分配
极值Min、Max、MinFunc、MaxFunc最小/最大元素
迭代All、Backward、Sorted、Collect与 iter.Seq 互转

两个最容易误用的点先埋在这里:Compact 只去相邻重复(要先排序才能全量去重),Delete*/Compact/Insert 都是原地修改(返回的切片要重新赋值)。

9.2.3 maps 速查表

maps 包比 slices 小得多:

函数说明
Keys、Values返回 iter.Seq,可配合 slices.Sorted 转有序切片
Clone浅拷贝一个映射
Copy把 src 的键值覆盖进 dst
Equal、EqualFunc比较两个映射是否相等
DeleteFunc按条件删除键值对
Insert把 iter.Seq2 的键值对插入映射

Keys/Values 返回的是迭代器而非切片,这是刻意的设计——避免为「只想遍历」的调用方多分配一个切片。需要切片时再显式收集。

9.2.4 cmp:比较的通用工具

cmp 只有三个函数,但出场率极高:

func Compare[T cmp.Ordered](x, y T) int  // x<y 返回负,相等返回 0,x>y 返回正
func Less[T cmp.Ordered](x, y T) bool    // x < y
func Or[T comparable](vals ...T) T        // 返回第一个非零值

cmp.Compare 的返回值约定(负/零/正)正好是 slices.SortFunc 期望的比较函数签名,所以两者是天生一对:

slices.SortFunc(tasks, func(a, b task.Task) int {
	return cmp.Compare(a.Title, b.Title)
})

cmp.Or 是「兜底值」的利器:

fmt.Println(cmp.Or("", "", "fallback")) // fallback

它常用来给配置项填默认值——第一个非零参数胜出。

9.2.5 重写查询:按标题排序后分页

把 TaskAPI 的「列出任务」改成「按标题排序、分页返回」:

package main

import (
	"cmp"
	"fmt"
	"slices"

	"taskapi/internal/pagination"
	"taskapi/internal/task"
)

func main() {
	tasks := []task.Task{
		task.New(1, "写第 9 章"),
		task.New(2, "复习 slices"),
		task.New(3, "补充测试"),
	}
	slices.SortFunc(tasks, func(a, b task.Task) int {
		return cmp.Compare(a.Title, b.Title)
	})
	p := pagination.New(tasks, 1, 2)
	fmt.Printf("total=%d page=%d size=%d items=%d\n", p.Total, p.Number, p.Size, len(p.Items))
	for _, t := range p.Items {
		fmt.Println("-", t.Title)
	}
}

实测输出:

total=3 page=1 size=2 items=2
- 写第 9 章
- 复习 slices

注意排序是按 UTF-8 字节序比较的:写、复、补 的 UTF-8 编码依次递增,所以结果是「写第 9 章」在前。对中文排序,cmp.Compare 给的是确定但未必符合人类语感的顺序——要做拼音排序得引入额外依赖,卷一不做。

9.2.6 过滤与去重

实际业务里更常见的是「筛掉已完成的」「去掉重复标题」。用 slices.DeleteFunc 与 slices.Compact:

nums := []int{1, 2, 3, 4, 5}
odd := slices.DeleteFunc(slices.Clone(nums), func(n int) bool { return n%2 == 0 })
fmt.Println("odd:", odd) // [1 3 5]

uniq := slices.Compact(slices.Clone([]string{"a", "a", "b"}))
fmt.Println("compact:", uniq) // [a b]

这里有两个必须记住的坑:

  1. DeleteFunc 会原地修改底层数组。上例先 slices.Clone 出一份副本再删,就是为了不破坏 nums。如果 nums 之后还要用,一定要先克隆。
  2. Compact 只去相邻重复。[]string{"a", "b", "a"} 经 Compact 后仍是三个元素。要全量去重,先 Sort 再 Compact,或者用 map 手工去重。

9.2.7 从 map 到有序列表

MemStore 内部用 map[int64]task.Task 存任务,遍历顺序是随机的。要输出稳定顺序,就得把 map 转成排序后的切片:

m := map[string]int{"a": 1, "b": 2}
keys := slices.Sorted(maps.Keys(m))
fmt.Println("keys:", keys) // [a b]

maps.Keys(m) 返回 iter.Seq[string],slices.Sorted 把它收集成切片并排序——一行完成「取键 + 排序」。如果还要按 value 排序,就得自己收集再 SortFunc:

type kv struct {
	K string
	V int
}
pairs := make([]kv, 0, len(m))
for k, v := range m {
	pairs = append(pairs, kv{k, v})
}
slices.SortFunc(pairs, func(a, b kv) int { return cmp.Compare(a.V, b.V) })

maps.Clone 与 maps.Equal 则常用于「快照对比」:

clone := maps.Clone(m)
fmt.Println("equal:", maps.Equal(m, clone)) // true

9.2.8 实战:把 map 索引摊成有序列表

MemStore 用 map[int64]task.Task 存任务,遍历顺序随机。要给它加一个「按标题排序的列表」,标准写法是「取键排序 → 按序取值 → 再按标题排序」:

index := map[int64]task.Task{
	3: task.New(3, "补充测试"),
	1: task.New(1, "写第 9 章"),
	2: task.New(2, "复习 slices"),
}
ids := slices.Sorted(maps.Keys(index))
out := make([]task.Task, 0, len(index))
for _, id := range ids {
	out = append(out, index[id])
}
slices.SortFunc(out, func(a, b task.Task) int {
	return cmp.Compare(a.Title, b.Title)
})
for _, t := range out {
	fmt.Printf("%d %s\n", t.ID, t.Title)
}

实测输出:

1 写第 9 章
2 复习 slices
3 补充测试

第一段 slices.Sorted(maps.Keys(index)) 先按 ID 建立稳定顺序,第二段再按标题排序。如果只关心标题顺序,其实可以跳过第一段,直接遍历 map 收集——但显式排序 ID 的好处是结果与 map 的随机遍历无关,可复现、可测试。

这段代码还演示了泛型的组合威力:maps.Keys 产出的 iter.Seq[int64] 直接被 slices.Sorted 消费,两个泛型 API 像管道一样接在一起,中间没有一次类型断言。

9.2.9 稳定性、性能与陷阱

把本节所有易错点汇总:

陷阱说明对策
SortFunc 不稳定相等元素相对顺序可能变需稳定排序用 SortStableFunc
DeleteFunc 原地改破坏原切片先 slices.Clone
Compact 只去相邻非相邻重复保留先排序或用 map
Keys 返回迭代器不能直接索引用 slices.Sorted 或 slices.Collect
Contains 需 comparable结构体含切片则不行用 ContainsFunc
排序比较函数返回 bool签名不匹配用 cmp.Compare 返回 int

关于性能:slices.Sort 用的是 pdqsort,平均 O(n log n),比手写冒泡快得多;Compact、DeleteFunc 都是 O(n) 单趟扫描。除非有实测数据,否则不要为了「性能」手写循环——标准库的泛型实现经过大量基准测试,你的手写版大概率更慢。

9.2.10 小结

slices/maps/cmp 是泛型最成功的应用:它们把「对集合的常见操作」变成了可组合的积木,而且类型安全、无需断言。记住三条使用纪律:

  1. 先查标准库,再写循环。绝大多数集合操作已经有人写好并优化过。
  2. 注意「原地修改」。Delete/Compact/Insert 都会改底层数组,需要保留原数据时先 Clone。
  3. 排序用 cmp.Compare 拼比较函数,别手写 if a < b 的三段式。

下一节我们换个角度:泛型这么好用,是不是到处都该用?答案是否定的。下一节讨论泛型的取舍——什么时候用泛型、什么时候用接口、什么时候老老实实写具体类型。

阅读导航:上一节:9.1 类型参数与约束 · 下一节:9.3 泛型的取舍 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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