切片(slice)是对底层数组一段连续元素的描述,值中带有起点、长度和容量;[]T 的长度不属于类型。
复制切片只会复制描述信息,多个切片仍可能共享元素;append 有时复用底层数组,有时换成新数组。
始终接收 append 的返回值,需要独立数据时使用 slices.Clone,并在 API 边界明确 nil 切片与空切片的含义。
是什么,为什么存在
切片是 Go 用来表示一段连续元素的主要类型。[]T 表示元素类型为 T 的切片,长度在运行时决定;相比之下,数组类型 [N]T 把长度 N 也写进类型。多数接受或返回一组值的 Go API 都使用切片,而不是固定长度的数组。
一个切片值描述某个 底层数组(backing array) 中的一段范围。它不把所有元素直接装进变量本身,也不是会自动同步的容器对象。切片赋给另一个变量或传给函数时,Go 仍然按值复制,但复制后的描述通常指向同一批元素。
这种设计让代码能以较低成本传递数组的窗口,也允许 append 在需要时扩大序列。代价是别名关系不总是写在类型上:两个 []byte 看起来互不相关,却可能引用同一块缓冲区。阅读切片代码时,除了元素类型,还要追踪长度、容量和底层数组的所有权。
切片适合请求结果、批次、缓冲区和按顺序保存的记录。若元素数量在编译时固定,数组可能更能表达约束;若按键查找才是主要操作,则 map 更合适。切片自身不提供集合唯一性、并发保护或不可变保证。
本主题聚焦切片值、切片表达式、append、复制以及 nil 与空切片。排序和搜索由标准库 slices 包提供,但算法复杂度与具体领域容器不在这里展开。
工作原理
切片值描述一段存储
从语义上看,一个 切片头(slice header) 包含指向底层存储的起点、长度和容量。长度是当前可通过索引访问的元素数;容量是从该起点到这段底层存储末端仍可使用的元素数。len(s) 和 cap(s) 分别读取这两个值。
这三个部分是一种可靠的思维模型,不应成为应用代码依赖的内存布局。reflect.SliceHeader 面向底层互操作,直接配合 unsafe 拼装切片容易破坏垃圾回收与指针规则。普通代码应使用切片表达式、make、append、copy 和 slices 包。
切片头按值复制,元素不会因此复制。下表中的两个变量可以有不同的长度和容量,但重叠索引仍指向同一个数组元素。
| 属性 | inventory[1:4] 的含义 | 影响 |
|---|---|---|
| 起点 | 指向原数组索引 1 | 切片索引 0 对应该元素 |
| 长度 | 4 - 1,即 3 | 合法索引为 0 到 2 |
| 容量 | 从索引 1 到数组末尾 | 决定原地 append 的空间 |
索引越界会在运行时触发 panic。容量不能小于长度;make([]T, length, capacity) 若违反这一条件,则无法编译或会在参数为运行时值时 panic。
创建、零值与预分配
切片字面量 []int{2, 4, 6} 会创建一个带三个元素的切片。从数组或另一个切片使用 source[low:high],会创建共享存储的新视图。make([]T, n) 创建长度和容量至少为 n 的切片,其中前 n 个元素已经存在并取 T 的零值。
若要逐个追加预计为 n 个的结果,常见写法是 make([]T, 0, n)。长度为零意味着目前没有元素,容量 n 为后续追加预留空间。若最终长度已经确定,则可用 make([]T, n) 后按索引填充;两种写法表达的初始内容不同。
声明 var values []T 得到 nil 切片(nil slice) 。它的长度和容量都是零,可以安全地遍历、append 和传给 copy。空字面量 []T{} 也没有元素,但它不等于 nil。
以下创建方式都合法,但契约不同:
var values []int表示尚无结果的 nil 切片。values := []int{10, 20}创建并填充两个元素。values := make([]int, 5)创建五个可索引的零值元素。values := make([]int, 0, 5)创建空结果并预留五个元素的容量。
半开区间与容量上界
简单切片表达式 source[low:high] 使用半开区间,包含 low,不包含 high。新切片长度为 high - low。对数组切片时,省略的下界默认为零,省略的上界默认为数组长度;对切片再次切片时,上界在规则允许的情况下可以延伸到容量。
完整切片表达式(full slice expression) 写作 source[low:high:max]。它把新切片的容量限制为 max - low,因此 source[i:j:j] 的长度和容量相等。随后再追加元素时,append 没有可复用的尾部容量,只能为结果选择新的底层数组。
容量上界只隔离未来的增长,不隔离已有元素。对 source[i:j:j] 中任一现有索引赋值,仍会修改共享的底层数组。如果调用方连已有元素都不能改,就需要复制,而不只是限制容量。
切片边界必须满足顺序与范围约束。边界来自外部输入时,应先校验,再执行切片表达式;把 panic 当作普通输入错误处理会让 API 契约变得含糊。
append 返回新的切片值
append(s, values...) 返回一个包含新增元素的切片。若 s 的容量足够,它会复用底层数组;若容量不足,它会分配足够大的新数组并复制现有元素。语言规范不承诺具体扩容倍率,因此代码不能依赖下一次容量是多少。
两种路径都会返回新的长度,所以应写成 s = append(s, value)。忽略返回值时,调用方看到的长度不会改变;若原切片还有容量,底层数组的不可见位置却可能已经被写入。这个组合尤其难查,因为日志只打印原切片时看不到那次写入。
将切片传给函数也不会改变这条规则。函数收到切片头的副本,可以修改共享范围内的元素,但若要让调用方看到新的长度,通常要返回切片并由调用方接收。只有 API 确实需要直接替换调用方的切片变量时,才考虑 *[]T;大多数追加辅助函数返回 []T 更清楚。
把另一个切片的元素展开追加可写作 append(dst, src...)。源和目标重叠也是定义良好的,但复杂的重叠写法很难审查;意图是复制完整数据时,slices.Clone 通常更直接。
复制、清零与 slices 包
赋值 clone := source 只复制切片头。内置函数 copy(dst, src) 复制 min(len(dst), len(src)) 个元素,并返回实际复制数量;源和目标重叠时也能正确工作。目标必须先有足够的长度,仅有容量不够,因为 copy 不会扩展长度。
标准库 slices.Clone(source) 创建浅拷贝:新的切片有独立的底层数组,但若元素本身是指针、map、切片或其他引用值,它们指向的对象仍然共享。slices.Equal 按元素比较可比较类型,并把长度同为零的 nil 与非 nil 切片视为相等。
clear(s) 把切片当前长度范围内的元素设为零值,不改变长度或容量。删除包含指针的元素时,尾部旧引用可能让对象继续存活;当前 slices.Delete 会把不再使用的尾部元素清零。手写删除逻辑则应在缩短长度前后明确清理这些槽位。
slices.Delete、slices.Insert 和排序函数可能复用输入的底层数组。调用它们后要使用返回值,并把原切片视为可能已被修改。需要保留输入快照时,先克隆再操作。
nil 与空切片是 API 选择
nil 切片和非 nil 空切片都满足 len(s) == 0,range 不执行循环体,append 也都可用。切片不能互相使用 == 比较,唯一允许的直接比较是与 nil 比较;内容比较可使用 slices.Equal 或自定义规则。
两者在某些边界上可观察到差别。标准库 encoding/json 默认把 nil 切片编码成 null,把非 nil 空切片编码成 []。反射、数据库驱动和其他序列化层也可能保留这种区别,因此返回哪一种应由 API 契约决定。
若接口只表达「零个元素」,通常让调用方依赖 len(s) == 0 更稳妥。若 JSON 字段必须总是数组,就在边界构造非 nil 空切片或提供自定义编码。不要在业务逻辑各处随意把两者来回转换。
示例
下面四个程序逐步展示共享元素、追加返回值、独立副本和序列化边界。每个程序都可以单独保存,并用 go run 文件名 执行;所示输出来自本地 Go 工具链。
观察共享数组与容量限制
window 与数组共享三个可见元素,所以修改 window[1] 会改到数组。limited 使用完整切片表达式把容量收紧为长度;追加第四个元素时,它必须转到另一份底层存储。
package main
import "fmt"
func main() {
inventory := [6]string{"pen", "notebook", "eraser", "ruler", "tape", "clips"}
window := inventory[1:4]
fmt.Printf("window=%v len=%d cap=%d\n", window, len(window), cap(window))
window[1] = "marker"
fmt.Println("inventory after write:", inventory)
// 收紧容量,阻止 append 覆盖 inventory 的后续元素。
limited := window[:len(window):len(window)]
limited = append(limited, "stapler")
fmt.Println("limited after append:", limited)
fmt.Println("inventory after append:", inventory)
}window=[notebook eraser ruler] len=3 cap=5
inventory after write: [pen notebook marker ruler tape clips]
limited after append: [notebook marker ruler stapler]
inventory after append: [pen notebook marker ruler tape clips]完整表达式没有阻止 window[1] 修改数组,因为该元素原本就在共享范围内。它只迫使后续增长脱离原数组。若连前三个元素也要隔离,应改用 slices.Clone(window)。
接收跨函数的 append 结果
第一次调用故意丢弃返回值。states 有多余容量,因此字符串被写入底层数组的第三个槽位,但调用方的长度仍为二;第二次调用从相同长度追加,覆盖了那个不可见值。
package main
import "fmt"
func appendState(states []string, state string) []string {
return append(states, state)
}
func main() {
states := make([]string, 2, 4)
states[0], states[1] = "new", "paid"
// 返回值被丢弃,调用方的长度仍是 2。
appendState(states, "packed")
fmt.Println("visible after ignored result:", states)
fmt.Println("hidden backing slot:", states[:cap(states)][2])
states = appendState(states, "shipped")
fmt.Println("after assigned result:", states)
}visible after ignored result: [new paid]
hidden backing slot: packed
after assigned result: [new paid shipped]正确契约是让辅助函数返回新切片,让调用方明确赋回。测试还应覆盖容量恰好用完和仍有余量的两种输入,因为错误在两条路径上的表象不同。
克隆后删除元素
这里先用 slices.Clone 建立独立队列,再修改并删除其中一段。原始工作流不受影响,slices.Equal 根据长度与元素返回 false。
package main
import (
"fmt"
"slices"
)
func main() {
original := []string{"draft", "review", "publish", "archive"}
queue := slices.Clone(original)
queue[0] = "queued"
queue = slices.Delete(queue, 1, 3)
fmt.Println("original:", original)
fmt.Println("queue:", queue)
fmt.Println("same contents:", slices.Equal(original, queue))
}original: [draft review publish archive]
queue: [queued archive]
same contents: false克隆是浅拷贝,但本例元素是字符串值,所以修改队列元素不会改写原切片对应槽位。若元素是 *Record,两个切片中的指针仍可能指向同一个 Record。
固定 nil 与空切片的 JSON 契约
程序把两种零长度切片交给 encoding/json。两者在 len 和 append 上表现相同,但默认 JSON 形状不同。
package main
import (
"encoding/json"
"fmt"
)
func encode(values []string) string {
data, err := json.Marshal(values)
if err != nil {
panic(err)
}
return string(data)
}
func main() {
var missing []string
empty := []string{}
fmt.Printf("missing: nil=%t len=%d JSON=%s\n", missing == nil, len(missing), encode(missing))
fmt.Printf("empty: nil=%t len=%d JSON=%s\n", empty == nil, len(empty), encode(empty))
missing = append(missing, "ready")
fmt.Println("after append:", missing)
}missing: nil=true len=0 JSON=null
empty: nil=false len=0 JSON=[]
after append: [ready]若响应模式要求数组,构造响应时应保证字段为非 nil 切片,并为零结果添加测试。若 null 表示「未查询」,则应保留 nil,并在文档中说明这个额外状态。
陷阱
把切片赋值当成数据复制
**修复:**需要独立的顶层元素槽位时使用 slices.Clone(records),或先创建足够长度的目标再调用 copy。若元素内部仍含指针、map 或切片,还要决定是否需要递归复制;「浅拷贝」和「深拷贝」不是一回事。
假定 append 总会或绝不会分配
**修复:**始终使用返回值,不用观测到的扩容倍率推断所有权。函数不能修改调用方后续元素时,接收只读约定并克隆;只需阻止增长覆盖尾部时,可传入容量受限的切片。
混淆 make 的长度和容量
**修复:**逐项追加时使用 make([]Item, 0, n),按索引填充时使用 make([]Item, n)。审查生成代码时,要把分配语句与写入方式放在一起读,不能只看它「预分配了空间」。
小切片长期保留大数组
**修复:**若小结果会长期存活,使用 slices.Clone 或 append([]T(nil), part...) 把所需元素复制出来。是否值得复制取决于生命周期和数据量,应通过内存剖析确认真实问题,而不是给每个子切片机械加一次分配。
对于 []*T,手写 copy 后直接缩短长度还可能在尾部留下过期指针。使用会清零废弃槽位的当前 slices.Delete,或在手写删除时对尾部调用 clear;同时接收删除函数的返回值。
随意改变 nil 与空切片
**修复:**在 API 边界测试序列化结果,并用文档说明零结果是否携带状态。内部算法若不关心差别,就用 len(s) == 0,不要传播不必要的 nil 判断。
并发追加共享切片
**修复:**让单个 goroutine 拥有切片并通过 channel 收集结果,或使用互斥锁保护切片头和相关元素。也可以让各 goroutine 写入预先分配且互不重叠的索引,但必须证明没有索引重叠,并用竞态检测器运行测试。
别名、生命周期与实现边界
值语义不等于独立存储
Go 的参数传递只有值语义。传入 []T 时,函数得到切片描述的副本;通过该副本访问的元素仍可能与调用方共享。因而「切片是引用类型」只能作为简写,不能推导出函数能替换调用方的长度、容量或起点。
元素写入与切片变量写入要分开判断。s[0] = value 沿描述找到共享元素,调用方通常可见;s = append(s, value) 先生成新的切片值,只修改函数的局部变量,除非函数把它返回或通过明确的指针间接写回。
切片中还可能套切片,例如 [][]byte。克隆外层只复制内层切片头,每一行的字节仍共享。需要真正独立的二维数据时,应为每个内层切片分别复制,并测试修改一个副本不会影响另一个。
完整切片表达式是有限隔离
表达式 part := whole[i:j:j] 把 part 的容量设为长度。只要追加至少一个元素,结果就无法占用 whole[j] 的槽位。这适合把可增长的窗口传给辅助函数,防止它意外覆盖窗口之后的数据。
它不是不可变视图。辅助函数仍可以修改 part[0:len(part)],而这些写入会反映到 whole。它也没有释放原数组,因为 part 仍指向同一分配。需要写隔离或生命周期隔离时都应复制。
容量也不是权限系统。收到切片的代码可以在语言允许的边界内再次切片,调用方不应把敏感数据与可变视图放在同一底层数组里,再只靠约定防止访问。真正的边界应交付独立存储或只暴露复制后的数据。
栈、堆与逃逸分析
「切片在堆上」并不准确。切片变量和底层数组放在栈还是堆,由编译器的逃逸分析、大小和优化决定;语言规范不保证具体位置。正确性代码不应依赖这一放置结果。
返回一个切片是安全的,即使它最初来自函数内创建的数组。若该存储需要比函数调用活得更久,编译器与运行时会安排有效生命周期。为了所谓「避免返回栈内存」而手写 unsafe 指针,反而会绕开 Go 的安全保证。
性能判断应看基准测试、分配统计和剖析结果。预分配通常能减少增长次数,但容量过大也会保留更多内存;合适的容量来自实际工作负载,而不是固定倍数规则。
保留底层数组的成本
子切片只占很小的可见范围,却仍让其底层数组保持可达。典型问题是读取大文件或网络帧后,长期缓存其中几十个字节。复制这几十个字节会增加一次小分配,却允许大缓冲区被回收。
对包含指针的切片,还要区分保留数组本身与保留数组引用的对象。缩短长度不会自动表达「尾部对象已经无用」;使用 clear 或当前标准库删除函数,可以移除废弃槽位中的指针。随后仍应确认没有其他切片别名保留这些元素。
slices.Clip 把容量限制为长度,但其定义等价于 s[:len(s):len(s)],不会复制底层数组。因此它能阻止结果原地增长到闲置容量,却不能解决大数组保留。需要解除保留关系时应使用 slices.Clone。
标准库助手仍有所有权语义
泛型 slices 包让比较、克隆、插入、删除与排序更易读,但它没有消除底层数组。Clone 明确创建副本;许多其他函数为了效率会原地修改或复用输入存储,并返回可能更新后的切片。
调用前先决定输入能否被修改,调用后使用返回值。若函数文档说明结果与输入共享存储,就要把该关系写进调用方的所有权推理。只凭函数名 Delete 或 Compact 推断已创建新数组并不可靠。
对于命名切片类型,泛型助手通常会保留切片类型参数所表达的类型。代码评审仍应关注元素级别的浅拷贝语义:类型被保留,不代表元素指向的对象也被复制。
判断一个切片操作的所有权效果时,可以按固定顺序追踪:
- 记录操作前切片的起点、长度、容量,以及已知别名。
- 判断操作写的是现有元素,还是会改变切片描述。
- 对
append同时分析复用与重新分配两条路径。 - 记录函数返回后,哪些切片与底层数组仍然可达。
这套追踪不需要读取运行时私有字段。它直接来自语言保证,也能覆盖容量变化、子切片返回和浅拷贝组合在一起的代码。
若仍无法确定共享关系,就写一个最小测试,在修改前后打印相关切片并检查源数据。行为测试比猜测某次分配更可靠。
5个问题 · 1 道输出预测题 · 2 道找错题