导航菜单

切片(Slice)

切片是 Go 语言中使用频率最高的数据结构。它建立在数组之上,提供了动态长度、自动扩容的能力,是处理序列数据的首选方式。

切片的本质

切片(Slice)

切片是对数组一个连续片段的引用。它本身并不存储数据,而是描述了底层数组的一段区间。切片由三个部分组成:指向底层数组的指针长度(len)容量(cap)

// 底层数组
array := [5]int{10, 20, 30, 40, 50}

// 切片引用底层数组的一部分
slice := array[1:4] // 索引 1(包含)到 4(不包含)

fmt.Println(slice)      // [20 30 40]
fmt.Println(len(slice))  // 3
fmt.Println(cap(slice))  // 4 —— 从 slice 的起始位置到底层数组末尾的长度

内存布局

一个切片在内存中由三个字段组成,共 24 字节(64 位系统):

┌──────────────┬──────────┬──────────┐
│    指针 ptr   │  长度 len │  容量 cap │
│   8 字节      │  8 字节   │  8 字节   │
└──────┬───────┴──────────┴──────────┘


┌───┬───┬───┬───┬───┐
│ 10│ 20│ 30│ 40│ 50│  ← 底层数组
└───┴───┴───┴───┴───┘
         ↑       ↑
       ptr      ptr+cap
       │← len →│←── cap ──→│
  • ptr:指向底层数组中切片起始元素的指针
  • len:切片当前包含的元素数量(len(slice)
  • cap:从切片起始位置到底层数组末尾的元素数量(cap(slice)
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4]

fmt.Printf("ptr: %p\n", &s[0])     // 指向 arr[1]
fmt.Printf("len: %d\n", len(s))     // 3
fmt.Printf("cap: %d\n", cap(s))     // 4(arr[1] 到 arr[4],共 4 个元素)

创建切片

方式一:使用 make 函数

make 是创建切片最常用的方式,它会在内存中分配一个底层数组并返回切片引用。

// make([]T, length, capacity)
s1 := make([]int, 5)        // 长度 5,容量 5
s2 := make([]int, 3, 10)    // 长度 3,容量 10
s3 := make([]string, 0, 5)  // 长度 0,容量 5(常用于 append 场景)

fmt.Println(s1)      // [0 0 0 0 0]
fmt.Println(len(s1)) // 5
fmt.Println(cap(s1)) // 5

fmt.Println(s2)      // [0 0 0]
fmt.Println(len(s2)) // 3
fmt.Println(cap(s2)) // 10

fmt.Println(s3)      // []
fmt.Println(len(s3)) // 0
fmt.Println(cap(s3)) // 5

方式二:使用字面量

// 完整声明
var s1 []int = []int{1, 2, 3, 4, 5}

// 短变量声明
s2 := []string{"Go", "is", "awesome"}
s3 := []float64{3.14, 2.72, 1.41}

// 空切片
s4 := []int{}       // len: 0, cap: 0
var s5 []int        // nil 切片,len: 0, cap: 0

fmt.Println(s1) // [1 2 3 4 5]
fmt.Println(s2) // [Go is awesome]
nil 切片 vs 空切片
  • nil 切片var s []int,声明但未初始化,指针为 nil,len 和 cap 均为 0。可以 append,但 JSON 序列化时输出 null
  • 空切片s := []int{}make([]int, 0),指针非 nil,len 和 cap 为 0。JSON 序列化时输出 []

方式三:从数组切分

arr := [8]int{0, 1, 2, 3, 4, 5, 6, 7}

// 基本语法:arr[low:high],low 包含,high 不包含
s1 := arr[2:5]   // [2 3 4],len=3, cap=6(从索引 2 到数组末尾)
s2 := arr[:4]    // [0 1 2 3],从开头到索引 4(不包含)
s3 := arr[4:]    // [4 5 6 7],从索引 4 到末尾
s4 := arr[:]     // [0 1 2 3 4 5 6 7],引用整个数组

fmt.Println(s1) // [2 3 4]
fmt.Println(s2) // [0 1 2 3]
fmt.Println(s3) // [4 5 6 7]
fmt.Println(s4) // [0 1 2 3 4 5 6 7]

// 三索引切分:arr[low:high:max],max 控制容量
s5 := arr[2:5:5] // [2 3 4],len=3, cap=3
// 容量 = max - low = 5 - 2 = 3

len 与 cap

理解 lencap 的区别是掌握切片的关键。

arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s := arr[3:7]

fmt.Println("切片:", s)       // [3 4 5 6]
fmt.Println("len:", len(s))   // 4  —— 切片当前包含的元素个数
fmt.Println("cap:", cap(s))   // 7  —— 从切片起始到底层数组末尾

// len 决定了可以访问的范围:s[0] 到 s[len(s)-1]
// cap 决定了可以 append 的空间:当前底层数组中还能追加多少元素
  • len:切片当前有多少个元素,即 s[i]i 的有效范围是 [0, len(s))。访问超出 len 的索引会导致 panic
  • cap:从切片起始位置到底层数组末尾的元素总数。当 len == cap 时再 append,会触发扩容。
s := make([]int, 3, 5) // len=3, cap=5
fmt.Println(s)         // [0 0 0]

// 在 cap 范围内 append,不触发扩容
s = append(s, 100)     // len=4, cap=5,底层数组不变
s = append(s, 200)     // len=5, cap=5,底层数组已满

// 超出 cap 范围 append,触发扩容
s = append(s, 300)     // len=6, cap=?(新分配更大的底层数组)

append 与扩容机制

基本用法

append 用于向切片追加元素,返回新的切片。它可能原地扩展,也可能分配新的底层数组。

s := []int{1, 2, 3}

// 追加单个元素
s = append(s, 4)        // [1 2 3 4]

// 追加多个元素
s = append(s, 5, 6, 7)  // [1 2 3 4 5 6 7]

// 追加另一个切片(使用 ... 展开)
t := []int{100, 200}
s = append(s, t...)     // [1 2 3 4 5 6 7 100 200]

Go 1.18+ 扩容策略

当切片容量不足时,Go 会分配一个更大的底层数组并将元素复制过去。Go 1.18 之后的扩容策略如下:

// 简化版的 Go 1.18+ 扩容策略(伪代码)
func growslice(oldCap, newLen int) int {
    newCap := oldCap
    doubleCap := newCap + newCap

    if newLen > doubleCap {
        newCap = newLen
    } else {
        if oldCap < 256 {
            newCap = doubleCap
        } else {
            // 每次增加约 25%(持续到 newCap >= newLen)
            for newCap < newLen {
                newCap += (newCap + 3*256) / 4
            }
        }
    }
    return newCap  // 实际还涉及内存对齐
}
扩容策略要点(Go 1.18+)
  1. 当期望容量 newLen 大于当前容量的 2 倍时,直接使用 newLen 作为新容量
  2. 当当前容量小于 256 时,新容量翻倍
  3. 当当前容量大于等于 256 时,每次大约增加 25%,直到满足所需容量
  4. 最终容量会进行内存对齐,实际分配可能略大于计算值
s := make([]int, 0)

fmt.Printf("len=%d, cap=%d\n", len(s), cap(s)) // len=0, cap=0

for i := 1; i <= 20; i++ {
    s = append(s, i)
    if i <= 1 || i == 256 || cap(s) != cap([]int{}) {
        // 打印容量变化
    }
}

// 典型的扩容序列(实际值可能因系统而异):
// cap: 0 → 1 → 2 → 4 → 8 → 16 → 32 → ...
// 小于 256 时每次翻倍
// 大于等于 256 时每次增长约 25%

批量预分配

在实际开发中,如果已知元素数量,应预分配足够容量以避免多次扩容:

// ❌ 不推荐:多次扩容
var s []int
for i := 0; i < 10000; i++ {
    s = append(s, i) // 可能触发十几次扩容和内存拷贝
}

// ✅ 推荐:预分配容量
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    s = append(s, i) // 零扩容
}

