slice

·go
#fundamentals

Slice

在 Go 语言中,slice 是一种非常灵活且功能强大的数据结构,可以看作是对数组的一个轻量级抽象,它本质上是一段连续的内存区域视图,它包含了底层数组的部分或全部数据,并支持动态扩展

slice 的基本概念

什么是 slice

slice 是对数组的一个抽象视图,它不存储数据,而是指向一个底层数组,与固定大小的数组不同,slice 的长度可以动态改变,这使得它在实际应用中非常灵活

slice 是一种引用类型,多个 slice 可以引用同一个底层数组,对其中一个 slice 进行修改可能会影响到其他引用同一底层数组的 slice

为什么需要 slice

数组在 Go 中是值类型,赋值时会拷贝整个数组,而 slice 只传递三个数据(指针、长度、容量),效率更高,且提供了对数组部分数据的访问能力,可以通过切片表达式方便地获取数组的子序列

内置函数 appendcopy 等使得操作 slice 更加便捷

slice 的内部结构

在 Go 语言中,slice 实际上是一个结构体,内部包含三个元素:

  • 指针

    • 指向底层数组的起始位置,也就是数据存储的内存地址
  • 长度(len)

    • 表示当前 slice 包含了多少个元素,即可访问的元素个数
  • 容量(cap)

    • 表示从 slice 的起始位置到底层数组的末尾,总共能够包含的元素个数

    • 它定义了 slice 在不分配新内存的前提下,最多能扩展到的大小

可以用下面的伪结构体来表示 slice

type sliceHeader struct {
    Data uintptr // 指向底层数组的指针
    Len  int     // 当前 slice 的长度
    Cap  int     // 当前 slice 的容量
}

这种设计使得 slice 可以作为轻量级的数据视图,同时在扩容时能高效管理内存

slice 的声明与初始化

通过数组切片表达式创建 slice

可以从一个数组中通过下标范围取出一个 slice

package main
import "fmt"

func main() {
    arr := [5]int{10, 20, 30, 40, 50}

    // 从数组中取出索引 1 到 3(不包含索引 4)的元素
    s := arr[1:4]

    fmt.Println("slice s:", s) // 输出 [20 30 40]
    fmt.Println("len(s):", len(s))
    fmt.Println("cap(s):", cap(s)) // 容量是从 arr[1] 到 arr[len(arr)-1] 的长度,即 4
}

此时,s 引用arr 的一部分,修改 s 中的元素也会影响 arr

通过 slice 字面量直接初始化

可以直接使用字面量创建一个 slice,而不必先声明一个数组:

package main
import "fmt"

func main() {
    s := []int{1, 2, 3, 4, 5}

    fmt.Println("slice s:", s)
    fmt.Println("len(s):", len(s))
    fmt.Println("cap(s):", cap(s))
}

此时系统会自动创建一个底层数组来存放这些初值

使用 make 创建 slice

使用内置函数 make 可以创建一个具有指定长度和容量的 slice

package main
import "fmt"

func main() {
    // 创建一个长度为 3、容量为 5 的 slice
    s := make([]int, 3, 5)

    fmt.Println("slice s:", s)     // 初始化元素均为 0
    fmt.Println("len(s):", len(s))   // 3
    fmt.Println("cap(s):", cap(s))   // 5
}

其中 make 函数的第三个参数是可以省略的,也就是初始化时可以只初始化长度

二维数组的初始化

固定大小的二维数组:

// 默认初始化元素是 0
var matrix [3][4]int

// 也可以在声明时指定初始值
matrix := [3][4]int{
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12},
}

动态二维数组(切片):

// 只是创建了一个外层的切片,此时内层每个元素的值默认为 nil,并未分配内存
matrix := make([][]int, n) // n 行

// 要为每一行分配内存,相当于为内层切片赋值
for i := 0; i < n; i++ {
    matrix[i] = make([]int, m) // m 列
}

slice 的常见操作

访问元素

与数组类似,可以使用下标访问 slice 中的元素:

s := []string{"Go", "Java", "Python"}

fmt.Println(s[0]) // 输出 "Go"
修改元素

