Go数组和切片到底差在哪
数组是定长的、本身就是数据,传参整份拷贝,开发用得少。切片是指针加 len 加 cap 的描述符,指向底层数组,可伸缩传参轻量,是主力。它的三个经典坑都源于共享底层数组:传参改原值、切片间共享内存、append 扩容前后行为突变。要独立副本就用 copy。
刚学 Go 的时候,我一度把数组(array)和切片(slice)当成一回事,反正都是 [] 括起来装一串东西。
直到被几个诡异的现象坑了:传给函数改了没生效、两个切片莫名其妙互相影响、append 完原来的变量变了……才意识到这俩差得远。
搞懂数组和切片的区别,关键是看清它们的底层结构。这篇从内存布局讲到使用陷阱,把它们彻底分开。
先看长相上的区别
写法上就有个细微但致命的差异:数组有固定长度,切片没有。
// 数组:方括号里有数字(或 ...),长度是类型的一部分
var arr [3]int = [3]int{1, 2, 3}
// 切片:方括号里是空的,长度不固定
var sli []int = []int{1, 2, 3}
注意 [3]int 和 []int 是两种不同的类型。
更关键的是:数组的长度写死在类型里。[3]int 和 [5]int 是完全不同的类型,不能互相赋值。所以数组一旦定义,长度就焊死了,加不进第四个元素。
切片则是可伸缩的,这也是实际开发里 99% 用切片的原因。
底层结构才是根本
长相只是表象,真正的区别在底层。
数组是一块连续的内存,它本身就是数据。[3]int 就是实实在在排在一起的三个 int。
切片不是数据本身,它是一个"描述符",内部长这样(简化版):
// slice 的底层结构(runtime 里的样子)
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 当前长度
cap int // 容量(底层数组能放多少)
}
划重点:切片自己不存数据,它只是指向一个底层数组,外加记录"用了多长(len)、底层数组总共多大(cap)"。
这个"指针 + 长度 + 容量"的结构,解释了后面所有的坑。
坑一:数组传参是拷贝
因为数组本身就是数据,把它传给函数,是整个拷贝一份。
func modify(a [3]int) {
a[0] = 999 // 改的是拷贝,不影响外面
}
func main() {
arr := [3]int{1, 2, 3}
modify(arr)
fmt.Println(arr) // [1 2 3],没变
}
数组越大,拷贝开销越大。传个 [10000]int 进函数,等于复制一万个 int,很浪费。
切片传参则不一样,拷贝的是那个"描述符"(指针 +len+cap),指针还指向同一个底层数组:
func modify(s []int) {
s[0] = 999 // 通过指针改到了同一个底层数组
}
func main() {
sli := []int{1, 2, 3}
modify(sli)
fmt.Println(sli) // [999 2 3],变了!
}
所以切片传参又轻量(只拷描述符)又能改到原数据。这也是实践里都用切片的另一个原因。
坑二:切片共享底层数组
既然切片是指向底层数组的指针,那从一个切片切出来的新切片,和原切片共享同一个底层数组。
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b 指向 a 底层数组的第 1~2 个元素,[2 3]
b[0] = 999 // 改 b,等于改了共享的底层数组
fmt.Println(a) // [1 999 3 4 5],a 也跟着变了!
fmt.Println(b) // [999 3]
}
这个坑非常隐蔽。你以为切出来是独立的新数据,其实共享着内存,改一个另一个跟着变。
想要真正独立的副本,得用 copy 显式拷贝:
b := make([]int, 2)
copy(b, a[1:3]) // 把数据拷到 b 自己的底层数组,从此和 a 无关
坑三:append 的扩容玄学
切片用 append 加元素,当底层数组还装得下(len < cap)时,直接往后放,不换底层数组:
a := make([]int, 2, 4) // len=2, cap=4,还能再塞 2 个
b := append(a, 100) // cap 够,b 和 a 共享底层数组
b[0] = 999
fmt.Println(a[0]) // 999,受影响了
但一旦超过容量(len == cap 还要加),Go 会分配一个更大的新底层数组,把老数据拷过去,之后 append 到的是新数组:
a := make([]int, 2, 2) // cap=2,满了
b := append(a, 100) // 超容量,b 换了新底层数组
b[0] = 999
fmt.Println(a[0]) // 0,不受影响了,因为已经各走各的
同一个 append,扩容前后行为完全不同:扩容前 b 影响 a,扩容后不影响。这种"看容量脸色"的行为是 append 最容易出 bug 的地方。
扩容规律大致是:小切片翻倍扩,大切片(超过一定阈值)按比例(约 1.25 倍)扩,具体版本有差异,知道"会换新数组"这个事实比记死数字重要。
一张对比
| 维度 | 数组 array | 切片 slice |
|---|---|---|
| 长度 | 固定,是类型一部分 | 可变 |
| 本质 | 数据本身 | 指向底层数组的描述符 |
| 传参 | 值拷贝(整份复制) | 拷描述符(共享底层数组) |
| 能否 append | 不能 | 能(可能触发扩容) |
| 日常使用频率 | 很低 | 几乎都用它 |
小结
数组是定长的、本身就是数据,传参会整份拷贝,长度写死在类型里,实际开发用得很少。
切片是"指针 + len + cap"的描述符,指向底层数组,可伸缩,传参轻量还能改到原数据,是日常主力。
它的三个经典坑都源于"共享底层数组":传参能改原值、切片之间共享内存、append 扩容前后行为突变。要独立副本就 copy,别指望切片自动帮你隔离。
记住一句话:切片变量只是个"遥控器",真正的数据在它指向的底层数组里。想清楚谁和谁共用一个底层数组,这些坑就都能提前躲开。