切片截取操作

切片支持灵活的截取(Reslicing)操作,可以在已有切片上创建新的切片视图:

arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s := arr[2:8] // [2 3 4 5 6 7]

// 对切片再截取
a := s[1:4]     // [3 4 5]
b := s[:3]      // [2 3 4]
c := s[3:]      // [5 6 7]
d := s[2:5:6]   // [4 5 6],len=3, cap=4

fmt.Println(a) // [3 4 5]
fmt.Println(b) // [2 3 4]
fmt.Println(c) // [5 6 7]
fmt.Println(d) // [4 5 6]
fmt.Println(cap(d)) // 4
original := []int{1, 2, 3, 4, 5, 6, 7, 8}
sub := original[2:5] // [3 4 5]

// 修改子切片会影响原切片
sub[0] = 300
fmt.Println(original) // [1 2 300 4 5 6 7 8]
fmt.Println(sub)      // [300 4 5]

删除元素

Go 没有内置的删除切片元素的函数,但可以通过截取组合实现:

// 删除指定索引的元素
func removeAt(s []int, index int) []int {
    return append(s[:index], s[index+1:]...)
}

s := []int{1, 2, 3, 4, 5}
s = removeAt(s, 2) // 删除索引 2 的元素
fmt.Println(s)      // [1 2 4 5]

