Golang Slice

Golang 中数组的长度是数组类型的一部分,这为数组的使用带来了一定的局限性:

  • 不能使用变量来指定数组的大小,因为 Go 中的类型必须在编译时(而不是运行时)就确定。
  • 无法用类型转换将不同大小的数组进行相互转换,因此无法用单个函数处理不同长度的数组,也无法将不同长度的数组赋值给同一变量

由于这些限制,除非事先知道所需的确切长度,否则不要直接使用数组。

Go 中存在数组的主要原因是为 Slice 提供底层存储,而 Slice 是 Go 最有用的特性之一。

在大多数情况下,当需要一个能保存数值序列的数据结构时,应该使用切片(slice)。切片的强大之处在于可以根据需要增加切片的长度。这是因为切片的长度不是类型的一部分。这消除了数组的最大限制,因此可以编写通用的函数处理任意长度的切片。在介绍完切片的基础知识后,接下来将介绍切片的最佳用法。

切片的用法与数组类似,但也存在一些细微的差异。首先要注意的是,声明切片时无需指定其大小:

var x = []int{10, 20, 30} // 声明切片时,无需指定大小

[...] 生成数组(array),用 [] 生成切片(slice)。

如下用切片字面量(Literal)创建一个包含 12 个整数的切片。与数组一样,也可以在切片字面量中仅指定部分值:

// 创建长度为 12 的切片:索引 0=1,5=4,6=6,10=100,11=15,其他位置为 0
var x = []int{1, 5: 4, 6, 10: 100, 15}

上述代码将创建一个包含 12 个 int 的切片,其值为:

[1, 0, 0, 0, 0, 4, 6, 0, 0, 0, 100, 15]

可以创建切片的切片来模拟多维切片。

var x [][]int // x 是一个二维切片,包含两个一维的 []int 类型元素

可以用括号([])语法来读写切片,并且与数组一样,不能读写超出切片末尾的内容,也不能使用负索引:

x[0] = 10       // 用 [] 语法读写切片元素
fmt.Println(x[2])

到目前为止,切片似乎与数组完全相同。当不使用字面量(Literal)声明切片时,就会发现数组和切片之间的差异:

var x []int // x 是一个长度为 0 的切片

上述代码创建了一个 int 类型的切片。由于没有赋值,所以 x 被赋值为该切片的零值(Zero Value)——nil。Go 语言中的 nil 与其他语言中的 null 并不相同。在 Go 语言中,nil 是一个标识符,代表某些类型的值缺失。就像前一章中介绍的非类型常量(Untyped Constants)一样,nil 没有类型,因此可以被赋值或与不同类型的值进行比较nil 切片不包含任何内容。

切片(slice)是一种不具有可比性的类型,因此不能用比较运算符判断切片是否相等。用 == 判断两个切片是否相同,或者用 != 判断两个切片是否不同,都是编译时错误。唯一可以用 == 比较的对象是 nil

fmt.Println(x == nil) // prints true

自 Go 1.21 起,标准库中的 slices 包提供了 2 个用于比较切片的函数。函数 slices.Equal 接收 2 个切片,如果切片长度相同且所有元素相等,则函数返回 true。该函数要求切片的元素具有可比性。而函数 slices.EqualFunc 允许你传递一个函数来确定相等性,并且不要求切片元素具有可比性。关于如何将函数作为参数传递到另一个函数中,详见“5.3.1 将函数作为参数传递<95页>”。关于 slices 包中的其他函数介绍,详见“8.12 将泛型引入到标准库<178页>”。

x := []int{1, 2, 3, 4, 5}
y := []int{1, 2, 3, 4, 5}
z := []int{1, 2, 3, 4, 5, 6}
s := []string{"a", "b", "c"}

fmt.Println(slices.Equal(x, y)) // prints true
fmt.Println(slices.Equal(x, z)) // prints false
fmt.Println(slices.Equal(x, s)) // does not compile

reflect 包中有一个 DeepEqual 函数,它几乎可以比较任何类型,包括切片。这是一个遗留函数,主要用于测试。在添加 slices.Equalslices.EqualFunc 之前,DeepEqual 函数经常被用于比较切片。不要在新代码中使用它,因为它比 slices 包中的函数更慢、更不安全。

3.2.1 切片长度

Go 提供了多个内置函数来处理切片。处理数组的 len 函数,同样也适用于切片。为 len 传递一个 nil 切片会返回 0。

len 函数之所以是内置的,是因为它需要能够以统一的方式处理不同的数据类型(数组、切片、字符串、映射、通道等),而这种能力是无法通过常规函数实现的。如果尝试将 len 函数用于不支持的数据类型,将会在编译时产生错误。在 Go 1.18 引入泛型之前,开发者不能编写一个函数,它可以接受任意字符串、数组、切片、通道或映射,同时拒绝其他类型。

3.2.2 append 函数

内置函数 append 用于向切片追加元素:

