目录
Please enable Javascript to view the contents

Go-Slice-切片操作汇总

 ·  ☕ 5 分钟

重要

Slice 就三件事需要记牢:

  1. 底层结构slice header = {ptr, len, cap},值类型,赋值/传参复制 header 但共享底层数组。
  2. 共享与脱离s[i:j] 共享底层数组;append 触发扩容后脱离原数组。
  3. 扩容两把尺子:需求大小(> 2×cap?)与切片大小(≥ 256?),决定 “精确分配 / 翻倍 / 1.25x 渐进” 三选一。

1. 声明与创建

三种声明方式:

方式代码lencap是否 nil备注
零值var s []int00s == nil 为 true
字面量s := []int{1, 2, 3}33最常用
makes := make([]T, len, cap)lencap预分配容量,减少扩容

1.1 nil vs 空切片

行为一致(lencaprangeappend 都能正常工作),仅 JSON 序列化不同:

1
2
3
4
5
var nilSlice []int
emptySlice := []int{}

json.Marshal(nilSlice)   // "null"
json.Marshal(emptySlice) // "[]"

对外 API 建议返回空切片而非 nil。

2. 核心机制

2.1 slice header 三元组

Slice 值本质是一个三元组:

1
2
3
4
5
type slice struct {
    ptr unsafe.Pointer // 指向底层数组
    len int            // 当前长度
    cap int            // 从 ptr 起可用容量
}

Slice 是值类型:赋值/传参复制的是 header,ptr 仍指向同一个底层数组。

1
2
3
4
a := []int{1, 2, 3}
b := a          // 复制 header,共享底层数组
b[0] = 99
fmt.Println(a)  // [99 2 3]

2.2 子切片共享底层

s[i:j] 与原切片共享底层数组,改元素互相可见:

1
2
3
4
a := []int{1, 2, 3, 4, 5}
b := a[1:3]     // [2 3],cap=4
b[0] = 99
fmt.Println(a)  // [1 99 3 4 5]

避免共享:完整切片表达式 s[low:high:max] 限制 cap,后续 append 必扩容并分配新数组:

1
2
b := a[1:3:3]     // len=2, cap=2
b = append(b, 10) // 扩容,脱离 a 的底层数组

2.3 append 与扩容脱离

append 在容量足够时复用底层数组,容量不足时分配新数组并拷贝

1
2
3
4
5
6
7
a := make([]int, 0, 2)
a = append(a, 1, 2)
b := a               // 共享底层数组
a = append(a, 3)     // a 扩容,指向新数组
b[0] = 99            // b 仍指向旧数组
fmt.Println(a)       // [1 2 3]
fmt.Println(b)       // [99 2]

必须接收返回值

1
2
append(s, 1)      // 错:返回值被丢弃
s = append(s, 1)  // 对

2.4 copy 不扩容

copy(dst, src) 复制 min(len(dst), len(src)) 个元素,不会扩展 dst

1
2
3
dst := make([]int, 2)
n := copy(dst, []int{1, 2, 3, 4})
// dst = [1 2], n = 2

3. 扩容机制

3.1 触发时机

append 时,如果 预期的新长度(len + 追加元素数) > cap,则触发扩容。

1
2
3
s := make([]int, 0, 2)
s = append(s, 1, 2)    // len=2, cap=2,未扩容
s = append(s, 3)        // 预期 len=3 > cap=2 → 扩容

3.2 两把尺子

Go 1.18+ 扩容策略由两个维度决定:

尺子判断含义
需求大小所需容量 > 2 × 原容量?一次追加是 “大幅跳跃” 还是 “小幅增长”
切片大小原容量 ≥ 256?当前是 “小切片” 还是 “大切片”

三种情况:

情况条件策略
需求大需求 > 2×cap精确分配到需求容量
需求小 + 小切片需求 ≤ 2×cap 且 cap < 256翻倍(2x)
需求小 + 大切片需求 ≤ 2×cap 且 cap ≥ 2561.25x 渐进

一句话:需要大直接给;需要小 → 小切片翻倍,大切片 1.25x 渐进。批量 append 只算一次扩容。

3.3 大切片的平滑公式

大切片(cap ≥ 256)采用循环公式,增长系数从 2x 平滑降到 1.25x:

1
2
3
4
newcap = oldcap
for newcap < expectedCap {
    newcap += (newcap + 3*256) / 4
}

即每轮 newcap = (5 × oldcap + 3 × 256) / 4,循环直到满足需求容量。增长系数随 oldcap 变化:

切片扩容增长系数

为什么要循环

一次迭代的增长系数在 1.25~2 之间。当 oldcap 较小时,单次迭代未必够。示例:

1
2
3
4
oldcap=256, expectedCap=800

第 1 轮:newcap = (5×256 + 3×256)/4 = 512   (2x)
第 2 轮:newcap = (5×512 + 3×256)/4 = 832   (≈1.625x)≥ 800 ✓

