需要预先了解:


1. 数组与切片

数组和切片是 Go 里最常用的两种序列容器,但它们的语义差别很大:数组是值类型,赋值和传参会整份拷贝;切片是一个包含"指针 + 长度 + 容量"的表头,拷贝表头后两个切片仍指向同一块底层数组。理解切片的表头结构,是理解后面所有"共享底层数组"陷阱的前提。


1.1 数组

数组的声明方式是 [长度]元素类型:

var a [3]int              // 零值数组:[0 0 0]
b := [3]int{1, 2, 3}      // 带初值
c := [...]int{1, 2, 3, 4} // 用 ... 让编译器数长度
d := [5]int{0: 1, 4: 5}   // 指定下标初始化:[1 0 0 0 5]

长度是类型的一部分

[3]int 和 [5]int 是两个不同的类型,不能互相赋值:

var x [3]int = [5]int{} // 编译错误
cannot use [5]int{} (value of type [5]int) as [3]int value in variable declaration

这条规则决定了数组的长度必须在编译期确定,也决定了"长度不同的数组"无法放进同一个变量。

数组是值类型

赋值和传参都会把整个数组复制一份:

func modifyArray(a [3]int) {
	a[0] = 999
}

func main() {
	a := [3]int{1, 2, 3}
	b := a
	b[0] = 100
	fmt.Println("a =", a, "b =", b) // a = [1 2 3] b = [100 2 3]

	modifyArray(a)
	fmt.Println("传参后 a =", a) // 传参后 a = [1 2 3]
}

b := a 之后 a 和 b 互不影响;把数组传给函数,函数内改的是副本。数组越大,拷贝成本越高——这正是切片要解决的问题。


1.2 切片是什么

如果每次传参都要复制整个序列,代码就没法用了。切片的设计目标就是:只复制一个很小的表头,底层数据仍然共享。

一个切片在运行时由三部分组成:

s := []int{1, 2, 3, 4, 5}
sub := s[1:3]

   s 的切片头                    底层数组
 +-----------+              +----+----+----+----+----+
 | array  ---|------------> | 1  | 2  | 3  | 4  | 5  |
 +-----------+              +----+----+----+----+----+
 | len = 5   |                 ^
 +-----------+                 |
 | cap = 5   |                 |
 +-----------+                 |
                               |
   sub 的切片头                 |
 +-----------+                 |
 | array  ---|-----------------+
 +-----------+
 | len = 2   |
 +-----------+
 | cap = 4   |
 +-----------+
  • array:指向底层数组某个元素的指针,切片并不拥有数据。
  • len:当前能看到多少个元素。
  • cap:从 array 指向的位置开始,到底层数组末尾还有多少空间。

在 64 位平台上,切片头占 24 字节(8 + 8 + 8):

fmt.Println("切片头大小:", unsafe.Sizeof(s), "字节")
切片头大小: 24 字节

1.3 创建切片

1. 切片表达式:从数组或已有切片上切一段,语法是 a[low:high],取下标 [low, high):

s := []int{1, 2, 3, 4, 5}
sub := s[1:3] // [2 3],len=2,cap=4

low 和 high 都可以省略:s[:2]、s[2:]、s[:]。

2. 字面量:

s := []int{1, 2, 3}

3. make:指定长度和容量(容量可省略,默认等于长度):

m := make([]int, 3, 5) // len=3 cap=5,前 3 个元素是零值

4. 三下标形式 a[low:high:max](Go 1.2+):把结果的容量限制为 max - low,用来防止 append 越界写到原数组的其他位置:

t := s[1:3:4] // len=2 cap=3

有了 cap 限制,对 t 做 append 在超过容量时会重新分配,而不是覆盖 s[3]。


1.4 append 的行为

append 把元素追加到切片末尾,返回新的切片。它的行为取决于剩余容量:

  • 容量足够:直接在底层数组上写入,返回的切片与原切片共享数组,地址不变;
  • 容量不足:分配一块更大的新数组,把原数据复制过去,返回的切片指向新地址。
s := make([]int, 2, 4)
fmt.Printf("初始: len=%d cap=%d 首元素地址=%p\n", len(s), cap(s), &s[0])

s = append(s, 3)
fmt.Printf("append 一个: len=%d cap=%d 首元素地址=%p\n", len(s), cap(s), &s[0])

s = append(s, 4)
fmt.Printf("append 到满: len=%d cap=%d 首元素地址=%p\n", len(s), cap(s), &s[0])

s = append(s, 5)
fmt.Printf("再 append: len=%d cap=%d 首元素地址=%p\n", len(s), cap(s), &s[0])

输出示例(地址每次运行都不同):

初始: len=2 cap=4 首元素地址=0x12f74ab8a260
append 一个: len=3 cap=4 首元素地址=0x12f74ab8a260
append 到满: len=4 cap=4 首元素地址=0x12f74ab8a260
再 append: len=5 cap=8 首元素地址=0x12f74ab88440

前三次 append 地址不变,说明是就地写入;第四次容量不够,地址变了,说明发生了重新分配。重新分配时新容量一般是原容量的两倍左右,具体规则受版本和元素大小影响,细节放在 切片、map 与字符串的底层实现。

注:append 的返回值必须接住。append(s, x) 本身不会改变 s 的长度,只有 s = append(s, x) 才会。


