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



