Golang 切片数据结构

GolangGolangBeginner
立即练习

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

介绍

在前一节中,我们讨论了 Go 语言中的数组。然而,数组有一些局限性:一旦声明并初始化后,其长度无法改变。因此,数组在日常编程中并不常用。相比之下,切片(slice)更为常见,它提供了更灵活的数据结构。

知识点:

  • 定义切片
  • 初始化切片
  • 切片的操作,即增、删、改、查
  • 扩展切片
  • 切片截取
  • 多维切片

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL go(("Golang")) -.-> go/BasicsGroup(["Basics"]) go(("Golang")) -.-> go/DataTypesandStructuresGroup(["Data Types and Structures"]) go(("Golang")) -.-> go/FunctionsandControlFlowGroup(["Functions and Control Flow"]) go/BasicsGroup -.-> go/values("Values") go/BasicsGroup -.-> go/variables("Variables") go/DataTypesandStructuresGroup -.-> go/arrays("Arrays") go/DataTypesandStructuresGroup -.-> go/slices("Slices") go/DataTypesandStructuresGroup -.-> go/pointers("Pointers") go/FunctionsandControlFlowGroup -.-> go/for("For") subgraph Lab Skills go/values -.-> lab-149077{{"Golang 切片数据结构"}} go/variables -.-> lab-149077{{"Golang 切片数据结构"}} go/arrays -.-> lab-149077{{"Golang 切片数据结构"}} go/slices -.-> lab-149077{{"Golang 切片数据结构"}} go/pointers -.-> lab-149077{{"Golang 切片数据结构"}} go/for -.-> lab-149077{{"Golang 切片数据结构"}} end

什么是切片

切片(Slice)与数组类似,它们都是用于存储相同数据类型元素的容器。然而,数组有一些局限性:一旦声明并初始化后,其长度无法改变。尽管数组在某些场景下有用,但它们不够灵活。因此,切片在日常编程中更为常用。

在 Go 语言中,切片是通过数组实现的。切片本质上是一个可以动态改变长度的数组。我们可以对切片进行添加、删除、修改和查找元素等操作,而这些操作在数组中是无法实现的。

定义切片

切片的初始化语法与数组非常相似。主要区别在于不需要指定元素的长度。让我们来看以下代码:

// 声明一个长度为 5 的数组
var a1 [5]byte
// 声明一个切片
var s1 []byte

切片是对数组的引用

如果我们声明一个 int 类型的数组,数组的每个元素的零值将是 0。

然而,如果我们声明一个切片,切片的零值将是 nil。让我们创建一个名为 slice.go 的文件来验证这一点:

touch ~/project/slice.go

输入以下代码:

package main

import "fmt"

func main() {
    var a [3]int
    var s []int

    fmt.Println(a[0] == 0) // true
    fmt.Println(s == nil)  // true
}
go run slice.go

运行代码后,将显示以下输出:

true
true

我们创建了一个数组 a 和一个切片 s。我们将数组 a 的第一个元素与零进行比较,并检查切片 s 是否为 nil

正如我们所看到的,当我们声明一个切片时,它的零值是 nil。这是因为切片并不存储任何数据;它们只是引用数组。切片指向底层的数组结构。

切片的数据结构

切片是一种复合数据类型,也称为结构体(struct)。它是由不同类型的字段组成的复合类型。切片的内部结构由三个元素组成:指针、长度和容量。

type slice struct {
    elem *type
    len  int
    cap  int
}

如前所述,该结构体引用了底层的数组。elem 指针指向数组的第一个元素,而 type 是被引用数组元素的类型。

lencap 分别表示切片的长度和容量。你可以使用 len()cap() 函数来获取切片的长度和容量。

下图展示了切片引用了一个 int 类型的底层数组,其长度为 8,容量为 10:

slice referencing int array

当你定义一个新的切片时,elem 指针会被初始化为零值(即 nil)。指针的概念将在后续实验中介绍。现在只需注意,指针指向某个值的内存地址。在上图中,elem 指针指向底层数组第一个元素的地址。

切片的操作:增、删、改、查

截取数组或切片

由于切片的底层结构是数组,我们可以提取数组的指定长度作为切片的引用。以下代码段展示了这一点:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // 声明一个空切片
    var s1 []int

    fmt.Println("切片 s1 为空:", s1 == nil)

    // 使用数组截取获取切片
    s1 = a[1:5]
    s2 := a[2:5]
    s3 := a[:]

    fmt.Println("切片 s1、s2 和 s3 为空:", s1 == nil, s2 == nil, s3 == nil)
    fmt.Println("数组 a 中的元素:", a)
    fmt.Println("切片 s1 中的元素:", s1)
    fmt.Println("切片 s2 中的元素:", s2)
    fmt.Println("切片 s3 中的元素:", s3)
}