var x []int
x = append(x, 10)          // 追加单个元素
x = append(x, 5, 6, 7)     // 追加多个元素
y := []int{20, 30, 40}
x = append(x, y...)        // 用 ... 展开切片,将 y 的元素追加到 x
var x []int
append(x, 10) // 编译错误:append(x, 10) (value of type []int) is not used

上面这种写法会在编译期报错,因为 Go 规定 append 的返回值必须被使用

但如果写成:

var x = []int{1, 2, 3}
y := append(x, 4) // 编译通过,但 x 本身未被更新

则不会报编译错误,程序可以正常运行,但 x 的值仍是 [1, 2, 3],追加的元素只存在于 y 中。

之所以会出现这种"未生效"的情况,是因为切片本身是一个包含指向底层数组的指针、长度、容量的结构体,按值传递意味着 append 操作的是这个结构体的副本。当 append 因扩容而分配了新的底层数组时,只有接收了返回值的变量才能拿到新的切片头信息;如果没有把返回值赋回原变量,原变量仍指向旧的底层数组和旧的长度,从而看不到追加的效果

3.2.3 切片容量

如您所见,切片(slice)是一个值序列。切片中的每个元素都被分配到连续的内存位置,这样就能快速读取或写入这些值。切片的长度是已分配值的连续内存位置的数量。每个切片还有一个容量,即保留的连续内存位置的数量。切片的容量可以大于其长度。每次对切片执行 append 操作时,都会在片段末尾增加一个(或多个)值。为切片每增加一个值,其长度就会加一。当长度达到容量时,就没有更多空间再放置值了。

如果在长度等于容量时尝试追加额外的值,函数 append 会使用 Go 运行时为切片重新分配一个新的、具有更大容量的底层数组。原始底层数组中的值将被复制到新的底层数组中,新值被追加到新底层数组的末尾;然后,更新切片以引用新的底层数组;最后,返回更新后的切片。

Go 运行时

许多高级语言都依赖一套运行时库来支撑程序执行,Go 也不例外。Go 运行时提供内存分配、垃圾回收、并发调度、网络支持以及内置类型(如切片、map)的底层实现等服务。

与 Java 等依赖虚拟机的语言不同,Go 运行时被静态编译进每个二进制文件,无需单独安装即可运行。这种设计简化了程序分发,也避免了版本兼容性问题,代价是即使最简单的 Go 程序,生成的二进制文件也有约 2 MB。

切片的扩容机制

append 导致切片长度超过当前容量时,Go 运行时需要:分配新的底层数组 → 将旧数据复制过去 → 让旧数组等待垃圾回收。这三步都有性能开销,因此 Go 采用倍增式扩容策略,通过一次性分配更大空间来减少扩容次数,从而摊销(amortize)性能成本。

Go 1.18 的具体扩容规则:

  • 当前容量 < 256:容量直接翻倍。
  • 当前容量 ≥ 256:按 (当前容量 + 768) / 4 增加,增长率随容量增大逐渐收敛到 25%。
    • 例如容量为 512 时,增长约 63%;容量为 4096 时,增长约 30%。

容量越大时增长率越低,是为了避免大切片扩容时一次性分配过多闲置内存,在"减少扩容次数"和"避免内存浪费"之间取得平衡。

lencap

cap 返回切片的当前容量(底层数组能容纳的元素总数),与返回当前长度的 len 相对,但使用频率远低于 len。常见用途是判断切片容量是否足够,以决定是否需要用 make 重新分配。

cap 也可作用于数组,但由于数组长度固定,其容量恒等于长度,cap(arr) == len(arr) 永远成立——这一细节实用价值不大,仅作语言知识补充。

示例:了解切片容量

var x []int
fmt.Println(x, len(x), cap(x))
x = append(x, 10)
fmt.Println(x, len(x), cap(x))
x = append(x, 20)
fmt.Println(x, len(x), cap(x))
x = append(x, 30)
fmt.Println(x, len(x), cap(x))
x = append(x, 40)
fmt.Println(x, len(x), cap(x))
x = append(x, 50)
fmt.Println(x, len(x), cap(x))

构建并运行代码后,将看到以下输出。请注意容量何时以及如何增加:

[] 0 0
[10] 1 1
[10 20] 2 2       // 容量翻倍,从 1 到 2
[10 20 30] 3 4    // 容量翻倍,从 2 到 4
[10 20 30 40] 4 4
[10 20 30 40 50] 5 8 // 容量翻倍,从 4 到 8

切片自动增长固然不错,但一次性确定切片大小效率更高。如果事先知道计划将多少内容放入切片中,则应该使用正确的初始容量来创建切片。可以使用 make 函数来执行此操作。

std::vector 使用时,如果预先知道容器大小/量级,通常会用 reserve() 来设置容量。

3.2.4 make 函数

已经了解了两种声明切片的方法:使用切片字面量(Literal)或 nil 零值(Zero Value)。虽然这两种方法都很有用,但都无法创建一个已经指定了长度或容量的空切片。这就是内置 make 函数的作用,它允许指定类型、长度和容量(可选):

