Appearance
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,容量 10len 返回当前元素个数,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-1。hosts[: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 rangenil 切片长度是 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 操作自己的切片,最后合并。
七、数组与切片选择
| 场景 | 推荐 | 原因 |
|---|---|---|
| 长度固定且小 | 数组 | 栈分配,无额外开销,值语义 |
| 长度不确定 | 切片 | 动态扩容 |
| 传递序列给函数 | 切片 | 避免大数组值复制 |
| 需要修改序列 | 切片 | 数组是值类型,修改需指针 |
| 矩阵/表格数据 | 切片的切片 | 每行长度可不同 |