每轮增长系数变化(2.0 → 1.625 → … → 1.25),一轮不够就再来一轮。

3.4 内存对齐

上述公式算出的 newcap 只是目标容量。Go 运行时分配前会做内存对齐:

步骤操作
1计算目标容量 newcap
2结合元素类型大小,算出所需字节数
3向上取整到 mspan 规格(8、16、48 字节等)
4最终 cap = 对齐后字节数 ÷ 元素大小

最终 cap 通常略大于计算值,最多到下一个 mspan 规格。

3.5 Go 1.18 前后差异

版本小切片大切片
1.17 及以前cap < 1024 翻倍cap ≥ 1024 一律 1.25x
1.18+cap < 256 翻倍cap ≥ 256 平滑过渡 2x → 1.25x

改动动机:1024 之前一律翻倍,大容量切片浪费严重;新算法在 256 之后就启动平滑过渡,更省内存。

3.6 预分配避免扩容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 差:多次扩容 + 拷贝
var s []int
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

// 好:一次分配到位
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
    s = append(s, i)
}

4. 删除

Fast版:改变顺序(O(1))

将最后一个元素移到被删位置,不保持原顺序:

1
2
3
4
5
6
a := []string{"A", "B", "C", "D", "E"}
i := 2 // 删除 "C"

a[i] = a[len(a)-1] // 末位移到 i
a = a[:len(a)-1]   // 截断
// [A B E D]

Slow版:保持顺序(O(n))

被删元素之后的元素全部前移一位:

1
2
3
4
5
6
a := []string{"A", "B", "C", "D", "E"}
i := 2 // 删除 "C"

copy(a[i:], a[i+1:]) // C 被 D 覆盖,D 被 E 覆盖
a = a[:len(a)-1]     // 截断
// [A B D E]

泛型版本(Go 1.18+)

1
2
3
func Delete[T any](s []T, i int) []T {
    return append(s[:i], s[i+1:]...)
}

5. 插入

1
2
3
4
5
6
7
// 在位置 i 插入元素 x
s = append(s[:i], append([]int{x}, s[i:]...)...)

// Go 1.17+ 可以用 copy 避免临时切片
s = append(s, 0)            // 扩展一个位置
copy(s[i+1:], s[i:])        // i 之后的元素后移
s[i] = x
1
2
3
4
5
6
func Insert[T any](s []T, i int, x T) []T {
    s = append(s, *new(T)) // Go 1.21: clear 更好
    copy(s[i+1:], s[i:])
    s[i] = x
    return s
}

6. 过滤

1
2
3
4
5
6
7
8
9
// 就地过滤,保留满足条件的元素
n := 0
for _, v := range s {
    if predicate(v) {
        s[n] = v
        n++
    }
}
s = s[:n]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 过滤偶数
nums := []int{1, 2, 3, 4, 5, 6}
n := 0
for _, v := range nums {
    if v%2 == 0 {
        nums[n] = v
        n++
    }
}
nums = nums[:n] // [2 4 6]

7. 深拷贝

1
2
3
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src) // 深拷贝

8. 清空(Go 1.21+)

1
clear(s) // 所有元素置零值,len 不变

旧版本等价写法:

1
2
3
for i := range s { s[i] = T{} }
// 或
s = s[:0] // 重置 len 为 0,保留底层数组和 cap

9. 常见陷阱

9.1 range 中的值拷贝

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
s := []User{{"A"}, {"B"}}
for _, u := range s {
    u.Name = "X" // 修改的是副本,不影响 s
}
fmt.Println(s) // [{A} {B}]

// 正确做法
for i := range s {
    s[i].Name = "X"
}

9.2 子切片导致的内存泄漏

1
2
3
4
5
6
7
8
// 大切片的一个小窗口,但底层大数组无法 GC
big := readMillionLines()
first10 := big[:10]
// 即使只用 10 行,百万行的底层数组依然存在

// 修复:拷贝需要的部分
first10 := make([]Line, 10)
copy(first10, big[:10])

9.3 共享数组导致的意外修改

传入函数的 slice 若容量足够,函数内的 append 会写入原底层数组:

1
2
3
4
5
6
7
func appendOne(s []int) {
    s = append(s, 999) // 若原 cap 够用,直接写原底层数组
}

a := make([]int, 2, 10)
appendOne(a)
fmt.Println(a[:3]) // [0 0 999]

10. 总结

操作代码注意
声明var s []T / []T{...} / make([]T, len, cap)三选一
追加s = append(s, x)必须接收返回值
删除(保序)copy(s[i:], s[i+1:]); s = s[:len(s)-1]O(n)
删除(不保序)s[i] = s[len(s)-1]; s = s[:len(s)-1]O(1)
子切片s[low:high:max]三参数限制 cap 避免共享
深拷贝dst := make([]T, len(src)); copy(dst, src)
过滤双指针法O(n),就地
清空clear(s) / s = s[:0]Go 1.21+ / 旧版

参考链接

分享

Hex
作者
Hex
CloudNative Developer