x := make([]int, 5) // 创建一个切片 x,并指定长度为 5、容量默认为 5

类型和长度必须指定,容量可选,前两者不提供会编译错误

示例代码将创建一个长度为 5、容量为 5 的 int 切片。由于长度为 5,因此 x[0]x[4] 都是有效元素,并且它们都被初始化为 0。

初学者的一个常见误区是尝试使用 append 函数填充这些初始元素:

x := make([]int, 5) // 创建切片 x,并指定长度为 5,容量默认为 5
x = append(x, 10)   // 追加第 6 个元素,容量立即翻倍为 10

因为 append 函数总是会增加切片的长度,所以 10 被放置在切片的末尾,即位于索引 0~4 的零值之后。现在,x 的值是:

[0 0 0 0 0 10]

长度为 6,容量为 10(在追加第 6 个元素时容量立即翻倍)。

还可以用 make 指定切片的初始容量:

x := make([]int, 5, 10) // 创建一个切片,并指定长度为 5、容量为 10

这将创建一个长度为 5、容量为 10 的 int 切片。

还可以创建长度为 0,但容量大于 0 的切片:

x := make([]int, 0, 10) // 创建一个切片,并指定长度为 0、容量为 10

上述代码将创建一个非空(non-nil)的切片,其长度为 0,但容量为 10。由于长度为 0(即未存储任何元素),因此尝试通过索引(如 s[0])读写切片中的值会触发越界错误。但可以用 append 函数将值追加到切片中:

x := make([]int, 0, 10) // 创建一个长度为 0 的切片,容量为 10
x = append(x, 5, 6, 7, 8) // 向切片 x 追加 4 个元素

切片 x 的值现在是 [5 6 7 8],长度为 4,容量为 10。

约束条件:切片的容量必须始终满足 capacity >= length。违反此规则时:

  • 若长度和容量均为常量,编译器会在编译期报错;
  • 若长度或容量为变量,则会在运行时触发 panic

3.2.5 清空切片

Go 1.21 新增了一个 clear 函数,该函数接收一个切片并将该切片的所有元素都设置为零值(Zero Value)。切片的长度保持不变。以下代码:

s := []string{"first", "second", "third"} // 创建一个包含三个字符串的切片
fmt.Println(s, len(s))

clear(s) // 清空切片 s 的所有元素,即将所有元素都设置为字符串的零值(即空字符串 "")
fmt.Println(s, len(s))

运行上述代码,将得到如下输出:

[first second third] 3
[  ] 3

切记,字符串的零值是空字符串(""),而非 nil。因此,切片 s 的长度保持不变,但所有元素都被设置为空字符串。

std::vector::clear():用于销毁并擦除容器中的所有元素

  • 大小归零:调用后 size() 返回 0,empty() 返回 true
  • 销毁对象:依次调用容器内所有元素的析构函数。
  • 内存保留不会释放底层分配的内存,capacity() 容量保持不变。
  • 迭代器失效:所有指向容器中元素的指针、引用和迭代器(以及尾后迭代器)都会失效

3.2.6 声明切片

了解了多种创建切片的方法后,如何选择合适的声明方式?核心目标是尽量减少切片扩容的次数

场景一:切片可能始终不需要增长

若不确定是否会向切片追加元素,应使用 var 声明一个 nil 切片,不需要预先分配任何内存。

声明一个可能始终为 nil 的切片

var data []int // 声明一个可能始终为 nil 的切片

也可以用空切片字面量创建切片:

var x = []int{} // 长度和容量均为 0

需要注意,[]int{}nil 切片并不等价:将长度为 0 的切片与 nil 比较会得到 false,而 nil 切片与 nil 比较则为 true。为避免混淆,通常应优先使用 nil 切片;空切片字面量仅在转换为 JSON 时有实际用途。

场景二:已知初始值且不会改变

若已有明确的初始数据,且这些值后续不会变化,使用切片字面量是更直接的方式。

data := []int{2, 4, 6, 8}

场景三:已知所需空间,但值尚未确定

若能预估切片所需容量,但具体元素值需在运行时确定,应使用 make。这里的关键问题是:该指定非 0 长度,还是长度为 0、容量为非 0? 通常有三种做法:

  1. 将切片用作缓冲区(详见"13.1 io 包及相关工具〈279页〉")时,应指定非 0 长度,以便直接按索引读写。

  2. 明确知道目标大小,且需要通过索引赋值优化性能时,可预先指定长度,再逐一填充每个索引位置。例如将一个切片转换后存入另一个切片:

    dst := make([]T, len(src)) // 预先指定长度
    for i, v := range src {
        dst[i] = convert(v)
    }
    

    这种方式的风险在于:一旦长度计算有误,会导致数据错误(末尾遗留零值)或索引越界 panic

  3. 其他更通用的场景,应使用 make 创建长度为 0、容量为预估值的切片,再通过 append 填充:

    result := make([]T, 0, estimatedSize)
    for _, v := range src {
        result = append(result, convert(v))
    }
    

    这种方式即使实际元素数少于预估容量,也不会有多余的零值;即使超出预估容量,append 也会自动扩容,不会引发 panic