// 删除第一个元素
s = s[1:]

// 删除最后一个元素
s = s[:len(s)-1]
// 防止内存泄漏的安全删除
func removeAtSafe(s []*int, index int) []*int {
    // 先将待删除元素置为 nil
    s[index] = nil
    // 再截取
    return append(s[:index], s[index+1:]...)
}

切片拷贝

使用内置的 copy 函数可以在两个切片之间复制元素:

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3) // 长度为 3

// copy 会复制 min(len(dst), len(src)) 个元素
n := copy(dst, src)
fmt.Println(dst) // [1 2 3]
fmt.Println(n)   // 3 —— 实际复制的元素个数

// copy 是深拷贝,修改 dst 不影响 src
dst[0] = 999
fmt.Println(src) // [1 2 3 4 5] —— 未受影响
// 常用场景:从大切片中提取子集
data := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
result := make([]int, 5)
copy(result, data[2:7])
fmt.Println(result) // [3 4 5 6 7]

切片作为函数参数

切片是引用类型,传递给函数时复制的是切片头(指针、len、cap),而不是整个底层数组。因此函数内对切片元素的修改会影响原切片,但如果触发了扩容,则不会。

func modifySlice(s []int) {
    s[0] = 999   // 修改底层数组元素,影响原切片 ✅
    s = append(s, 100) // 如果没有触发扩容,追加的元素在原底层数组上可见
}

func main() {
    s := make([]int, 3, 10)
    s[0] = 1
    modifySlice(s)
    fmt.Println(s) // [999 0 0](s[0] 被修改,append 的结果可能丢失)
}
func appendSlice(s []int) {
    // 如果 append 触发扩容,函数内的 s 会指向新的底层数组
    // 但外部的 s 仍然指向旧底层数组
    s = append(s, 100, 200, 200, 200, 200, 200, 200, 200, 200, 200)
    fmt.Println("函数内 len:", len(s), "cap:", cap(s))
}

func main() {
    s := make([]int, 3, 5)
    appendSlice(s)
    fmt.Println("函数外 len:", len(s), "cap:", cap(s))
    // 函数外 len: 3, cap: 5 —— 未受 append 影响
}
// 方式一:返回新切片(推荐)
func addElement(s []int, elem int) []int {
    return append(s, elem)
}

// 方式二:传入切片指针
func addElementPtr(s *[]int, elem int) {
    *s = append(*s, elem)
}

常见陷阱

陷阱一:循环中的切片追加变量引用