输出结果如下:

切片 s1 为空: true
切片 s1、s2 和 s3 为空: false false false
数组 a 中的元素: [0 1 2 3 4 5 6 7 8 9]
切片 s1 中的元素: [1 2 3 4]
切片 s2 中的元素: [2 3 4]
切片 s3 中的元素: [0 1 2 3 4 5 6 7 8 9]

在这个程序中,我们首先声明并初始化了数组 a,然后使用数组截取将数组的一部分赋值给空切片 s1。通过这种方式,我们创建了一个新的切片。

s1[1:5] 表示从数组 a 的索引 1 到索引 5 创建一个切片,但不包括索引 5 的元素。

注意: 在编程语言中,第一个元素的索引是 0,而不是 1。同样,数组中的第二个元素的索引是 1。

我们使用 := 运算符将截取的数组直接赋值给切片 s2s3 也是如此,但没有指定范围,因此它会截取数组的所有元素。

下图展示了截取的过程。注意,切片的绿色部分表示对蓝色数组的引用。换句话说,它们共享同一个底层数组,即 a

slice truncation visualization

切片的截取语法如下:

[start:end]

startend 都是可选参数。当我们想要获取数组的所有元素时,可以省略 startend 参数。这在前面的程序中的 s3 := a[:] 中得到了展示。

如果我们想要获取某个索引之后的所有元素,可以省略 end 参数。例如,a1[3:] 将获取从索引 3 开始的所有元素。

要获取某个索引之前的所有元素,可以省略 start 参数。例如,a1[:4] 将提取从索引 0 到索引 4 的所有元素,但不包括索引 4 的元素。

除了从数组中提取切片外,我们还可以从现有的切片中提取新的切片。操作与数组相同。以下是一个简单的示例:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // 创建初始切片 s1
    var s1 []int
    s1 = a[1:7]
    fmt.Printf("切片 s1: %d\t长度: %d\t容量: %d\n", s1, len(s1), cap(s1))

    // 从初始切片 s1 中提取新的切片 s2
    s2 := s1[2:4]
    fmt.Printf("切片 s1: %d\t长度: %d\t容量: %d\n", s2, len(s2), cap(s2))
}

输出结果如下:

切片 s1: [1 2 3 4 5 6]	长度: 6	容量: 9
切片 s1: [3 4]	长度: 2	容量: 7

在这个程序中,我们通过截取数组 a 得到了切片 s1s1 的范围是从索引 1 到索引 7。通过使用 :=,我们从 s1 中提取了一个新的切片 s2。由于 s1 的截取是连续的,新的切片 s2 也将是连续的。

我们注意到,切片的容量会随着截取而改变。规则如下:

如果我们截取一个容量为 c 的切片,s[i:j] 的长度将是 j-i,容量将是 c-i

对于 s1,底层数组是 aa[1:7] 的容量是 9(即 10-1)。

对于 s2,底层数组与 s1 相同。由于上一步截取的部分容量为 9,s1[2:4] 的容量变为 7(即 9-2)。

修改切片的值会同时影响底层数组元素的值

由于切片不存储数据,而是引用数组,修改切片的值也会同时改变底层数组的值。让我们来演示这一点:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[2:5]
    s2 := a[:]

    fmt.Println("修改前: ")
    fmt.Println("数组 a 中的元素: ", a)
    fmt.Println("数组 a 中的元素: ", s1)
    fmt.Println("数组 a 中的元素: ", s2)

    // 将切片 s1 中索引 2 的值修改为 23
    s1[2] = 23

    fmt.Println("修改后: ")
    fmt.Println("数组 a 中的元素: ", a)
    fmt.Println("数组 a 中的元素: ", s1)
    fmt.Println("数组 a 中的元素: ", s2)
}

输出结果如下:

修改前:
数组 a 中的元素:  [0 1 2 3 4 5 6 7 8 9]
数组 a 中的元素:  [2 3 4]
数组 a 中的元素:  [0 1 2 3 4 5 6 7 8 9]
修改后:
数组 a 中的元素:  [0 1 23 3 4 5 6 7 8 9]
数组 a 中的元素:  [2 3 23]
数组 a 中的元素:  [0 1 23 3 4 5 6 7 8 9]

在这个程序中,切片 s1s2 都引用了数组 a。当切片 s1 将索引 2 的值修改为 23 时,数组 a 和切片 s2 的值也会被更新。