Go 社区对第二种和第三种方式各有偏好。第三种方式更稳妥、更不容易引入 bug,虽然在个别场景下性能略逊于第二种,但通常是更推荐的默认选择。

注意append 永远是向切片末尾追加元素、增加长度,而不是填充已有的零值位置。如果使用 make 指定了非 0 长度,之后再调用 append,务必确认这是否是你真正需要的行为——否则很容易在切片开头留下一堆意外的零值。

场景一和场景二都比较少,大部分情况是场景三:

  • 明确知道大小:make([]T, LEN)
  • 不知道大小,能预估容量:make([]T, 0, CAP)
  • 大小和容量都不确定:make([]T, 0)

3.2.7 切片操作

切片表达式允许基于已有切片创建新切片,其语法是在方括号([])中指定起始偏移量和结束偏移量,二者用冒号(:)分隔。

s[low:high]
  • low:起始索引,闭区间(包含该位置)
  • high:结束索引,开区间(不包含该位置)

若省略起始偏移量,low 默认为 0;

若省略结束偏移量,high 默认为 len(s)

lowhigh 都必须满足:0 <= low <= high <= cap(s)(注意是 cap,不是 len)

这意味着可以让 high 超过原切片的 len,只要不超过 cap,从而"扩展"出原本不可见但已分配的容量空间,原切片预留但尚未使用的容量空间中的数据,现在变得可见、可索引、可修改了。

x := make([]int, 2, 5) // x = [0 0],len=2, cap=5
x[0], x[1] = 1, 2

y := x[0:4] // high=4 > len(x)=2,但 4 <= cap(x)=5,合法
fmt.Println(y) // [1 2 0 0],len(y)=4, cap(y)=5

示例 3.4:切片操作

x := []string{"a", "b", "c", "d"}
y := x[:2]   // [a b]
z := x[1:]   // [b c d]
d := x[1:3]  // [b c]
e := x[:]    // [a b c d]

fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)
fmt.Println("d:", d)
fmt.Println("e:", e)

运行上述代码,将得到如下输出:

x: [a b c d]
y: [a b]
z: [b c d]
d: [b c]
e: [a b c d]

当对切片执行切片操作时,并不会创建数据副本,而是生成两个共享同一块内存空间的变量。这意味着对任一切片中元素的修改,都会影响到所有共享该内存区域的切片。

示例 3.5:共享存储的切片

x := []string{"a", "b", "c", "d"}
y := x[:2]
z := x[1:]

x[1] = "y" // 修改 x[1] 会影响到 y 和 z,因为它们共享相同的底层数组
y[0] = "x" // 修改 y[0] 会影响到 x,因为它们共享相同的底层数组
z[1] = "z" // 修改 z[1] 会影响到 x,因为它们共享相同的底层数组

fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

允许上述代码,将得到如下输出内容:

x: [x y z d]
y: [x y]
z: [y z d]

修改 x 会导致 yz 同时被修改,而对 yz 的修改同样会影响到 x

当切片操作与 append 函数结合使用时,会引发更复杂的内存共享问题。

示例 3.6:append 使重叠切片更加混乱

x := []string{"a", "b", "c", "d"}
y := x[:2]

fmt.Println(cap(x), cap(y)) // 4 4

y = append(y, "z")

fmt.Println("x:", x) // [a b z d],x 的 'c' 被覆盖为 'z'
fmt.Println("y:", y) // [a b z]

当通过切片操作(如 x[:2])创建子切片时,子切片的容量并非等于其长度,而是:

子切片容量 = 原切片容量 - 子切片起始偏移量

这意味着:子切片会复用原切片从子切片起点一直到底层数组可用末尾的所有容量空间

在本例中:

  • x 的容量为 4;
  • y := x[:2] 的起始偏移量为 0,因此 y 的容量 = 4 - 0 = 4,与 x 的容量相同,尽管 y 的长度只有 2。

由于 y 的容量(4)大于其长度(2),对 y 执行 append 操作时不会触发扩容,新元素 "z" 会被直接写入底层数组中索引为 2 的位置——而这个位置正是 x"c" 所在的位置。因此 append 操作同时修改了 xy 共享的底层数组,导致 x 中的 "c" 被覆盖为 "z",而 x 的第 4 个元素 "d" 因未被 y 覆盖而保留不变。

示例 3.7:更令人困惑的切片

x := make([]string, 0, 5)
x = append(x, "a", "b", "c", "d")
y := x[:2]
z := x[2:]

fmt.Println(cap(x), cap(y), cap(z))

y = append(y, "i", "j", "k")
x = append(x, "x")
z = append(z, "y")

