Skip to content

05|切片

切片是 Go 中最常用的序列类型,可以把它理解为长度可变的动态数组。和数组不同,切片的长度不是类型的一部分,[]string 既可以装 3 个元素,也可以装 300 个。切片底层引用一个数组,通过指针、长度和容量三个字段管理数据。理解切片的底层结构,能避免在追加、扩容和子切片操作里踩到共享底层数组的坑。

一、声明与初始化

声明切片时不指定长度:

go
package main

import "fmt"

func main() {
	var hosts []string          // nil 切片
	hosts := []string{}         // 空切片
	hosts := []string{"web01"}  // 带初始值
}

nil 切片和空切片在行为上大部分一致:len 都是 0,append 都能用。但和 nil 比较时,nil 切片返回 true,空切片返回 false。打印时两者都输出 [],无法从外观区分。

make 创建指定长度和容量的切片:

go
hosts := make([]string, 3)       // 长度 3,容量 3,元素是零值 ["", "", ""]
hosts := make([]string, 3, 10)   // 长度 3,容量 10

len 返回当前元素个数,cap 返回底层数组的总容量。元素个数达到容量后,再 append 会触发扩容。

二、append 与扩容

append 向切片追加元素,返回新切片:

go
hosts := []string{"web01"}
hosts = append(hosts, "web02")
hosts = append(hosts, "db01", "cache01")
fmt.Println(hosts) // [web01 web02 db01 cache01]

append 可能返回一个新的底层数组。当原切片容量足够时,append 在原数组上直接写入;容量不足时,Go 会分配一个更大的新数组,把旧数据复制过去,再追加新元素。此时原切片不受影响,但返回的新切片指向了新数组。

go
a := make([]int, 0, 2)
a = append(a, 1, 2)

b := append(a, 3) // a 的容量已满,b 可能指向新数组
fmt.Println(a)    // [1 2]
fmt.Println(b)    // [1 2 3]

如果容量没满,append 会修改底层数组,所有引用该数组的切片都会看到变化:

go
a := make([]int, 0, 4)
a = append(a, 1, 2)

b := append(a, 3) // 容量未满,b 和 a 共享底层数组
b[0] = 99
fmt.Println(a) // [99 2]
fmt.Println(b) // [99 2 3]

这个行为是切片最常见的坑之一。两个切片共享底层数组时,修改其中一个可能影响另一个,排查时很难定位。

扩容策略:Go 的切片扩容不是简单地加 1。容量小于 1024 时,新容量约翻倍;大于 1024 时,增长约 25%。具体算法在不同 Go 版本有调整,不能依赖精确的数字。

三、子切片

切片可以用 [start:end] 语法取子切片,子切片和原切片共享底层数组:

go
hosts := []string{"web01", "web02", "db01", "cache01"}
sub := hosts[1:3] // [web02 db01]

子切片的起始索引是 start,结束索引是 end-1hosts[:3] 等价于 hosts[0:3]hosts[1:] 等价于 hosts[1:len]

共享底层数组意味着修改子切片会影响原切片:

go hosts
	sub := hosts[0:2]
	sub[0] = "proxy01"
	fmt.Println(hosts) // [proxy01 web02 db01]

如果需要独立副本,用 copy

go hosts
	sub := make([]string, 2)
	copy(sub, hosts[0:2])
	sub[0] = "proxy01"
	fmt.Println(hosts) // [web01 web02 db01],不受影响

copy 的返回值是实际复制的元素个数,取两个切片长度的较小值。源切片和目标切片可以重叠,copy 能正确处理重叠复制。

四、删除元素

Go 没有内置的删除切片元素的方法。删除中间元素需要手动移动后续元素:

go
func removeAt(s []string, index int) []string {
	if index < 0 || index >= len(s) {
		return s
	}
	return append(s[:index], s[index+1:]...)
}

这个操作会修改原切片底层数组。如果需要保留原切片,先 copy 一份再删除。

删除第一个元素:s = s[1:] 删除最后一个元素:s = s[:len(s)-1]

注意这些操作不会释放被删除元素占用的内存,底层数组仍然完整存在。如果切片长期持有大量数据,只有一小部分在用,可以 copy 到新切片释放旧数组。

五、切片的切片

二维切片在运维和后端开发里常用于表格数据:

go
records := [][]string{
	{"host", "status", "cpu"},
	{"web01", "ok", "45"},
	{"web02", "ok", "52"},
}

for _, row := range records {
	for _, col := range row {
		fmt.Print(col, "\t")
	}
	fmt.Println()
}

二维切片的每一行都是独立的切片,可以有不同的长度。但这也意味着遍历和操作时要处理更多的边界情况。

六、常见错误

nil 切片直接索引

go
var hosts []string
fmt.Println(hosts[0]) // panic: index out of range

nil 切片长度是 0,任何索引访问都会 panic。使用前应该先检查长度或确保切片已被初始化。

子切片泄漏

从大切片取一小段子切片后,原大切片的底层数组仍然被引用,垃圾回收器不会释放。如果大切片是读取的日志或监控数据,子切片长期存活会造成内存泄漏:

go
// 不推荐:sub 引用了 huge 的整个底层数组
huge := readHugeLog()
sub := huge[0:10]
store(sub) // sub 长期存活,huge 的内存无法释放

// 推荐:copy 后释放大数组
sub := make([]string, 10)
copy(sub, huge[0:10])
store(sub)

append 后没有重新赋值

go hosts
	append(hosts, "web02") // 错误:返回值被忽略
	fmt.Println(hosts)     // [web01]

append 可能返回新的切片,原变量必须用返回值重新赋值:

go
	hosts = append(hosts, "web02")

遍历时修改切片

range 遍历切片时,第二个变量是元素的副本,修改它不影响原切片(同数组)。需要修改时用索引。

并发写切片

切片本身不是并发安全的。多个 goroutine 同时 append 同一个切片会触发数据竞争,结果不可预期。并发场景下需要加锁保护,或者每个 goroutine 操作自己的切片,最后合并。

七、数组与切片选择

场景推荐原因
长度固定且小数组栈分配,无额外开销,值语义
长度不确定切片动态扩容
传递序列给函数切片避免大数组值复制
需要修改序列切片数组是值类型,修改需指针
矩阵/表格数据切片的切片每行长度可不同