1.5 共享底层数组的陷阱

陷阱一:两个切片互相影响

s := []int{1, 2, 3, 4, 5}
sub := s[1:3]
sub[0] = 20
fmt.Println(s) // [1 20 3 4 5]

sub 和 s 指向同一块底层数组,通过 sub 修改元素,s 也能看到。

陷阱二:append 让两个切片"分裂"

a := []int{1, 2, 3, 4, 5}
b := a[:3]
b = append(b, 100)
fmt.Println("a =", a) // a = [1 2 3 100 5]
fmt.Println("b =", b) // b = [1 2 3 100]

b 的容量是 5,还有空间,所以 append 把 100 写进了 a[3]。此时 a 的第 4 个元素被悄悄改掉了。继续追加直到超出容量,就会重新分配,之后两个切片再无关系:

c := a[:3]
c = append(c, 200, 300, 400) // 超出 cap=5,重新分配
c[0] = 777
fmt.Println("扩容后 c =", c) // [777 2 3 200 300 400]
fmt.Println("a =", a)        // [1 2 3 100 5],a 不受影响

这就是"共享 — 分裂"的完整过程:只要不扩容就共享,一旦扩容就分裂。

陷阱三:小切片让大数组无法回收

big := make([]byte, 1<<20)
small := big[:10]
fmt.Printf("big: len=%d cap=%d\n", len(big), cap(big))     // big: len=1048576 cap=1048576
fmt.Printf("small: len=%d cap=%d\n", len(small), cap(small)) // small: len=10 cap=1048576

small 只用了 10 个字节,但它的 cap 仍然是 1048576,说明底层那 1MB 内存依然被引用着,垃圾回收无法回收它。如果 small 被长期持有(比如放进一个缓存),就会白白占住 1MB。

解决办法是显式复制一份只需要的数据:

small = append([]byte(nil), big[:10]...)

或者用 slices.Clone(big[:10])(Go 1.21+,需要 import "slices"),这样 small 的容量就只剩 10。


1.6 copy

copy(dst, src) 把 src 的元素复制到 dst,返回实际复制的元素个数。数量取两者长度的较小值:

src := []int{1, 2, 3}
dst := make([]int, 2)
n := copy(dst, src)
fmt.Println("copy 返回:", n, "dst =", dst) // copy 返回: 2 dst = [1 2]

copy 是值复制,复制完成后两个切片互不影响。它也可以处理同一底层数组内部的重叠复制(会正确处理方向),例如 copy(s[1:], s[:len(s)-1]) 可以向右搬移元素。


1.7 nil 切片与空切片

nil 切片和长度为 0 的空切片在使用上几乎一样,但有一个区别:只有 nil 切片与 nil 相等。

var nilSlice []int
emptySlice := []int{}

fmt.Println("nilSlice == nil:", nilSlice == nil)     // true
fmt.Println("emptySlice == nil:", emptySlice == nil) // false
fmt.Printf("nilSlice: len=%d cap=%d %v\n", len(nilSlice), cap(nilSlice), nilSlice)       // nilSlice: len=0 cap=0 []
fmt.Printf("emptySlice: len=%d cap=%d %v\n", len(emptySlice), cap(emptySlice), emptySlice) // emptySlice: len=0 cap=0 []

两者都可以 append、range、取 len,不会 panic:

nilSlice = append(nilSlice, 1) // [1]
for range nilSlice { }         // 不执行,也不会 panic

区别主要体现在序列化上——nil 切片会被编码成 null,空切片被编码成 []:

b1, _ := json.Marshal(nilSlice)
b2, _ := json.Marshal(emptySlice)
fmt.Println("nil 切片 JSON:", string(b1)) // null
fmt.Println("空切片 JSON:", string(b2))   // []

需要给前端返回空数组时,记得用 []T{} 或 make([]T, 0) 而不是 var s []T。


1.8 切片传参:表头拷贝

切片传给函数时,拷贝的是那 24 字节的表头,底层数组仍然共享。这导致两个看似矛盾的现象:

func modifyElem(s []int) { s[0] = 100 } // 能改到调用方的元素

func addElem(s []int) {
	s = append(s, 999) // 只改了副本的表头
}
s := []int{1, 2, 3}
modifyElem(s)
fmt.Println(s) // [100 2 3]

addElem(s)
fmt.Println(s, len(s)) // [100 2 3] 3,长度没变
  • 通过下标修改元素,改的是共享的底层数组,调用方能看到;
  • 在函数内 append 只修改了副本的表头,调用方的 len 不会变。

如果确实需要在函数内追加元素并让调用方看到,就要返回新切片让调用方接收,或者传 *[]int(不推荐,会让代码变复杂)。


1.9 练习

  1. 用 make([]int, 0, 10) 创建切片,依次 append 10 个元素,打印每一步的 len 与首元素地址,说明哪一步发生了重新分配。
  2. 写一个函数 RemoveAt(s []int, i int) []int 删除下标 i 的元素,思考它是就地修改了原切片还是产生了新切片,并解释对调用方的影响。
  3. 从一个包含 100 万个元素的切片中取出前 3 个元素,分别用直接切片和 slices.Clone 得到结果,打印两者的 cap,说明差异带来的内存影响。