fmt.Println("x:", x)
fmt.Println("y:", y)
fmt.Println("z:", z)

运行结果:

5 5 3
x: [a b i j y]
y: [a b i j y]
z: [i j y]

初始状态make([]string, 0, 5) 创建长度为 0、容量为 5 的切片,追加 4 个元素后:

  • x = [a b c d],长度 4,容量 5,底层数组:[a b c d _](索引 0~4)

创建子切片

  • y := x[:2]:起始偏移量为 0,容量 = 5 - 0 = 5,长度 2,y = [a b]
  • z := x[2:]:起始偏移量为 2,容量 = 5 - 2 = 3,长度 2,z = [c d]

三者共享同一个底层数组,cap(x)=5, cap(y)=5, cap(z)=3

依次执行 append(顺序至关重要):

  1. y = append(y, "i", "j", "k")y 长度从 2 增至 5,仍不超过容量 5,无需扩容。新元素写入底层数组索引 2、3、4。
    → 底层数组变为:[a b i j k]

  2. x = append(x, "x")x 长度从 4 增至 5,未超容量 5,无需扩容。x 按自身长度 4,将新元素写入索引 4,覆盖了上一步写入的 "k"
    → 底层数组变为:[a b i j x]

  3. z = append(z, "y")z 长度从 2 增至 3,未超容量 3,无需扩容。z 的偏移量为 2,新元素应写入 偏移量 + 原长度 = 2 + 2 = 4再次覆盖索引 4,这次是 "x"
    → 底层数组最终为:[a b i j y]

最终状态:三个切片仍共享同一底层数组,只是长度和偏移量不同:

由于三次 append 操作都没有超出各自的容量上限,因此都未触发底层数组的重新分配,全部写入同一块内存,导致后写入的操作不断覆盖先写入的结果——这正是共享底层数组带来的"隐蔽覆盖"问题。

要避免这种混乱,有两种选择:

  1. 永远不要对通过切片表达式得到的子切片使用 append,否则原切片的数据也可能会被覆盖
  2. 使用完整切片表达式(full slice expression),显式限制子切片的容量,使其等于长度,从而保证 append 必然触发扩容、分配新的底层数组,不再与原切片共享内存。

完整切片表达式的语法为 x[low:high:max],三个参数的含义:

  • low:起始索引(闭区间,包含在内)
  • high:结束索引(开区间,不包含在内),决定子切片的长度 = high - low
  • max:容量边界(开区间,不包含在内),决定子切片的容量 = max - low

示例 3.8 将对示例 3.7 的前 4 行代码进行改写,使用完整切片表达式来限制子切片的容量,从而避免上述问题。

示例 3.8:完整切片表达式可防止 append

x := make([]string, 0, 5)
x = append(x, "a", "b", "c", "d")
y := x[:2:2]    // 完整的切片表达式,指定了子切片容量
z := x[2:4:4]

yz 的容量都是 2。后面的代码将子切片的容量限制为其长度,所以在 yz 上追加元素会创建新的切片,但新切片不会与其他切片发生交互。

代码运行后:

x = [a b c d x]
y = [a b i j k]
z = [c d y]

两个切片会共享同一块内存,对其中一个的修改会同步反映到另一个中。

若切片已被切取(或是通过切取其他切片而生成的),应避免修改它们

建议使用三段式切片表达式(如 x[low:high:max])来限制切片容量(通常设置 high = max),这样在执行 append 操作时,新切片将不再与其他切片共享底层数组的容量空间。

示例 3.9:利用切片操作删除元素

可以通过切片表达式 + append 组合,实现从切片中删除指定位置的元素。

x := []int{1, 2, 3, 4, 5}

// 删除索引为 2 的元素(即值为 3 的元素)
out := append(x[:2], x[3:]...)

fmt.Println(out) // [1 2 4 5]
  • x[:2] 取出索引 0、1 的元素:[1 2]
  • x[3:] 取出索引 3 之后的元素:[4 5]
  • appendx[3:] 的元素追加到 x[:2] 之后,得到 [1 2 4 5],即跳过了索引 2 处的元素 3

由于 append(x[:2], x[3:]...) 中的 x[:2] 与原切片 x 共享同一底层数组append 会直接把后续元素向前移动覆盖,因此原始切片 x 本身也会被修改:

fmt.Println(x) // [1 2 4 5 5]

可以看到,x 的长度依然是 5(切片操作不改变原变量的长度信息),但底层数组内容已被覆盖,末尾出现了重复的 5(原数组最后一个位置未被覆盖,仍保留旧值)。

若不希望修改原始切片,应先拷贝:

func removeAt(s []int, index int) []int {
    result := make([]int, 0, len(s)-1)
    result = append(result, s[:index]...)
    result = append(result, s[index+1:]...)
    return result
}

x := []int{1, 2, 3, 4, 5}
y := removeAt(x, 2)

fmt.Println(x) // [1 2 3 4 5],原切片未受影响
fmt.Println(y) // [1 2 4 5]

