大家好,我是极客老墨。

写 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

老墨避坑指南

  1. 常规方案:使用 sync.RWMutex 封装 map。这是最通用的做法。
  2. 读多写少:使用 sync.Map,它是内置的安全 map,但性能在写多场景下不如加锁。
  3. 检测工具:运行程序时带上 -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 的几个亮点:

  1. 数组长度固定:长度是类型的一部分,实际开发很少用
  2. 切片是动态数组:自动扩容,包含指针、长度、容量三个字段
  3. 切片共享底层数组:修改时要小心,用 copy 创建独立副本
  4. map 遍历是随机的:不要依赖遍历顺序,需要有序用切片存储键
  5. nil map 不能写入:必须先 make 初始化

实战建议

  • 优先使用切片而不是数组
  • 预分配容量提升性能:make([]int, 0, 100)
  • 用 copy 创建独立副本,避免共享底层数组
  • 检查 map 键是否存在:value, ok := m[key]
  • 并发访问 map 要加锁或用 sync.Map
  • nil 切片可以直接 append,但 nil map 不能写入

Go 的数据结构设计简洁但强大,切片和 map 是日常开发的主力,掌握它们的特性和陷阱很重要。


你在用 Go 时,踩过切片共享底层数组的坑吗?或者 map 遍历顺序随机的坑?欢迎评论区讨论!

极客老墨,继续折腾!

练习题

  1. 创建一个包含 10 个元素的整型切片,使用 range 遍历并打印所有偶数
  2. 统计一段英文文本中每个单词出现的次数,使用 map[string]int 存储并打印结果
  3. 实现一个函数,删除切片中的重复元素(提示:用 map 记录已出现的元素)
  4. 实现一个函数,反转切片中的元素(提示:双指针交换)
  5. 编写代码演示切片共享底层数组的问题,并用 copy 给出解决方案
  6. 使用 map 实现一个简单的缓存系统,支持 Get、Set 和 Delete 操作

快来评论区秀出你的代码,大家一起讨论!


极客老墨,继续折腾!

如果有任何问题,欢迎在评论区留言或关注公众号「极客老墨」交流。

完整示例代码在 go-tutorial-code/07-collections


相关阅读