我们可以看到,数组 a 中索引 2 的值被修改为 23,这导致切片 s2 中索引 4 的值也被修改。

这会在程序开发中导致难以调试的 bug。因此,在日常编程中,我们应尽量避免多个切片引用同一个底层数组。

向切片追加元素

在本节中,我们将介绍 append 函数,它用于向切片添加元素。语法如下:

func append(slice []Type, elems ...Type) []Type

第一个参数是切片 slice,其余参数是要添加到切片中的元素。末尾的 []Type 表示 append 函数将返回一个与 slice 数据类型相同的新切片。

elems 后面的 ... 表示这是一个可变参数,意味着可以输入一个或多个参数。

以下是使用 append 函数的示例:

package main

import "fmt"

func main() {
    // 定义一个长度为 10 的整型数组
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[1:7]
    fmt.Printf("初始 s1 值: %d\t长度: %d\t容量: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 12)
    fmt.Printf("修改后的 s1 值: %d\t长度: %d\t容量: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 14, 14)
    fmt.Printf("修改后的 s1 值: %d\t长度: %d\t容量: %d\n", s1, len(s1), cap(s1))
}

输出结果如下:

初始 s1 值: [1 2 3 4 5 6]	长度: 6	容量: 9
修改后的 s1 值: [1 2 3 4 5 6 12]	长度: 7	容量: 9
修改后的 s1 值: [1 2 3 4 5 6 12 14 14]	长度: 9	容量: 9

在这个程序中,我们首先通过截取数组 a 创建了切片 s1s1 的范围是从索引 1 到索引 7。我们使用 := 运算符将 append 的返回值赋值给 s1。当我们使用 append 添加元素时,切片的容量会发生变化。如果元素数量超过容量,s1 的容量将翻倍。

切片的扩展规则如下:

  • 如果切片的底层数组可以容纳新元素,切片的容量不会改变。
  • 如果切片的底层数组无法容纳新元素,Go 会创建一个更大的数组,将原始切片的值复制到新数组中,然后将追加的值添加到新数组中。

注意: 切片的扩展并不总是翻倍,它取决于元素的大小、扩展的元素数量以及计算机的硬件等因素。更多信息请参考切片的高级部分。

删除切片中的元素

Go 没有提供关键字或函数来删除切片中的元素,但我们可以使用数组截取来实现相同的功能,或者更强大的功能。

以下代码删除了切片 s 中索引 5 的元素,并将其赋值给新的切片 s1

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    // 删除前打印切片 `s`
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // 删除索引 5 的元素后打印切片 `s` 和 `s1`
    fmt.Printf("%d\n%d\n", s, s1)
}

输出结果如下:

[0 1 2 3 4 5 6 7 8 9]
[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

删除特定索引的关键在于 append 语句。它将索引 6 之后的元素追加到索引 5 之前的元素中。

这个操作相当于提前覆盖。例如,元素 5 被元素 6 覆盖,元素 6 被元素 7 覆盖,依此类推,直到元素 9 被添加到新切片中。因此,此时底层数组的值为 [0 1 2 3 4 6 7 8 9 9]

此时,如果我们检查 s1 的长度和容量,可以看到长度为 9,但容量为 10。这是因为 s1 是对 s 截取的引用。它们共享同一个底层数组。

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // 检查 s1 的长度和容量
    fmt.Println(len(s1), cap(s1))
    fmt.Printf("\n%d\n%d\n\n", s, s1)

    // 修改切片 s
    s[3] = 22
    fmt.Printf("%d\n%d\n", s, s1)
}

输出结果如下:

[0 1 2 3 4 5 6 7 8 9]
9 10

[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

[0 1 2 22 4 6 7 8 9 9]
[0 1 2 22 4 6 7 8 9]

练习

除了删除特定位置的元素外,我们还可以使用截取从切片中删除特定范围的元素。操作与删除特定索引的元素相同。让我们做一个小练习。

创建一个文件 slice1.go。创建一个切片 a 并按以下方式初始化。然后使用截取创建另一个切片 s,其中不包括大于 3 或小于 7 的元素。最后,打印新的切片。

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

输出:

[9 8 7 3 2 1 0]
  • 提示: 注意切片的起始索引(记住,索引从 0 开始)。
  • 要求: slice1.go 文件需要放在 ~/project 目录中。
✨ 查看解决方案并练习

扩展切片

切片有两个属性:lencaplen 表示切片当前包含的元素数量,而 cap 表示切片可以容纳的最大元素数量。

当向切片添加的元素数量超过其容量时会发生什么?让我们一起来探索:

package main

import "fmt"

func main() {
    s1 := make([]int, 3)
    s2 := make([]int, 3, 5)
    fmt.Println("s1 追加前:", s1, "长度:", len(s1), "容量:", cap(s1))
    fmt.Println("s2 追加前:", s2, "长度:", len(s2), "容量:", cap(s2))

    s1 = append(s1, 12)
    s2 = append(s2, 22)
    fmt.Println("s1 追加后:", s1, "长度:", len(s1), "容量:", cap(s1))
    fmt.Println("s2 追加后:", s2, "长度:", len(s2), "容量:", cap(s2))
}

运行程序时,执行以下命令:

go run slice.go

输出结果如下:

s1 追加前: [0 0 0] 长度: 3 容量: 3
s2 追加前: [0 0 0] 长度: 3 容量: 5
s1 追加后: [0 0 0 12] 长度: 4 容量: 6
s2 追加后: [0 0 0 22] 长度: 4 容量: 5

正如我们在这个程序中看到的,当我们向切片添加元素并且元素数量超过其原始容量时,切片的容量会自动增加。

切片扩展的规则如下:

  • 如果切片的底层数组可以容纳新元素,切片的容量不会改变。
  • 如果切片的底层数组无法容纳新元素,Go 会创建一个更大的数组,将原始切片的值复制到新数组中,然后将追加的值添加到新数组中。

注意: 切片的容量并不总是翻倍,它取决于元素的大小、元素的数量以及计算机的硬件等因素。更多信息请参考切片的高级部分。

复制切片

我们可以使用 copy 函数将一个切片复制到另一个切片中。语法如下:

func copy(dst, src []Type) int

dst 是目标切片,src 是源切片,最后的 int 表示复制的元素数量,即 len(dst)len(src) 的最小值。

注意: copy 函数不会添加元素。

以下是一个示例:

package main

import "fmt"

func main() {
    s1 := []int{0, 1, 2, 3}
    s2 := []int{8, 9}

    s3 := []int{0, 1, 2, 3}
    s4 := []int{8, 9}

    // 将 s1 复制到 s2
    n1 := copy(s2, s1)
    // 将 s4 复制到 s3
    n2 := copy(s3, s4)

    fmt.Println(n1, s1, s2)
    fmt.Println(n2, s3, s4)
}

运行程序时,执行以下命令:

go run slice.go

输出结果如下:

2 [0 1 2 3] [0 1]
2 [8 9 2 3] [8 9]

在这个程序中,我们将切片 s1 的值复制到切片 s2,并将切片 s4 的值复制到切片 s3copy 函数返回复制的元素数量。

我们注意到,s1s2 的值相同,s3s4 的值也相同。第一个 copy 函数将 s1[0, 1, 2, 3] 复制到 s2[8, 9]。由于 s1s2 的最小长度为 2,因此复制了 2 个值。目标切片 s2 被修改为 [0, 1]

第二个 copy 函数将 s4[8, 9] 复制到 s3[0, 1, 2, 3]。由于 s3s4 的最小长度为 2,因此复制了 2 个值。结果,s3 被修改为 [8, 9, 2, 3]

遍历切片

遍历切片与遍历数组类似。所有数组的遍历方法都可以用于切片。

练习

在这个练习中,我们将测试并巩固对切片和数组遍历的理解。

创建一个文件 slice2.go。声明一个数组 a1 和一个切片 s1,并按以下方式初始化它们。然后,遍历数组 a1 和切片 s1 中的元素,并打印它们的索引和值。

a1 := [5]int{1, 2, 3, 9, 7}
s1 := []int{1, 8, 12, 1, 3}

输出:

数组 a1 中索引 0 的元素是 1
数组 a1 中索引 1 的元素是 2
数组 a1 中索引 2 的元素是 3
数组 a1 中索引 3 的元素是 9
数组 a1 中索引 4 的元素是 7
切片 s1 中索引 0 的元素是 1
切片 s1 中索引 1 的元素是 8
切片 s1 中索引 2 的元素是 12
切片 s1 中索引 3 的元素是 1
切片 s1 中索引 4 的元素是 3
  • 要求: slice2.go 文件需要放在 ~/project 目录中。
  • 提示: 你可以使用 range 格式或索引格式来遍历元素。
✨ 查看解决方案并练习

总结

在本节中,我们学习了切片及其用法。相比之下,切片比数组更加灵活和多功能。在处理多个切片时,我们需要谨慎以避免意外的切片操作。