Go 1.21 引入了标准库 slices 包,其中提供了 slices.Delete 函数,可以更安全、更符合语言习惯地完成切片删除操作,无需手写 append 技巧。

import "slices"

x := []int{1, 2, 3, 4, 5}
x = slices.Delete(x, 2, 3) // 删除索引 [2,3) 范围的元素,即索引 2

fmt.Println(x) // [1 2 4 5]

函数签名

func Delete[S ~[]E, E any](s S, i, j int) S
  • ij 表示要删除的区间,遵循左闭右开规则(即删除 s[i:j])。
  • 若只删除单个元素,写作 slices.Delete(s, i, i+1)
  • 返回删除后的切片,必须接收返回值(原理与 append 一致)。

slices.Delete 的底层实现本质上与手写的 append(x[:i], x[j:]...) 类似:

// 简化版源码逻辑
func Delete[S ~[]E, E any](s S, i, j int) S {
    _ = s[i:j] // 边界检查
    return append(s[:i], s[j:]...)
}

因此它同样会修改原底层数组,语义上和手写版本一致,只是提供了更清晰、更不易出错的 API(比如自动处理了边界校验、支持批量删除区间等)。

如果需要按条件删除(而非按索引),slices 包还提供了 DeleteFunc

gox := []int{1, 2, 3, 4, 5}
x = slices.DeleteFunc(x, func(v int) bool {
    return v%2 == 0 // 删除所有偶数
})
fmt.Println(x) // [1 3 5]

3.2.8 切片复制

如果需要创建独立于原切片的新切片,可以使用内置函数 copy

x := []int{1, 2, 3, 4} // 源切片
y := make([]int, 4)     // 目标切片
num := copy(y, x)       // y 为目标切片,x 为源切片
fmt.Println(y, num)

将得到以下输出:

[1 2 3 4] 4

copy 函数接收两个参数:第一个参数是目标切片,第二个参数是源切片。该函数将尽可能多的值从源切片复制到目标切片(受较小切片的限制),并返回已复制的元素数量。源切片 x 与目标切片 y容量并不重要,重要的是长度

还可以复制切片的子集。以下代码将 4 元素切片的前 2 个元素复制到 2 元素切片中:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
num := copy(y, x) // 将切片 x 中的前 2 个元素复制到切片 y 中

变量 y 被设置为 [1 2]num 被设置为 2。

也可以从源切片的中间位置复制:

x := []int{1, 2, 3, 4}
y := make([]int, 2)
copy(y, x[2:]) // 将切片 x 中的第 3、4 个元素复制到切片 y 中

通过切片操作,将切片 x 中的第 3、4 个元素复制到切片 y 中。并且此示例中没有使用函数 copy 的返回值赋给任何变量。

当源切片与目标切片有重叠时,copy 函数能够正确处理这种情况。也就是说,即使两个切片指向同一个底层数组,且它们的元素区域有重叠,copy 会确保在复制过程中,源切片的元素在被覆盖之前已经被正确复制。

x := []int{1, 2, 3, 4}
num := copy(x[:3], x[1:])
fmt.Println(x, num)

上述示例代码将切片 x 中的后 3 个值复制到 x 的前 3 个值之上。这将打印:

[2 3 4 4] 3

Go 内置函数 copy 的底层实现类似于 C 语言的 memmove(而不是简单的 memcpy),它通过判断源地址与目标地址的相对位置关系,来决定复制的方向,从而保证正确性。

无论切片是否重叠,copy(dst, src) 都会先确定:目标区域的起始地址是在源区域的前面还是后面

  • dst 的起始地址 < src 的起始地址(目标在前,源在后): 从前往后依次复制。因为目标区域覆盖的是"更早"的内存,而源数据还在后面尚未被触及的位置,正向复制不会覆盖尚未读取的源数据。
  • dst 的起始地址 > src 的起始地址(目标在后,源在前): 从后往前依次复制。因为如果正向复制,前面的元素会先被写入目标位置,而这些位置恰好是源数据后续还需要读取的内容,会导致数据在被读取之前就被覆盖。改为反向复制后,先处理末尾的元素,可以确保每次读取源数据时,该位置尚未被目标写入覆盖。

虽然函数 copy 直接操作的是切片,但可以通过将数组转换为切片(如 array[:]),让数组作为复制操作的源或目标。

x := []int{1, 2, 3, 4} // 声明一个切片
d := [4]int{5, 6, 7, 8} // 声明一个数组
y := make([]int, 2)     // 声明一个切片

copy(y, d[:])   // ❶ 将数组 d 的前 2 个元素复制到切片 y 中
fmt.Println(y)  // 打印切片 y 的内容 [5 6]

copy(d[:], x)   // ❷ 将切片 x 的所有元素复制到数组 d 中
fmt.Println(d)  // 打印数组 d 的内容 [1 2 3 4]