// ❌ 错误示例
var funcs []func()
for _, v := range []int{1, 2, 3} {
    funcs = append(funcs, func() {
        fmt.Println(v) // 闭包捕获的是变量 v 的地址
    })
}
for _, f := range funcs {
    f() // 输出:3, 3, 3
}

// ✅ 正确做法:在循环体内创建局部变量
var funcs []func()
for _, v := range []int{1, 2, 3} {
    v := v // 创建新的局部变量
    funcs = append(funcs, func() {
        fmt.Println(v)
    })
}
for _, f := range funcs {
    f() // 输出:1, 2, 3
}

陷阱二:容量泄漏

// 从大数组创建小切片后,小切片持有对大数组的引用
// 导致大数组无法被垃圾回收

func getFirstN(data []byte, n int) []byte {
    return data[:n] // 危险:返回的切片引用整个 data 的底层数组
}

// ✅ 安全做法:拷贝数据到新切片
func getFirstNSafe(data []byte, n int) []byte {
    result := make([]byte, n)
    copy(result, data[:n])
    return result // result 的底层数组大小正好是 n
}

陷阱三:切片比较

切片不能直接使用 == 比较(只能与 nil 比较)。比较两个切片是否相等需要逐元素比较或使用 reflect.DeepEqual

s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}

// s1 == s2 // 编译错误:slice can only be compared to nil
fmt.Println(s1 == nil) // false —— 切片只能与 nil 比较

// 正确的比较方式
import "reflect"
fmt.Println(reflect.DeepEqual(s1, s2)) // true

// 或手动比较
func equal(a, b []int) bool {
    if len(a) != len(b) {
        return false
    }
    for i := range a {
        if a[i] != b[i] {
            return false
        }
    }
    return true
}

陷阱四:字符串与 []byte 的转换

s := "hello"
b := []byte(s) // 创建了底层数组的拷贝
b[0] = 'H'
fmt.Println(s) // "hello" —— 字符串不可变,不受影响

// Go 1.20+ 提供了 unsafe 方式避免拷贝(仅用于性能敏感场景)

练习题

练习 1:切片基本操作

编写一个函数,接收一个 []int 切片,返回一个新切片,其中只包含原切片中的偶数元素。

参考答案
package main

import "fmt"

func filterEven(nums []int) []int {
    result := make([]int, 0, len(nums))
    for _, v := range nums {
        if v%2 == 0 {
            result = append(result, v)
        }
    }
    return result
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    evens := filterEven(nums)
    fmt.Println(evens) // [2 4 6 8 10]
}

练习 2:切片去重

编写一个函数,接收一个 []int 切片,返回一个去重后的新切片(保持原有顺序)。

参考答案
package main

import "fmt"

func unique(nums []int) []int {
    seen := make(map[int]bool)
    result := make([]int, 0, len(nums))
    for _, v := range nums {
        if !seen[v] {
            seen[v] = true
            result = append(result, v)
        }
    }
    return result
}

func main() {
    nums := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3}
    result := unique(nums)
    fmt.Println(result) // [3 1 4 5 9 2 6]
}

练习 3:理解扩容

编写一个程序,展示切片从空切片开始,依次 append 元素时 len 和 cap 的变化情况。观察在什么位置发生扩容。

参考答案
package main

import "fmt"

func main() {
    var s []int
    oldCap := 0

    for i := 0; i < 20; i++ {
        s = append(s, i)
        if cap(s) != oldCap {
            fmt.Printf("len=%2d, cap=%2d (扩容! 旧 cap=%2d)\n", len(s), cap(s), oldCap)
            oldCap = cap(s)
        }
    }
    // 典型输出(可能因 Go 版本和系统而异):
    // len= 1, cap= 1 (扩容! 旧 cap= 0)
    // len= 2, cap= 2 (扩容! 旧 cap= 1)
    // len= 3, cap= 4 (扩容! 旧 cap= 2)
    // len= 5, cap= 8 (扩容! 旧 cap= 4)
    // len= 9, cap=16 (扩容! 旧 cap= 8)
    // len=17, cap=32 (扩容! 旧 cap=16)
}

搜索