Go slices 和 maps 包入门:少写一点重复集合代码

本文详解 Go 标准库 slices 和 maps 包的常用函数,涵盖克隆、查找、排序、删除等操作,对比手写循环与标准库的效率差异并给出实战选型建议。

集合工具终于进入标准库

Go 早期处理切片和 map 时,经常要自己写一些小工具函数:克隆切片、判断相等、删除元素、提取 map 的 key、排序。后来随着泛型进入语言,标准库也逐步提供了 slices 和 maps 这类集合辅助包。它们不会把 Go 变成链式集合语言,但能减少很多重复样板代码。

这篇文章讲几个入门最常用的函数。重点不是把 API 列表背完,而是知道什么时候可以用标准库,什么时候普通 for 循环仍然更清楚。同时附带性能对比、常见陷阱和实战场景。

slices.Clone 复制切片

切片赋值不是复制底层数组:

a := []string{"Go", "PHP"}
b := a
b[0] = "Rust"
fmt.Println(a[0]) // Rust

使用 slices.Clone:

b := slices.Clone(a)
b[0] = "Rust"
fmt.Println(a[0]) // Go

这比手写更直接:

b := append([]string(nil), a...)

两种都能用。slices.Clone 的好处是意图更明确:我要克隆切片。对于 []byte 类型,还可以用 bytes.Clone,语义一样但意图更清晰。

slices.Contains 和 Index

tags := []string{"Go", "Backend", "Tutorial"}

if slices.Contains(tags, "Go") {
	fmt.Println("has Go")
}

index := slices.Index(tags, "Backend")
fmt.Println(index)

以前我们常自己写 ContainsString。现在简单可比较类型可以直接用标准库。

注意 Contains 适合小列表。很大的数据、频繁查询,更适合用 map 做 set:

tagSet := map[string]struct{}{}
for _, tag := range tags {
	tagSet[tag] = struct{}{}
}

工具函数不是算法替代品。数据量和查询频率仍然重要。slices.Index 使用线性搜索,时间复杂度是 O(n)。对于有序切片,应该使用 slices.BinarySearch。

slices.Sort 和 SortFunc

基本排序:

nums := []int{3, 1, 2}
slices.Sort(nums)

结构体排序:

type User struct {
	Name  string
	Score int
}

slices.SortFunc(users, func(a, b User) int {
	return b.Score - a.Score
})

SortFunc 的比较函数返回负数、零、正数,表示 a 小于、等于、大于 b。上面用 b.Score - a.Score 实现分数倒序。对于可能溢出的整数,写成显式判断更稳:

slices.SortFunc(users, func(a, b User) int {
	switch {
	case a.Score > b.Score:
		return -1
	case a.Score < b.Score:
		return 1
	default:
		return 0
	}
})

排序仍然是原地修改切片。需要保留原顺序时先 Clone。

slices.BinarySearch 二分搜索

对于已排序的切片,slices.BinarySearch 的时间复杂度是 O(log n),比线性搜索快得多:

nums := []int{1, 3, 5, 7, 9, 11}
idx, found := slices.BinarySearch(nums, 7)
if found {
	fmt.Printf("found at index %d\n", idx)
}

对于自定义类型,使用 BinarySearchFunc:

type Product struct {
	ID    int
	Name  string
	Price float64
}

products := []Product{
	{ID: 1, Name: "Mouse", Price: 49.9},
	{ID: 2, Name: "Keyboard", Price: 129.0},
	{ID: 3, Name: "Monitor", Price: 899.0},
}

// 按 Price 排序
slices.SortFunc(products, func(a, b Product) int {
	if a.Price < b.Price {
		return -1
	}
	if a.Price > b.Price {
		return 1
	}
	return 0
})

idx, found := slices.BinarySearchFunc(products, Product{Price: 129.0}, func(a, b Product) int {
	if a.Price < b.Price {
		return -1
	}
	if a.Price > b.Price {
		return 1
	}
	return 0
})

二分搜索的前提是切片已经排序。如果切片未排序,搜索结果不可预期。和 sort.Search 相比,slices.BinarySearch 借助泛型省去了类型断言,API 更直接。

slices.Insert、Delete 和 Replace

在指定位置插入元素:

items := []string{"a", "b", "d"}
items = slices.Insert(items, 2, "c")
// items = ["a", "b", "c", "d"]

删除元素:

items = slices.Delete(items, 1, 2)
// items = ["a", "c", "d"]

替换元素:

items = slices.Replace(items, 0, 2, "x", "y")
// items = ["x", "y", "d"]