第一次调用 copy(❶)将数组 d 中的前 2 个值复制到切片 y 中;第二次调用 copy(❷)将切片 x 中的所有值复制到数组 d 中。这将产生以下输出:

[5 6]
[1 2 3 4]

3.2.9 转换数组为切片

切片操作不仅限于切片类型本身,还可以应用于数组。用切片表达式可以从数组中截取出一个切片,这是将数组传递给只接收切片的函数的便捷方式。若要将整个数组转为切片,使用 [:] 语法即可

xArray := [4]int{5, 6, 7, 8} // 声明一个数组
xSlice := xArray[:]           // 将数组转换为切片

还可以将数组的子集转换为切片:

x := [4]int{5, 6, 7, 8}
y := x[:2]
z := x[2:]

请注意,从数组中提取切片与从切片中提取切片具有相同的内存共享特性

x := [4]int{5, 6, 7, 8} // 声明一个包含 4 个元素的 int 数组
y := x[:2]               // 从数组 x 中提取前 2 个元素,生成切片 y
z := x[2:]               // 从数组 x 中提取后 2 个元素,生成切片 z

x[0] = 10                // 修改数组 x 的第 0 个元素为 10

fmt.Println("x:", x)     // 打印数组 x 的内容 [10 6 7 8]
fmt.Println("y:", y)     // 打印切片 y 的内容 [10 6]
fmt.Println("z:", z)     // 打印切片 z 的内容 [7 8]

将得到以下输出:

x: [10 6 7 8]
y: [10 6]
z: [7 8]

3.2.10 转换切片为数组

在 Go 语言中,可以通过类型转换(type conversion)将切片(Slice)转换为数组(array)。可以将整个切片转换为相同类型的数组,也可以从切片的子集创建数组。

将切片转换为数组时,切片中的数据将被复制到新内存中。这意味着对切片的更改不会影响数组,反之亦然

以下是将切片转换为数组的示例:

xSlice := []int{1, 2, 3, 4} // 声明一个包含 4 个元素的 int 切片
xArray := [4]int(xSlice)     // 将整个切片转换为数组
smallArray := [2]int(xSlice) // 从切片的子集创建数组

xSlice[0] = 10               // 对切片的修改,不会影响到数组

fmt.Println(xSlice)          // 打印切片 xSlice 的内容 [10 2 3 4]
fmt.Println(xArray)          // 打印数组 xArray 的内容 [1 2 3 4]
fmt.Println(smallArray)      // 打印数组 smallArray 的内容 [1 2]

数组的大小必须在编译时指定。在切片到数组的类型转换中使用 [...] 将会导致编译时错误。

数组的大小可以小于切片的大小,但不能大于切片的大小。不幸的是,编译器无法检测到这一点。如果指定的数组大小大于切片的长度(而不是容量),代码将在运行时引发 panic。以下代码:

xSlice := []int{1, 2, 3, 4}      // 声明一个包含 4 个元素的 int 切片
panicArray := [5]int(xSlice)      // 数组大小大于切片长度,将引发运行时 panic
fmt.Println(panicArray)

上述代码将在运行时引发 panic,并显示消息:

panic: runtime error: cannot convert slice with length 4 to array
or pointer to array with length 5

工程实践:修改切片参数的函数设计

在 Go 中设计"需要修改切片"的函数时,核心问题是:切片是值传递的结构体(指针+长度+容量),但底层数组是共享的。这导致修改行为分为两类,需要不同的设计策略。

一、两种"修改"

1. 只修改元素值,不改变 SliceHeader

如果只是修改切片中已有元素的值(如排序、原地过滤、原地转换),由于底层数组是共享的,调用方会立即看到变化,无需返回值。

// 原地修改,调用方自动感知
func Double(s []int) {
    for i := range s {
        s[i] *= 2
    }
}

s := []int{1, 2, 3}
Double(s)
fmt.Println(s) // [2 4 6],无需接收返回值

2. 改变 SliceHeader

若涉及 append、删除等操作,必须返回新切片,并要求调用方显式接收:

func AppendUnique(s []int, v int) []int {
    for _, x := range s {
        if x == v {
            return s
        }
    }
    return append(s, v)
}

s = AppendUnique(s, 4) // 必须重新赋值

关键原则:函数签名应让"必须重新赋值"这一事实一目了然。Go 标准库的 append 本身就是典范——通过强制编译器检查返回值是否被使用,倒逼调用者正确处理。

二、避免意外的"部分共享"副作用

如果函数内部对参数做了 append,而传参时传入的本身就是切片表达式,这存在”对切片表达式进一步 append“的情况。

函数参数的切片表达式可能与调用方的其他切片共享底层数组,就会引发类似示例 3.6/3.7 中的隐蔽 bug。

防御性设计

  • 若函数需要安全地追加而不影响原切片,应在内部先显式拷贝
func SafeAppend(s []int, vs ...int) []int {
    result := make([]int, len(s), len(s)+len(vs))
    copy(result, s)
    return append(result, vs...)
}

