大家好,我是极客老墨。
写 Go 之前,我在 Java 里用 ArrayList、HashMap 这些集合类。转到 Go 发现只有数组、切片、map 三种,心想"这够用吗?"
结果发现 Go 的切片是动态数组,自动扩容,比 Java 的 ArrayList 还简洁。map 就是哈希表,但遍历顺序是随机的,这点要注意。
这篇就聊聊 Go 数组、切片和 map 的几个关键特性和几大隐蔽却常见的坑。
数组:长度固定,很少用
Go 的数组长度是类型的一部分,[3]int 和 [4]int 是不同类型:
1var arr [3]int = [3]int{10, 20, 30}
2fmt.Println(arr) // [10 20 30]
3
4// 让编译器推导长度
5arr2 := [...]int{1, 2, 3, 4, 5}
⚠️ 注意:数组是值类型,赋值和传参会复制整个数组。
数组的几种初始化方式
1// 指定索引初始化
2arr := [5]int{0: 10, 2: 20, 4: 30} // [10, 0, 20, 0, 30]
3
4// 部分初始化(其余为零值)
5arr2 := [5]int{1, 2} // [1, 2, 0, 0, 0]
6
7// 多维数组
8matrix := [2][3]int{
9 {1, 2, 3},
10 {4, 5, 6},
11}
💡 技巧:实际开发中很少直接用数组,都用切片,因为比起固定长度,我们多数更需要的是动态扩容。
切片:动态数组,自动扩容
切片是对底层数组的引用,包含三个字段:
- 指针:指向底层数组
- 长度 (len):切片中元素的数量
- 容量 (cap):从切片起始位置到底层数组末尾的元素数量
1arr := [5]int{1, 2, 3, 4, 5}
2slice := arr[1:4] // [2, 3, 4]
3
4fmt.Println("len:", len(slice)) // 3
5fmt.Println("cap:", cap(slice)) // 4
创建切片的几种方式
1// 1. 使用字面量(最常用)
2slice := []int{1, 2, 3, 4, 5}
3
4// 2. 使用 make(预分配容量)
5slice2 := make([]int, 5) // len=5, cap=5
6slice3 := make([]int, 3, 5) // len=3, cap=5
7
8// 3. nil 切片 vs 空切片
9var nilSlice []int // nil 切片
10emptySlice := []int{} // 空切片
11
12fmt.Println(nilSlice == nil) // true
13fmt.Println(emptySlice == nil) // false
切片操作:切取、追加、复制
1s := []int{1, 2, 3, 4, 5}
2
3// 切取子集
4s1 := s[1:3] // [2, 3]
5s2 := s[:3] // [1, 2, 3]
6s3 := s[2:] // [3, 4, 5]
7
8// 追加元素
9s = append(s, 6) // 追加单个
10s = append(s, 7, 8, 9) // 追加多个
11s = append(s, []int{10, 11}...) // 追加切片
12
13// 复制切片
14src := []int{1, 2, 3}
15dst := make([]int, len(src))
16copy(dst, src)
切片扩容机制
当 append 导致容量不足时,Go 会分配新的底层数组:
- 容量小于 1024:容量翻倍
- 容量大于等于 1024:容量增长 25%
1s := make([]int, 0, 1)
2for i := 0; i < 10; i++ {
3 s = append(s, i)
4 fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
5}
6// 容量变化:1 -> 2 -> 4 -> 8 -> 16
切片的两个大坑
坑 1:共享底层数组
1arr := [5]int{1, 2, 3, 4, 5}
2s1 := arr[1:3] // [2, 3]
3s2 := arr[2:4] // [3, 4]
4
5s1[1] = 100 // 修改 s1 会影响 s2
6fmt.Println(s2) // [100, 4]
坑 2:append 触发扩容导致底层数组地址变更
这是最隐蔽的坑。当你 append 一个元素,如果容量不够触发了扩容,Go 会申请一块新的内存,并把老数据搬过去。此时,如果你还持有指向旧数组的指针,就会发现数据对不上了。
1s := make([]int, 0, 3)
2s = append(s, 1, 2, 3)
3ptr1 := &s[0]
4s = append(s, 4) // 触发扩容
5ptr2 := &s[0] // ptr1 != ptr2,地址变了!
老墨避坑指南:
永远不要假设
append之后的切片地址不变。在go-tutorial-code/07-collections/slice_pitfalls.go中,我写了一个专门的演示程序,建议你运行看下地址的变化。
解决方案:使用 copy 创建独立副本
1s1 := []int{1, 2, 3}
2s2 := make([]int, len(s1))
3copy(s2, s1) // s2 是独立的副本
这样操作后,s2 的任何改动(包括追加元素导致的扩容),都和 s1 没关系了。
切片的常用操作
1// 删除元素(删除索引 2)
2s := []int{1, 2, 3, 4, 5}
3s = append(s[:2], s[3:]...) // [1, 2, 4, 5]
4
5// 插入元素(在索引 2 插入 3)
6s = []int{1, 2, 4, 5}
7s = append(s[:2], append([]int{3}, s[2:]...)...) // [1, 2, 3, 4, 5]
map:无序的键值对集合
Go 的 map 就是哈希表,但有几个特点:
- 无序:遍历顺序是随机的
- 引用类型:传递时不会复制数据
- 键必须可比较:不能用切片、map、函数作为键
- 非线程安全:并发读写需要加锁
创建 map 的几种方式
1// 1. 使用 make
2m := make(map[string]int)
3m["Alice"] = 95
4
5// 2. 使用字面量
6m2 := map[string]int{
7 "Alice": 95,
8 "Bob": 88,
9}
10
11// 3. 预分配容量(性能优化)
12m3 := make(map[string]int, 100)
13
14// 4. nil map(不能写入)
15var m4 map[string]int // nil map
16// m4["a"] = 1 // panic!
⚠️ 小坑:nil map 不能写入,必须先 make 初始化,新手经常会犯这个错误。
map 的基本操作
1m := make(map[string]int)
2
3// 添加/修改
4m["Alice"] = 95
5m["Bob"] = 88
6
7// 读取
8score := m["Alice"] // 95
9
10// 检查键是否存在
11if score, ok := m["Alice"]; ok {
12 fmt.Println("Found:", score)
13} else {
14 fmt.Println("Not found")
15}
16
17// 删除
18delete(m, "Bob")
19
20// 遍历(顺序随机)
21for key, value := range m {
22 fmt.Printf("%s: %d\n", key, value)
23}
💡 技巧:读取不存在的键会返回零值,所以要用 value, ok := m[key] 判断键是否存在。
map 的高级用法
1// 1. map 的值是切片
2m := make(map[string][]int)
3m["a"] = []int{1, 2, 3}
4m["a"] = append(m["a"], 4)
5
6// 2. map 的值是 map
7m2 := make(map[string]map[string]int)
8m2["user1"] = make(map[string]int)
9m2["user1"]["score"] = 95
10
11// 3. 使用结构体作为键
12type Point struct {
13 X, Y int
14}
15m3 := make(map[Point]string)
16m3[Point{1, 2}] = "A"
17
18// 4. 统计词频
19text := "hello world hello"
20wordCount := make(map[string]int)
21for _, word := range strings.Fields(text) {
22 wordCount[word]++
23}
map 的并发安全
这是面试高频题,也是生产环境最容易崩溃的点:Go 原生的 map 不是线程安全的。如果多个 Goroutine 同时对同一个 map 进行读写,程序会直接 panic 崩溃,而且这个 panic 无法通过 recover 捕获!
1m := make(map[string]int)
2go func() { for { m["a"] = 1 } }()
3go func() { for { _ = m["a"] } }() // 运行一会儿就会 panic: concurrent map read and map write
老墨避坑指南:
- 常规方案:使用
sync.RWMutex封装 map。这是最通用的做法。 - 读多写少:使用
sync.Map,它是内置的安全 map,但性能在写多场景下不如加锁。 - 检测工具:运行程序时带上
-race标志(如go run -race main.go),它能帮你揪出隐藏的并发写冲突。后边老墨为专门出文章来讲解这个知识点。
这里给出两个简单的解决方案,请看代码:
1// 方案 1:使用 sync.RWMutex
2type SafeMap struct {
3 mu sync.RWMutex
4 m map[string]int
5}
6
7func (sm *SafeMap) Set(key string, value int) {
8 sm.mu.Lock()
9 defer sm.mu.Unlock()
10 sm.m[key] = value
11}
12
13// 方案 2:使用 sync.Map(读多写少场景)
14var m sync.Map
15m.Store("key", "value")
16value, ok := m.Load("key")
关于 sync.Map 的使用,请关注老墨后续文章。
具体的并发安全实现代码,可以参考
go-tutorial-code/07-collections/map_concurrency.go。
完整示例
把前面讲的知识点串起来,看个完整的例子:
1package main
2
3import (
4 "fmt"
5 "strings"
6)
7
8func main() {
9 // 1. 数组(很少用)
10 arr := [3]int{10, 20, 30}
11 fmt.Println("Array:", arr)
12
13 // 2. 切片(常用)
14 slice := []int{1, 2, 3, 4, 5}
15
16 // 切取子集
17 subSlice := slice[1:3]
18 fmt.Println("SubSlice:", subSlice)
19
20 // 追加元素
21 slice = append(slice, 6)
22 fmt.Println("Appended:", slice)
23
24 // 3. map(常用)
25 scores := make(map[string]int)
26 scores["Alice"] = 95
27 scores["Bob"] = 88
28
29 // 检查键是否存在
30 if score, ok := scores["Alice"]; ok {
31 fmt.Printf("Alice: %d\n", score)
32 }
33
34 // 删除
35 delete(scores, "Bob")
36
37 // 遍历(顺序随机)
38 for name, score := range scores {
39 fmt.Printf("%s: %d\n", name, score)
40 }
41
42 // 4. 统计词频
43 text := "hello world hello go"
44 wordCount := make(map[string]int)
45 for _, word := range strings.Fields(text) {
46 wordCount[word]++
47 }
48 fmt.Println("Word count:", wordCount)
49
50 // 5. 切片操作
51 s := []int{1, 2, 3, 4, 5}
52
53 // 删除索引 2 的元素
54 s = append(s[:2], s[3:]...)
55 fmt.Println("After delete:", s)
56
57 // 复制切片
58 s2 := make([]int, len(s))
59 copy(s2, s)
60 fmt.Println("Copied:", s2)
61}
这个例子展示了:
- 数组的声明
- 切片的创建、切取、追加
- map 的创建、读取、删除、遍历
- 统计词频的实际应用
- 切片的删除和复制操作
老墨总结
Go 数组、切片和 map 的几个亮点:
- 数组长度固定:长度是类型的一部分,实际开发很少用
- 切片是动态数组:自动扩容,包含指针、长度、容量三个字段
- 切片共享底层数组:修改时要小心,用 copy 创建独立副本
- map 遍历是随机的:不要依赖遍历顺序,需要有序用切片存储键
- nil map 不能写入:必须先 make 初始化
实战建议:
- 优先使用切片而不是数组
- 预分配容量提升性能:
make([]int, 0, 100) - 用 copy 创建独立副本,避免共享底层数组
- 检查 map 键是否存在:
value, ok := m[key] - 并发访问 map 要加锁或用 sync.Map
- nil 切片可以直接 append,但 nil map 不能写入
Go 的数据结构设计简洁但强大,切片和 map 是日常开发的主力,掌握它们的特性和陷阱很重要。
你在用 Go 时,踩过切片共享底层数组的坑吗?或者 map 遍历顺序随机的坑?欢迎评论区讨论!
极客老墨,继续折腾!
练习题
- 创建一个包含 10 个元素的整型切片,使用 range 遍历并打印所有偶数
- 统计一段英文文本中每个单词出现的次数,使用 map[string]int 存储并打印结果
- 实现一个函数,删除切片中的重复元素(提示:用 map 记录已出现的元素)
- 实现一个函数,反转切片中的元素(提示:双指针交换)
- 编写代码演示切片共享底层数组的问题,并用 copy 给出解决方案
- 使用 map 实现一个简单的缓存系统,支持 Get、Set 和 Delete 操作
快来评论区秀出你的代码,大家一起讨论!
极客老墨,继续折腾!
如果有任何问题,欢迎在评论区留言或关注公众号「极客老墨」交流。
完整示例代码在 go-tutorial-code/07-collections。