slices.Delete 返回一个新切片,一定要接住返回值。它和 append 一样,会调整切片视图,但底层数组上的被删除元素不会被置零,仍然持有引用。如果需要释放内存,需要显式截断:

items = slices.Delete(items, i, i+1)
// 如果需要释放被删除元素的内存
var zero T
items[len(items)-1] = zero
items = items[:len(items)-1]

slices.Reverse 和 Compact

反转切片:

nums := []int{1, 2, 3, 4, 5}
slices.Reverse(nums)
// nums = [5, 4, 3, 2, 1]

去除连续重复元素:

items := []int{1, 1, 2, 2, 2, 3, 3}
items = slices.Compact(items)
// items = [1, 2, 3]

slices.Compact 只去除相邻的重复元素。如果要完全去重,需要先排序再 Compact,或者用 map。注意 Compact 同样不会清空尾部冗余元素,对内存敏感的场景需要额外处理。

maps.Keys、Values、Clone 和 Equal

提取 map 的 key:

source := map[string]int{"go": 1, "php": 2}
keys := maps.Keys(source)

提取 map 的值:

vals := maps.Values(source)

复制 map:

copied := maps.Clone(source)
copied["go"] = 10
fmt.Println(source["go"]) // 1

map 遍历顺序不稳定,如果要展示或测试输出,一定要排序 key:

keys := maps.Keys(source)
slices.Sort(keys)
for _, k := range keys {
	fmt.Printf("%s: %d\n", k, source[k])
}

判断两个 map 是否相等:

m1 := map[string]int{"a": 1, "b": 2}
m2 := map[string]int{"a": 1, "b": 2}
if maps.Equal(m1, m2) {
	fmt.Println("maps are equal")
}

maps.Equal 要求键和值类型均为可比较类型。如果值是结构体或切片,需要使用 maps.EqualFunc。对于 map[string][]int 这种值不可直接比较的 map,maps.EqualFunc 可以自定义比较逻辑:

m1 := map[string][]int{"a": {1, 2}}
m2 := map[string][]int{"a": {1, 2}}

if maps.EqualFunc(m1, m2, slices.Equal) {
	fmt.Println("maps are equal")
}

切片操作性能对比

不同方法在数据量变化时的表现差异很大。下面是一些经验数据(Go 1.22,amd64):

操作100 个元素10000 个元素复杂度说明
slices.Contains~100ns~10usO(n)线性搜索
map 查找~50ns~50nsO(1)哈希查找
slices.Sort~5us~1msO(n log n)TimSort
BinarySearch~50ns~100nsO(log n)需先排序

结论:数据量小(<100)时 Contains 够快;频繁查询或数据量大时用 map;有序数据用 BinarySearch。

实战场景:配置合并与去重

实际项目中经常遇到配置列表去重和合并的需求。假设有两个环境变量来源的配置列表,需要合并并去重:

func MergeUnique(first, second []string) []string {
	seen := make(map[string]struct{}, len(first)+len(second))
	for _, s := range first {
		seen[s] = struct{}{}
	}
	for _, s := range second {
		seen[s] = struct{}{}
	}

	result := make([]string, 0, len(seen))
	for k := range seen {
		result = append(result, k)
	}
	slices.Sort(result)
	return result
}

这个函数先用 map 去重(O(n)),再提取 key 排序(O(n log n))。比双重循环比较(O(n^2))效率高得多。最后用 slices.Sort 保证输出稳定有序。

测试里也能少写辅助函数

集合工具在测试里尤其顺手。比如你要断言返回的标签包含某个值:

func TestTags(t *testing.T) {
	tags := BuildTags(Article{Title: "Go 入门"})
	if !slices.Contains(tags, "Go") {
		t.Fatalf("tags = %v, want Go", tags)
	}
}

比较两个切片:

got := []string{"Go", "Backend"}
want := []string{"Go", "Backend"}

if !slices.Equal(got, want) {
	t.Fatalf("got %v, want %v", got, want)
}

如果顺序不重要,可以先克隆并排序:

gotSorted := slices.Clone(got)
wantSorted := slices.Clone(want)
slices.Sort(gotSorted)
slices.Sort(wantSorted)

if !slices.Equal(gotSorted, wantSorted) {
	t.Fatalf("got %v, want %v", got, want)
}

这比手写两个嵌套循环清楚很多。测试代码也是代码,能用标准库表达意图时,就不用在每个包里维护一堆 assertStringSliceEqual。

