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,别指望切片自动帮你隔离。

记住一句话:切片变量只是个"遥控器",真正的数据在它指向的底层数组里。想清楚谁和谁共用一个底层数组,这些坑就都能提前躲开。

更多推荐

章节目录