# 切片

Source: https://codewiki.com/zh/go/slices/

> - **what**: 切片（slice）是对底层数组一段连续元素的描述，值中带有起点、长度和容量；`[]T` 的长度不属于类型。
> - **trap**: 复制切片只会复制描述信息，多个切片仍可能共享元素；`append` 有时复用底层数组，有时换成新数组。
> - **fix**: 始终接收 `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`。

以下创建方式都合法，但契约不同：

1. `var values []int` 表示尚无结果的 nil 切片。
2. `values := []int{10, 20}` 创建并填充两个元素。
3. `values := make([]int, 5)` 创建五个可索引的零值元素。
4. `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` 使用完整切片表达式把容量收紧为长度；追加第四个元素时，它必须转到另一份底层存储。

<!-- quick -->

```go
// file: views.go
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)
}
```

```text
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]
```

<!-- /quick -->

完整表达式没有阻止 `window[1]` 修改数组，因为该元素原本就在共享范围内。它只迫使后续增长脱离原数组。若连前三个元素也要隔离，应改用 `slices.Clone(window)`。

### 接收跨函数的 `append` 结果

第一次调用故意丢弃返回值。`states` 有多余容量，因此字符串被写入底层数组的第三个槽位，但调用方的长度仍为二；第二次调用从相同长度追加，覆盖了那个不可见值。

```go
// file: append_result.go
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)
}
```

```text
visible after ignored result: [new paid]
hidden backing slot: packed
after assigned result: [new paid shipped]
```

正确契约是让辅助函数返回新切片，让调用方明确赋回。测试还应覆盖容量恰好用完和仍有余量的两种输入，因为错误在两条路径上的表象不同。

### 克隆后删除元素

这里先用 `slices.Clone` 建立独立队列，再修改并删除其中一段。原始工作流不受影响，`slices.Equal` 根据长度与元素返回 `false`。

```go
// file: clone_delete.go
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))
}
```

```text
original: [draft review publish archive]
queue: [queued archive]
same contents: false
```

克隆是浅拷贝，但本例元素是字符串值，所以修改队列元素不会改写原切片对应槽位。若元素是 `*Record`，两个切片中的指针仍可能指向同一个 `Record`。

### 固定 nil 与空切片的 JSON 契约

程序把两种零长度切片交给 `encoding/json`。两者在 `len` 和 `append` 上表现相同，但默认 JSON 形状不同。

```go
// file: nil_empty.go
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)
}
```

```text
missing: nil=true len=0 JSON=null
empty: nil=false len=0 JSON=[]
after append: [ready]
```

若响应模式要求数组，构造响应时应保证字段为非 nil 切片，并为零结果添加测试。若 `null` 表示「未查询」，则应保留 nil，并在文档中说明这个额外状态。

## 陷阱

### 把切片赋值当成数据复制

> **陷阱:** `backup := records` 只复制切片头；修改 `backup[i]` 通常也会修改 `records[i]`。

**修复：**需要独立的顶层元素槽位时使用 `slices.Clone(records)`，或先创建足够长度的目标再调用 `copy`。若元素内部仍含指针、map 或切片，还要决定是否需要递归复制；「浅拷贝」和「深拷贝」不是一回事。

### 假定 `append` 总会或绝不会分配

> **陷阱:** 有余量时 `append` 可改写共享数组，没有余量时结果会脱离原数组；依赖其中任一路径都会产生容量相关的错误。

**修复：**始终使用返回值，不用观测到的扩容倍率推断所有权。函数不能修改调用方后续元素时，接收只读约定并克隆；只需阻止增长覆盖尾部时，可传入容量受限的切片。

### 混淆 `make` 的长度和容量

> **陷阱:** `make([]Item, n)` 已经含有 `n` 个零值元素；随后再追加 `n` 个结果会得到长度 `2n`，前半段都是零值。

**修复：**逐项追加时使用 `make([]Item, 0, n)`，按索引填充时使用 `make([]Item, n)`。审查生成代码时，要把分配语句与写入方式放在一起读，不能只看它「预分配了空间」。

### 小切片长期保留大数组

> **陷阱:** 从大缓冲区返回很小的子切片，仍可能让整块底层数组保持可达；只把容量裁到长度不会解除这种引用。

**修复：**若小结果会长期存活，使用 `slices.Clone` 或 `append([]T(nil), part...)` 把所需元素复制出来。是否值得复制取决于生命周期和数据量，应通过内存剖析确认真实问题，而不是给每个子切片机械加一次分配。
对于 `[]*T`，手写 `copy` 后直接缩短长度还可能在尾部留下过期指针。使用会清零废弃槽位的当前 `slices.Delete`，或在手写删除时对尾部调用 `clear`；同时接收删除函数的返回值。

### 随意改变 nil 与空切片

> **陷阱:** 内部重构把 nil 结果改成空字面量，看似不影响循环，却可能把 JSON 从 `null` 改成 `[]`，破坏外部模式。

**修复：**在 API 边界测试序列化结果，并用文档说明零结果是否携带状态。内部算法若不关心差别，就用 `len(s) == 0`，不要传播不必要的 nil 判断。

### 并发追加共享切片

> **陷阱:** 多个 goroutine 同时对同一个切片变量或共享底层元素写入，会产生数据竞争；预留足够容量并不会让 `append` 变得安全。

**修复：**让单个 goroutine 拥有切片并通过 channel 收集结果，或使用互斥锁保护切片头和相关元素。也可以让各 goroutine 写入预先分配且互不重叠的索引，但必须证明没有索引重叠，并用竞态检测器运行测试。

<!-- deep -->

## 别名、生命周期与实现边界

### 值语义不等于独立存储

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` 推断已创建新数组并不可靠。

对于命名切片类型，泛型助手通常会保留切片类型参数所表达的类型。代码评审仍应关注元素级别的浅拷贝语义：类型被保留，不代表元素指向的对象也被复制。

判断一个切片操作的所有权效果时，可以按固定顺序追踪：

1. 记录操作前切片的起点、长度、容量，以及已知别名。
2. 判断操作写的是现有元素，还是会改变切片描述。
3. 对 `append` 同时分析复用与重新分配两条路径。
4. 记录函数返回后，哪些切片与底层数组仍然可达。

这套追踪不需要读取运行时私有字段。它直接来自语言保证，也能覆盖容量变化、子切片返回和浅拷贝组合在一起的代码。

若仍无法确定共享关系，就写一个最小测试，在修改前后打印相关切片并检查源数据。行为测试比猜测某次分配更可靠。

<!-- /deep -->

[检查点: go/slices](https://codewiki.com/zh/go/slices/#checkpoint)

## 延伸阅读

- [Go 语言规范：切片类型](https://go.dev/ref/spec#Slice_types)
- [Go 博客：切片的用法与内部机制](https://go.dev/blog/slices-intro)
- [内置函数 `append` 文档](https://pkg.go.dev/builtin#append)
- [标准库 `slices` 包](https://pkg.go.dev/slices)