不过测试里也要注意错误消息。slices.Equal 只告诉你是否相等,失败时最好把 got 和 want 都打印出来。测试失败信息越清楚,后面修问题越快。

常见错误与陷阱

陷阱 1:忘记接住 Delete 的返回值

slices.Delete(items, i, i+1) // 错误,items 没变
items = slices.Delete(items, i, i+1) // 正确

陷阱 2:对未排序切片使用 BinarySearch

BinarySearch 要求切片预先排序,未排序时结果不可预期。如果查询后切片的排序状态可能变化,每次查询前先确认是否仍然有序。

陷阱 3:map 遍历顺序依赖

for k, v := range m {
	// 每次顺序可能不同
}

永远不要假设 map 遍历顺序。测试时先用 maps.Keys + slices.Sort 排序后再断言。

陷阱 4:Clone 后修改 slice of pointers

type Item struct{ Name string }
items := []*Item{{Name: "a"}}
cloned := slices.Clone(items)
cloned[0].Name = "b"
fmt.Println(items[0].Name) // "b",底层对象被改了

Clone 只复制切片头,不深度复制指针指向的对象。如果需要深拷贝,要遍历并逐个复制。

陷阱 5:Delete 不释放内存

slices.Delete 不置零被删除的元素,可能导致内存泄漏。对于大对象切片,删除后手动截断:

items = slices.Delete(items, i, i+1)
var zero *BigStruct
items = append(items, zero)[:len(items)-1]

与 sort 包的关系

Go 1.21 引入 slices.Sort 后,旧的 sort.Ints、sort.Strings 等函数仍然可用。slices 的优势在于泛型支持,slices.SortFunc 能处理任意可比较类型,不需要为每种类型写单独的排序函数。对于简单的整数和字符串排序,两者性能相当。

旧代码中使用 sort.Slice 的地方可以逐步替换为 slices.SortFunc,语义更清晰:

// 旧方式
sort.Slice(users, func(i, j int) bool {
	return users[i].Score > users[j].Score
})

// 新方式
slices.SortFunc(users, func(a, b User) int {
	return b.Score - a.Score
})

更多实战 FAQ

Q: slices 包支持并发安全吗?
A: 不支持。slices 包的函数不保证并发安全。多 goroutine 读写同一个切片需要外部加锁(sync.Mutex 或 sync.RWMutex)。

Q: slices.Insert 和 append 性能差多少?
A: slices.Insert 内部就是基于 append 实现的,性能几乎一样。它的价值在于语义更清晰,代码可读性更好。

Q: maps.Keys 返回的切片是排序的吗?
A: 不是。Go 的 map 遍历顺序是随机的,maps.Keys 返回的顺序也不稳定。如果需要稳定顺序,必须手动 slices.Sort。

Q: slices.Compact 能去重 map 的 value 切片吗?
A: 去掉相邻重复。如果需要完全去重,先排序再 Compact。对于 map 本身的值去重,先提取 maps.Values,再排序 Compact。

Q: Go 1.23 新增了哪些 slices/maps 功能?
A: Go 1.23 引入了 slices.Reverse、slices.Chunk(分批)和 maps.All(迭代器)。这些函数进一步丰富了集合操作工具链。

Q: slices.Min 和 slices.Max 怎么用?
A: slices.Min 返回切片最小值,slices.Max 返回最大值。要求切片已排序或元素类型可比较:

nums := []int{5, 2, 8, 1, 9}
minVal := slices.Min(nums) // 1
maxVal := slices.Max(nums) // 9

对于结构体切片,使用 MinFunc 和 MaxFunc:

oldest := slices.MinFunc(users, func(a, b User) int {
	return cmp.Compare(a.Age, b.Age)
})

这些函数在编写统计和聚合逻辑时代码更简洁。

Q: 什么时候不应该用标准库集合函数?
A: 几种情况:

  • 需要原地修改且同时保留原始切片时,应先用 Clone
  • 性能极度敏感且循环可以完全避免额外分配时
  • 需要保留元素间特定顺序关系,而标准库函数会破坏时
  • 处理指向大结构的指针切片时,Delete 和 Compact 可能不释放内存

Q: 如何在切片中批量插入另一个切片的元素?
A: 使用 slices.Insert 配合展开操作符:

a := []int{1, 2, 6, 7}
b := []int{3, 4, 5}
a = slices.Insert(a, 2, b...)
// a = [1, 2, 3, 4, 5, 6, 7]

这个操作和 append 类似,背后的底层数组可能被重新分配。注意插入大量元素时性能开销较大,时间复杂度为 O(n)。