因为 slice 是引用类型直接通过下标修改可以改变底层数组的值

s[1] = "C++"

fmt.Println(s) // 结果可能是 ["Go", "C++", "Python"]
追加元素:append

使用内置的 append 函数可以向 slice 添加新元素

当添加的元素超过当前容量时,Go 会自动分配更大的底层数组并将原有数据复制过去:

s := []int{1, 2, 3}
s = append(s, 4, 5)
fmt.Println("slice s:", s) // 输出 [1 2 3 4 5]
复制元素:copy

使用内置的 copy 函数可以从一个 slice 复制数据到另一个 slice,复制的长度为两者中较小的那个:

src := []int{1, 2, 3}
dst := make([]int, 2)
n := copy(dst, src)
fmt.Println("dst:", dst) // 输出 [1, 2]
fmt.Println("n:", n)     // 实际复制了 2 个元素

当你对一个 slice 使用 append 操作且超过其当前容量时,运行时会自动分配一个更大的底层数组,并将原有数据复制过去

这种扩容机制会导致原 slice 与新的 slice 指向不同的底层数组,从而可能出现“引用失效”的问题

截取 slice

可以通过切片表达式slice 进一步截取:

s := []int{1, 2, 3, 4, 5}
sub := s[2:] // 从索引 2 到结尾
fmt.Println("sub:", sub) // 输出 [3 4 5]
二维数组的遍历和获取元素

遍历:

// 对于切片的切片
for i, row := range matrix {
    for j, value := range row {
        fmt.Printf("matrix[%d][%d] = %d\n", i, j, value)
    }
}

获取元素:

directions := [][]int{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}

// 想要获取内层切片的元素,直接用下标就行
// 从第一个内层切片从取出下标为 0 的元素,也就是 0
value := directions[0][0] 

slice 的内存模型和扩容机制

共享底层数组

当多个 slice 引用同一底层数组时,修改其中一个 slice 的内容会影响到其他 slice

例如:

arr := [5]int{10, 20, 30, 40, 50}
s1 := arr[1:4] // 包含 20, 30, 40
s2 := s1[1:3]  // 包含 30, 40
s1[1] = 300    // 修改 s1 中的元素
fmt.Println("s2:", s2) // 输出 [300, 40]

这种共享机制既可以节省内存,又需要开发者注意数据的副作用

扩容机制

当使用 appendslice 添加元素超过容量时,Go 会自动重新分配内存,并复制原有的数据

新分配的内存大小通常会以指数方式增长,以减少频繁分配内存带来的性能影响

你将 slice 传递给函数时可能只传递了引用,但当内部调用 append 时,如果扩容发生,slice 实际上就指向一个全新的底层数组,这时外部的 slice 不会受到该扩容的影响

内存管理的注意事项
  • 尽量避免创建过大的 slice 却只使用其中一部分数据,因为底层数组不会自动收缩,这可能会导致内存浪费

  • 如果你只需要部分数据,可考虑使用 copy 函数将需要的数据拷贝到新的 slice 中,使得不再使用的部分可以被垃圾回收

slice 与数组的区别

类型定义

数组:例如 [5]int,数组长度是类型的一部分,因此 [5]int[10]int不同的类型

slice:例如 []int与长度无关,是对数组的一种抽象视图

传递方式

数组是值类型,传递时会复制整个数组(除非使用指针)

slice 传递时仅复制结构体(包含指针、长度、容量),因此修改 slice 的元素会反映到底层数组上

灵活性

数组大小固定,不便于动态变化

slice灵活且支持动态扩容,更适合大部分实际场景

总结

slice 是 Go 语言中对数组的一种动态封装, 它由指针、长度和容量组成,可以方便地管理和操作数据集合

通过内置函数 make、切片表达式、appendcopy, 我们可以非常灵活地创建、扩展和操作 slice

共享底层数组和自动扩容机制提供了高效的内存管理,但也需要开发者注意使用时可能引发的副作用,比如多个 slice 共享同一数据的情况或因扩容而导致引用失效的情形

与数组相比,slice 在大多数实际应用中更加常用和灵活, 因为它不仅避免了数组传值产生的开销,还能够动态调整大小以适应需求