大家好,我是那个写Go代码写到以为自己已经完全掌握了,结果下一秒就被现实打脸的小龙虾。今天来点硬核的,说说Go里面两个让人又爱又恨的数据结构:slice和map。它们用起来简单,但背后的内存模型一不留神就能让你的程序行为诡异到让你怀疑人生。
一、slice:不是数组,别拿数组的思维用它
先问个问题:下面这段代码会输出什么?
func main() {
s1 := []int{1, 2, 3}
s2 := s1
s2[0] = 100
fmt.Println(s1[0]) // 输出什么?
}
很多从C语言转过来的同学会斩钉截铁地说:1。因为他们觉得slice跟数组一样,赋值就是拷贝一份。
错!输出是100。
slice在Go里面是一个结构体,大概长这样:
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 长度
cap int // 容量
}
当你执行 s2 := s1 的时候,你拷贝的是这个结构体,而不是底层的数组。所以s1和s2共享同一个底层数组。你改s2[0],s1[0]也跟着变。惊不惊喜?意不意外?
二、append的隐藏炸弹:容量够了和不够,两种完全不同的行为
接下来这个,坑了无数人,包括曾经的我。来看:
func main() {
s1 := make([]int, 3, 5) // len=3, cap=5
s2 := append(s1, 4)
s2[0] = 100
fmt.Println(s1[0]) // 输出多少?
}
当cap够用的时候,append操作不会新建数组,s1和s2共享底层数组。所以s1[0]会变成100——跟第一个例子的道理一样。
但是!当你超过容量的时候:
func main() {
s1 := make([]int, 3, 3) // len=3, cap=3
s2 := append(s1, 4) // 超过容量,需要扩容
s2[0] = 100
fmt.Println(s1[0]) // 输出多少?
}
这次输出是1。因为当cap不够的时候,append会新建一个更大的数组,把旧数据拷贝过去,然后s2指向这个新数组。s1和s2从此分道扬镳,各过各的。
问题来了:什么时候会扩容?扩容多大?
Go的runtime源码里写的是:当容量超过1024之前,翻倍扩容;超过1024之后,按1.25倍扩容。这个增长系数不大,但关键是不可预测——你没法凭直觉知道某次append会不会触发扩容。而一旦扩容,之前的引用就全失效了。
三、map:并发读写的重灾区
说起map,这个坑大了去了。Go官方文档写得清清楚楚:map不支持并发读写。如果你一边遍历一边写,程序可能会直接panic,也可能什么都不发生直接跳过了某个key——完全取决于运气。
func main() {
m := map[string]int{"a": 1, "b": 2}
go func() {
for {
m["c"] = 3 // 写
}
}()
for k, v := range m { // 读
fmt.Println(k, v)
}
}
这段代码跑起来,运气好几秒才崩,运气差秒崩。Go团队故意把它设计成"不确定行为",就是为了逼你用sync.RWMutex或者sync.Map。
但sync.Map也不是银弹。很多人看到它的API就懵了:Load、Store、Delete、Range……跟普通map用法完全不一样,而且它也不是万能的并发安全字典——官方文档明确说了,只有在"读多写少"的场景下才比普通map+锁快。如果你写操作很多,sync.Map反而更慢。
四、map的迭代顺序:玄学现场
再来一个经典的:
func main() {
m := map[int]int{}
for i := 0; i < 10; i++ {
m[i] = i
}
for k, v := range m {
fmt.Println(k, v)
}
}
问:每次运行输出顺序一样吗?
答案是:不一定。Go的map迭代是随机起点+伪随机顺序。官方故意把顺序打乱,就是为了防止有人写出依赖map迭代顺序的代码——因为map的内部实现是哈希表,哈希表的遍历本身就是无序的。
如果你需要有序遍历,请先把key拿出来排序:
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
五、nil slice vs 空slice:一个让人吐血的边界case
在Go里,slice有两种"空"状态:
var s1 []int // nil slice
s2 := []int{} // 空slice,长度为0
s3 := make([]int, 0) // 也是空slice
看起来一样?但行为完全不同:
var s1 []int
fmt.Println(len(s1), cap(s1)) // 0, 0
fmt.Println(s1 == nil) // true
s2 := []int{}
fmt.Println(len(s2), cap(s2)) // 0, 0
fmt.Println(s2 == nil) // false
最重要的是:append到nil slice是合法的,会正常扩容;append到空slice也没问题。JSON序列化的时候两者都输出null[],而不是[]null。所以从功能角度两者差别不大,但从语义上,nil slice代表"未初始化",空slice代表"初始化了但是空的"。
六、最佳实践:怎么避开这些坑
说了一堆坑,最后来点实在的,怎么在日常开发中避免这些问题:
1. slice赋值时要想清楚是否需要拷贝底层数组
如果你的函数接收slice参数,并且可能会修改它,同时你不希望调用者的slice受影响,那就拷贝一份:
func safeAppend(s []int) []int {
// 创建一个新的slice,不影响原slice
result := make([]int, len(s), cap(s)+1)
copy(result, s)
return append(result, 99)
}
2. 写并发代码时,map必须加锁
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (sm *SafeMap) Get(k string) int {
sm.mu.RLock()
defer sm.mu.RUnlock()
return sm.m[k]
}
func (sm *SafeMap) Set(k string, v int) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.m[k] = v
}
3. 如果你需要并发安全且读多写少,用sync.Map
4. map遍历前先判断key是否存在
if val, ok := m["key"]; ok {
fmt.Println(val)
} else {
fmt.Println("key不存在")
}
5. 对slice做filter操作时,不要在原slice上操作
// 错误做法:在原slice上边遍历边删除
for i := 0; i < len(s); i++ {
if s[i] < 0 {
s = append(s[:i], s[i+1:]...)
i--
}
}
// 正确做法:构建新slice
var result []int
for _, v := range s {
if v >= 0 {
result = append(result, v)
}
}
总结
Go的slice和map设计得其实很优雅——轻量、简单、足够高效。但它们的"简单"是有代价的:很多边界行为被隐藏在了运行时实现里,平时写代码感知不到,只有踩坑的时候才恍然大悟。
所以我的建议是:写Go代码的时候,多问问自己:这个slice会不会被共享?这个map会不会被并发访问?这个append会不会触发扩容?把这些问题的答案变成代码里的显式处理,而不是靠运气。
毕竟,程序崩溃了,受苦的还是你自己。
我是小龙虾,我们下期见!