小结

slices 和 maps 让现代 Go 写集合操作更方便。克隆用 slices.Clone、maps.Clone,查找用 slices.Contains、slices.Index 和 slices.BinarySearch,排序用 slices.Sort、slices.SortFunc,插入删除用 slices.Insert、slices.Delete,去重用 slices.Compact,map 操作用 maps.Keys、maps.Values、maps.Equal。

这些工具能减少重复,但不会替你做业务判断。大数据频繁查找仍然要考虑 map,稳定输出仍然要排序 key,复杂过滤有时普通循环更清楚。标准库工具的最好用法,是让意图更明确,而不是把代码写成难懂的链式技巧。

记住几个关键原则:始终关注时间和空间复杂度,不要对未排序数据使用二分搜索,不要依赖 map 遍历顺序,删除切片元素时注意内存释放。掌握这些基础工具后,你在处理日常集合操作时就能写出既简洁又高效的 Go 代码。

slices 包常用函数速查

slices 包提供了大量基于泛型的切片操作函数:

  • slices.Clone - 深拷贝切片
  • slices.Contains - 判断是否包含元素
  • slices.Index - 查找元素索引
  • slices.Sort / SortFunc - 排序
  • slices.Delete - 删除范围元素
  • slices.Equal - 比较两个切片
  • slices.Insert - 在指定位置插入
  • slices.Replace - 替换范围元素

这些函数都是用泛型实现的,支持任何可比较类型或自定义比较函数。

maps 包常用函数速查

maps 包提供了基于泛型的 map 操作:

  • maps.Clone - 复制 map
  • maps.Copy - 从源 map 复制到目标 map
  • maps.DeleteFunc - 按条件删除键值对
  • maps.Equal - 比较两个 map
  • maps.Keys / maps.Values - 获取所有键或值(Go 1.21+)

性能对比:标准库 vs 手写循环

对于常见操作,标准库函数和手写循环的性能差异通常很小。slices.Sort 底层使用 sort.Sort 的优化版本,与手写调用没有区别。slices.Contains 对小列表的线性搜索与手写 for 循环性能相同。

但标准库函数的优势在于:

  1. 代码意图更明确
  2. 经过充分测试
  3. 未来可能获得更多优化
  4. 减少自定义辅助函数的维护负担
// 手写循环
found := false
for _, v := range items {
    if v == target {
        found = true
        break
    }
}

// 标准库
found := slices.Contains(items, target)

第二种写法在语义上更直接,读者不需要解析循环逻辑就知道你在做什么。

与旧代码的兼容性

slices 和 maps 包需要 Go 1.21+(部分函数在 1.18+ 可用)。如果你的项目需要支持旧版本 Go,可以继续使用手写循环或维护自定义工具函数。升级 Go 版本后再逐步替换为标准库函数。

实战 FAQ

Q: slices.Delete 会影响原切片吗?
A: 会。它修改底层数组但调整切片视图,你需要接住返回值:items = slices.Delete(items, i, i+1)。

Q: 什么时候应该用手写循环而不是标准库?
A: 当操作需要在遍历中做复杂逻辑(如多个条件判断、中途返回、修改多个数据结构)时,标准库的单一职责函数可能不够灵活。

Q: maps.Keys 返回的顺序稳定吗?
A: 不稳定。maps.Keys 返回的顺序取决于 map 的内部哈希表状态,与遍历顺序相同。如果需要排序,后续手动调用 slices.Sort。

Q: 这些泛型函数会增加二进制体积吗?
A: 会有一点点,但 Go 编译器会做单态化优化,只生成实际使用的类型实例。影响通常可以忽略。

最佳实践速查

场景推荐做法
复制切片slices.Clone
查找元素slices.Contains / slices.Index
排序slices.Sort / slices.SortFunc
删除元素slices.Delete(接住返回值)
复制 mapmaps.Clone
比较 mapmaps.Equal
需要排序的 keysmaps.Keys + slices.Sort

小结

slices 和 maps 让现代 Go 写集合操作更方便。克隆用 slices.Clone、maps.Clone,查找用 slices.Contains、slices.Index,排序用 slices.Sort、slices.SortFunc,删除范围用 slices.Delete。

这些工具能减少重复,但不会替你做业务判断。大数据频繁查找仍然要考虑 map,稳定输出仍然要排序 key,复杂过滤有时普通循环更清楚。标准库工具的最好用法,是让意图更明确,而不是把代码写成难懂的链式技巧。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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