这样牺牲一定性能,换取"绝不修改调用方原始数据"的安全保证,适用于库函数、公共 API 等对副作用敏感的场景。

四、Slice 指针?

极少数场景(如需要在多层调用中原地扩容且不想每层都返回)可以传递 *[]T

func AppendInPlace(s *[]int, v int) {
    *s = append(*s, v)
}

不推荐作为默认方案,原因:

  • 违背 Go 惯用法,*[]T 会让调用方困惑;
  • 可读性差,容易忘记解引用;
  • 仅在极端性能敏感、需要修改多个嵌套结构体字段中的切片时才考虑。

五、项目示例

当函数接收的 slice 参数可能是原数组的子切片(如 x[2:5])时,函数内直接对其执行 append 存在隐患:如果该子切片后面仍有剩余容量,append 会复用这部分容量,覆盖调用方持有的其他数据,造成隐蔽的 aliasing bug。

An aliasing bug happens when two or more distinct programming references or pointers access the same underlying memory location or object.

Go 1.21 引入的 slices.Clip 为这一问题提供了标准解法,替代手写的 s[:len(s):len(s)]

slices.Clip 的作用是让 len(s) == cap(s)。据此,只要函数体内执行了至少一次 append

func Foo[T any](s []T, values ...T) []T {
    return append(slices.Clip(s), values...)
}

append 必然触发扩容,分配新数组。原切片元素仍被共享,但追加的新元素与原 backing array 完全隔离。

不使用 slices 包的等价写法:

func Foo[T any](s []T, values ...T) []T {
    s = s[:len(s):len(s)]
    return append(s, values...)
}

gopls modernize 工具会自动建议将前者替换为 slices.Clip,这也是当前更规范的表达方式。

标准库中的实践

Go 源码中多处采用了这一模式。

go/src/cmd/go/main.go at master · golang/go:

help.Help(
    os.Stdout,
    append(slices.Clip(args[:used]), args[used+1:]...),
)

输入 args[:used] 正是一个切片表达式,其后仍属于 args 的容量,通过 slices.Clip(args[:used]) 防止 append 复用 args[:used] 后面的容量

go/src/cmd/go/internal/modload/buildlist.go at master · golang/go

// BuildList returns the selected versions of all modules present in the graph,
// beginning with the main modules.
//
// The order of the remaining elements in the list is deterministic
// but arbitrary.
//
// The caller must not modify the returned list, but may safely append to it
// and may rely on it not to be modified.
func (mg *ModuleGraph) BuildList() []module.Version {
	mg.buildListOnce.Do(func() {
		mg.buildList = slices.Clip(mg.g.BuildList())
	})
	return mg.buildList
}

注释"caller must not modify the returned list, but may safely append to it" 说明调用方可以安全 append,不会写入内部切片的预留容量。

opentelemetry-go-contrib/bridges/otelslog/handler.go at main · open-telemetry/opentelemetry-go-contrib

// KeyValues returns kvs appended to the [attribute.KeyValue] held by b. The
// returned slice never shares spare capacity with b's backing array, so
// appending to it (including a concurrent KeyValues call on the same shared
// buffer) cannot corrupt b or a slice returned by a previous call.
func (b *kvBuffer) KeyValues(kvs ...attribute.KeyValue) []attribute.KeyValue {
	if b == nil {
		return kvs
	}
	// Clip forces append to allocate a new backing array rather than writing
	// kvs into b.data's spare capacity, which is shared across concurrent
	// Handle calls.
	return append(slices.Clip(b.data), kvs...)
}

明确说明 Clip 强制后续 append 分配新的 backing array,避免并发调用之间相互破坏共享 buffer。

Clip 与 Clone 的边界

Clip 限制的是容量,不解除已有元素的共享。以下代码仍会影响调用者:

func Foo(s []int) []int {
    s = slices.Clip(s)
    s[0] = 100 // 仍然修改调用方底层数组
    return append(s, 99)
}

s[0] = 100 直接写入原数组,只有 append 产生的新增部分才是隔离的。

据此可以区分两种契约:

  • 允许共享已有元素,禁止 append 复用隐藏容量

    func Foo(s []T) []T {
        return append(slices.Clip(s), ...)
    }
    

    对应标准库中 Clip 的典型用法。

  • 函数内部对 slice 的任何操作都不得影响调用者

    func Foo(s []T) []T {
        s = slices.Clone(s)
        return append(s, ...)
    }
    

    需要完全隔离时应使用 Clone

规范

当某个函数内部功能是「参数是切片,调用者可能传入任意子切片,函数内部需要 append,且不允许覆盖该切片之后的隐藏容量」,应采用:

append(slices.Clip(s), ...)

这一模式在 Go 标准库及官方工具链中有明确先例,且语义清晰:共享已有区间,禁止 append 复用该切片之后的 backing-array 容量。

Reference

chenyz1984/LearningGoV2CN: 《Learning Go, 2nd Ed》

评论