切片(slice)是对数组的一个连续片段的引用,所以切片是一个引用类型(因此更类似于 C/C++ 中的数组类型,或者 Python 中的 list 类型),这个片段可以是整个数组,也可以是由起始和终止索引标识的一些项的子集,需要注意的是,终止索引标识的项不包括在切片内。

Go语言中切片的内部结构包含地址、大小和容量,切片一般用于快速地操作一块数据集合,如果将数据集合比作切糕的话,切片就是你要的“那一块”,切的过程包含从哪里开始(切片的起始位置)及切多大(切片的大小),容量可以理解为装切片的口袋大小,如下图所示。

切片操作[开始 :位置](用刀切)
分配的内存
地址
开始位置
大小
切多大
容量
袋子大小

请添加图片描述

从数组或切片生成新的切片

切片默认指向一段连续内存区域,可以是数组,也可以是切片本身。从连续内存区域生成切片是常见的操作,格式如下:

// slice:表示目标切片对象
// 开始位置:对应目标切片对象的索引
// 结束位置:对应目标切片的结束索引
slice [开始位置 : 结束位置]

从数组生成切片,代码如下:

var a  = [3]int{1, 2, 3}
fmt.Println(a, a[1:2])

其中 a 是一个拥有 3 个整型元素的数组,被初始化为数值 1 到 3,使用 a[1:2] 可以生成一个新的切片,代码运行结果如下:

[1 2 3]  [2]

其中 [2] 就是 a[1:2] 切片操作的结果。

从数组或切片生成新的切片拥有如下特性:

  • 取出的元素数量为:结束位置 - 开始位置;
  • 取出元素不包含结束位置对应的索引,切片最后一个元素使用 slice[len(slice)] 获取;
  • 当缺少开始位置时,表示从连续区域开头到结束位置;
  • 当缺少结束位置时,表示从开始位置到整个连续区域末尾;
  • 两者同时缺少时,与切片本身等效;
  • 两者同时为 0 时,等效于空切片,一般用于切片复位。

根据索引位置取切片 slice 元素值时,取值范围是(0~len(slice)-1),超界会报运行时错误,生成切片时,结束位置可以填写 len(slice) 但不会报错。下面通过实例来熟悉切片的特性:

从指定范围中生成切片

切片有点像C语言里的指针,指针可以做运算,但代价是内存操作越界,切片在指针的基础上增加了大小,约束了切片对应的内存区域,切片使用中无法对切片内部的地址和大小进行手动调整,因此切片比指针更安全、强大。

切片和数组密不可分,如果将数组理解为一栋办公楼,那么切片就是把不同的连续楼层出租给使用者,出租的过程需要选择开始楼层和结束楼层,这个过程就会生成切片,示例代码如下:

// 代码中构建了一个 30 层的高层建筑
// 数组的元素值从 1 到 30,分别代表不同的独立楼层,输出的结果是不同的租售方案
var highRiseBuilding [30]int

for i := 0; i < 30; i++ {
        highRiseBuilding[i] = i + 1
}

// 区间
// 尝试出租一个区间楼层
fmt.Println(highRiseBuilding[10:15])

// 中间到尾部的所有元素
// 出租 20 层以上
fmt.Println(highRiseBuilding[20:])

// 开头到中间指定位置的所有元素
// 出租 2 层以下,一般是商用铺面
fmt.Println(highRiseBuilding[:2])

代码输出如下:

[11 12 13 14 15]
[21 22 23 24 25 26 27 28 29 30]
[1 2]

表示原有的切片

生成切片的格式中,当开始和结束位置都被忽略时,生成的切片将表示和原切片一致的切片,并且生成的切片与原切片在数据内容上也是一致的,代码如下:

a := []int{1, 2, 3}
fmt.Println(a[:])

a 是一个拥有 3 个元素的切片,将 a 切片使用 a[:] 进行操作后,得到的切片与 a 切片一致,代码输出如下:

[1 2 3]

重置切片,清空拥有的元素

把切片的开始和结束位置都设为 0 时,生成的切片将变空,代码如下:

a := []int{1, 2, 3}
fmt.Println(a[0:0])

代码输出如下:

[]

直接声明新的切片

除了可以从原有的数组或者切片中生成切片外,也可以声明一个新的切片,每一种类型都可以拥有其切片类型,表示多个相同类型元素的连续集合,因此切片类型也可以被声明,切片类型声明格式如下:

// 其中 name 表示切片的变量名
// Type 表示切片对应的元素类型
var name []Type

下面代码展示了切片声明的使用过程:

// 声明字符串切片
// 声明一个字符串切片,切片中拥有多个字符串
var strList []string

// 声明整型切片
// 声明一个整型切片,切片中拥有多个整型数值
var numList []int

// 声明一个空切片
// 将 numListEmpty 声明为一个整型切片
// 本来会在{}中填充切片的初始化元素,这里没有填充,所以切片是空的,但是此时的 numListEmpty 已经被分配了内存,只是还没有元素
var numListEmpty = []int{}

// 输出3个切片
// 切片均没有任何元素,3 个切片输出元素内容均为空
fmt.Println(strList, numList, numListEmpty)

// 输出3个切片大小
// 没有对切片进行任何操作,strList 和 numList 没有指向任何数组或者其他切片
fmt.Println(len(strList), len(numList), len(numListEmpty))

// 切片判定空的结果
//声明但未使用的切片的默认值是 nil,strList 和 numList 也是 nil,所以和 nil 比较的结果是 true
// numListEmpty 已经被分配到了内存,但没有元素,因此和 nil 比较时是 false
fmt.Println(strList == nil)
fmt.Println(numList == nil)
fmt.Println(numListEmpty == nil)

代码输出结果:

[] [] []
0 0 0
true
true
false

使用 make() 函数构造切片

如果需要动态地创建一个切片,可以使用 make() 内建函数,格式如下:

// 其中 Type 是指切片的元素类型
// size 指的是为这个类型分配多少个元素
// cap 为预分配的元素数量,这个值设定后不影响 size,只是能提前分配空间,降低多次分配空间造成的性能问题
make( []Type, size, cap )

示例如下:

a := make([]int, 2)
b := make([]int, 2, 10)

fmt.Println(a, b)
fmt.Println(len(a), len(b))

代码输出如下:

[0 0] [0 0]
2 2

其中 a 和 b 均是预分配 2 个元素的切片,只是 b 的内部存储空间已经分配了 10 个,但实际使用了 2 个元素。容量不会影响当前的元素个数,因此 a 和 b 取 len 都是 2。

温馨提示:使用 make() 函数生成的切片一定发生了内存分配操作,但给定开始与结束位置(包括切片复位)的切片只是将新的切片结构指向已经分配好的内存区域,设定开始与结束位置,不会发生内存分配操作。

Go语言append()为切片添加元素

Go语言的内建函数 append() 可以为切片动态添加元素,代码如下所示:

var a []int
a = append(a, 1) // 追加1个元素
a = append(a, 1, 2, 3) // 追加多个元素, 手写解包方式
a = append(a, []int{1,2,3}...) // 追加一个切片, 切片需要解包

不过需要注意的是,在使用 append() 函数为切片动态添加元素时,如果空间不足以容纳足够多的元素,切片就会进行“扩容”,此时新切片的长度会发生改变。切片在扩容时,容量的扩展规律是按容量的 2 倍数进行扩充,例如 1、2、4、8、16……,代码如下:

// 声明一个整型切片
var numbers []int

// 循环向 numbers 切片中添加 10 个数
for i := 0; i < 10; i++ {
    numbers = append(numbers, i)
    // 打印输出切片的长度、容量和指针变化,使用函数 len() 查看切片拥有的元素个数,使用函数 cap() 查看切片的容量情况
    fmt.Printf("len: %d  cap: %d pointer: %p\n", len(numbers), cap(numbers), numbers)
}

代码输出如下:

len: 1  cap: 1 pointer: 0xc0420080e8
len: 2  cap: 2 pointer: 0xc042008150
len: 3  cap: 4 pointer: 0xc04200e320
len: 4  cap: 4 pointer: 0xc04200e320
len: 5  cap: 8 pointer: 0xc04200c200
len: 6  cap: 8 pointer: 0xc04200c200
len: 7  cap: 8 pointer: 0xc04200c200
len: 8  cap: 8 pointer: 0xc04200c200
len: 9  cap: 16 pointer: 0xc042074000
len: 10  cap: 16 pointer: 0xc042074000

通过查看代码输出,可以发现一个有意思的规律:切片长度 len 并不等于切片的容量 cap。往一个切片中不断添加元素的过程,类似于公司搬家,公司发展初期,资金紧张,人员很少,所以只需要很小的房间即可容纳所有的员工,随着业务的拓展和收入的增加就需要扩充工位,但是办公地的大小是固定的,无法改变,因此公司只能选择搬家,每次搬家就需要将所有的人员转移到新的办公点。

  • 员工和工位就是切片中的元素。
  • 办公地就是分配好的内存。
  • 搬家就是重新分配内存。
  • 无论搬多少次家,公司名称始终不会变,代表外部使用切片的变量名不会修改。
  • 由于搬家后地址发生变化,因此内存“地址”也会有修改。

除了在切片的尾部追加,我们还可以在切片的开头添加元素:

var a = []int{1,2,3}
a = append([]int{0}, a...) // 在开头添加1个元素
a = append([]int{-3,-2,-1}, a...) // 在开头添加1个切片

在切片开头添加元素一般都会导致内存的重新分配,而且会导致已有元素全部被复制 1 次,因此,从切片的开头添加元素的性能要比从尾部追加元素的性能差很多。

因为 append 函数返回新切片的特性,所以切片也支持链式操作,我们可以将多个 append 操作组合起来,实现在切片中间插入元素:

var a []int
a = append(a[:i], append([]int{x}, a[i:]...)...) // 在第i个位置插入x
a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第i个位置插入切片

每个添加操作中的第二个 append 调用都会创建一个临时切片,并将 a[i:] 的内容复制到新创建的切片中,然后将临时创建的切片再追加到 a[:i] 中。

Go语言copy():切片复制(切片拷贝)

Go语言的内置函数 copy() 可以将一个数组切片复制到另一个数组切片中,如果加入的两个数组切片不一样大,就会按照其中较小的那个数组切片的元素个数进行复制。copy() 函数的使用格式如下:

// 其中 srcSlice 为数据来源切片
// destSlice 为复制的目标(也就是将 srcSlice 复制到 destSlice)
// 目标切片必须分配过空间且足够承载复制的元素个数,并且来源和目标的类型必须一致
// copy() 函数的返回值表示实际发生复制的元素个数。

copy( destSlice, srcSlice []T) int

下面的代码展示了使用 copy() 函数将一个切片复制到另一个切片的过程:

slice1 := []int{1, 2, 3, 4, 5}
slice2 := []int{5, 4, 3}
copy(slice2, slice1) // 只会复制slice1的前3个元素到slice2中
copy(slice1, slice2) // 只会复制slice2的3个元素到slice1的前3个位置

虽然通过循环复制切片元素更直接,不过内置的 copy() 函数使用起来更加方便,copy() 函数的第一个参数是要复制的目标 slice,第二个参数是源 slice,两个 slice 可以共享同一个底层数组,甚至有重叠也没有问题。下面通过代码演示对切片的引用和复制操作后对切片元素的影响:

package main

import "fmt"

func main() {

    // 设置元素数量为1000
    const elementCount = 1000

    // 预分配足够多的元素切片
    // 预分配拥有 1000 个元素的整型切片,这个切片将作为原始数据
    srcData := make([]int, elementCount)

    // 将切片赋值
    // 将 srcData 填充 0~999 的整型值
    for i := 0; i < elementCount; i++ {
        srcData[i] = i
    }

    // 引用切片数据
    // 将 refData 引用 srcData,切片不会因为等号操作进行元素的复制
    refData := srcData

    // 预分配足够多的元素切片
    // 预分配与 srcData 等大(大小相等)、同类型的切片 copyData
    copyData := make([]int, elementCount)
    // 将数据复制到新的切片空间中
    // 使用 copy() 函数将原始数据复制到 copyData 切片空间中
    copy(copyData, srcData)

    // 修改原始数据的第一个元素
    // 修改原始数据的第一个元素为 999
    srcData[0] = 999

    // 打印引用切片的第一个元素
    // 引用数据的第一个元素将会发生变化
    fmt.Println(refData[0])

    // 打印复制切片的第一个和最后一个元素
    // 打印复制数据的首位数据,由于数据是复制的,因此不会发生变化
    fmt.Println(copyData[0], copyData[elementCount-1])

    // 复制原始数据从4到6(不包含)
    // 将 srcData 的局部数据复制到 copyData 中
    copy(copyData, srcData[4:6])

	// 打印复制局部数据后的 copyData 元素
    for i := 0; i < 5; i++ {
        fmt.Printf("%d ", copyData[i])
    }
}

Go语言从切片中删除元素

Go语言并没有对删除切片元素提供专用的语法或者接口,需要使用切片本身的特性来删除元素,根据要删除元素的位置有三种情况,分别是从开头位置删除、从中间位置删除和从尾部删除,其中删除切片尾部的元素速度最快。

从开头位置删除

删除开头的元素可以直接移动数据指针:

a = []int{1, 2, 3}
a = a[1:] // 删除开头1个元素
a = a[N:] // 删除开头N个元素

也可以不移动数据指针,但是将后面的数据向开头移动,可以用 append 原地完成(所谓原地完成是指在原有的切片数据对应的内存区间内完成,不会导致内存空间结构的变化):

a = []int{1, 2, 3}
a = append(a[:0], a[1:]...) // 删除开头1个元素
a = append(a[:0], a[N:]...) // 删除开头N个元素

还可以用 copy() 函数来删除开头的元素:

a = []int{1, 2, 3}
a = a[:copy(a, a[1:])] // 删除开头1个元素
a = a[:copy(a, a[N:])] // 删除开头N个元素

从中间位置删除

对于删除中间的元素,需要对剩余的元素进行一次整体挪动,同样可以用 append 或 copy 原地完成:

a = []int{1, 2, 3, ...}
a = append(a[:i], a[i+1:]...) // 删除中间1个元素
a = append(a[:i], a[i+N:]...) // 删除中间N个元素
a = a[:i+copy(a[i:], a[i+1:])] // 删除中间1个元素
a = a[:i+copy(a[i:], a[i+N:])] // 删除中间N个元素

从尾部删除

a = []int{1, 2, 3}
a = a[:len(a)-1] // 删除尾部1个元素
a = a[:len(a)-N] // 删除尾部N个元素

删除开头的元素和删除尾部的元素都可以认为是删除中间元素操作的特殊情况,下面来看一个示例:删除切片指定位置的元素,

package main

import "fmt"

func main() {
	// 声明一个整型切片,保存含有从 a 到 e 的字符串
    seq := []string{"a", "b", "c", "d", "e"}

    // 指定删除位置
    // 为了演示和讲解方便,使用 index 变量保存需要删除的元素位置
    index := 2

    // 查看删除位置之前的元素和之后的元素
    // seq[:index] 表示的就是被删除元素的前半部分,值为 [1 2]
    // seq[index+1:] 表示的是被删除元素的后半部分,值为 [4 5]
    fmt.Println(seq[:index], seq[index+1:])

    // 将删除点前后的元素连接起来
    // 使用 append() 函数将两个切片连接起来
    seq = append(seq[:index], seq[index+1:]...)
	
	// 输出连接好的新切片,此时,索引为 2 的元素已经被删除
    fmt.Println(seq)
}

代码输出结果:

[a b] [d e]
[a b d e]

代码的删除过程可以使用下图来描述。
请添加图片描述
提示:连续容器的元素删除无论在任何语言中,都要将删除点前后的元素移动到新的位置,随着元素的增加,这个过程将会变得极为耗时,因此,当业务需要大量、频繁地从一个切片中删除元素时,如果对性能要求较高的话,就需要考虑更换其他的容器了(如双链表等能快速从删除点删除元素)。

Go语言range关键字:循环迭代切片

通过前面的学习我们了解到切片其实就是多个相同类型元素的连续集合,既然切片是一个集合,那么我们就可以迭代其中的元素,Go语言有个特殊的关键字 range,它可以配合关键字 for 来迭代切片里的每一个元素,如下所示:

// 创建一个整型切片,并赋值
slice := []int{10, 20, 30, 40}
// 迭代每一个元素,并显示其值
// index 和 value 分别用来接收 range 关键字返回的切片中每个元素的索引和值
// 这里的 index 和 value 不是固定的,读者也可以定义成其它的名字
for index, value := range slice {
    fmt.Printf("Index: %d Value: %d\n", index, value)
}

上面代码的输出结果为:

Index: 0 Value: 10
Index: 1 Value: 20
Index: 2 Value: 30
Index: 3 Value: 40

当迭代切片时,关键字 range 会返回两个值,第一个值是当前迭代到的索引位置,第二个值是该位置对应元素值的一份副本,如下图所示:

请添加图片描述需要强调的是,range 返回的是每个元素的副本,而不是直接返回对该元素的引用,如下所示:

【示例 1】range 提供了每个元素的副本

// 创建一个整型切片,并赋值
slice := []int{10, 20, 30, 40}
// 迭代每个元素,并显示值和地址
for index, value := range slice {
    fmt.Printf("Value: %d Value-Addr: %X ElemAddr: %X\n", value, &value, &slice[index])
}

输出结果为:

Value: 10 Value-Addr: 10500168 ElemAddr: 1052E100
Value: 20 Value-Addr: 10500168 ElemAddr: 1052E104
Value: 30 Value-Addr: 10500168 ElemAddr: 1052E108
Value: 40 Value-Addr: 10500168 ElemAddr: 1052E10C

因为迭代返回的变量是一个在迭代过程中根据切片依次赋值的新变量,所以 value 的地址总是相同的,要想获取每个元素的地址,需要使用切片变量和索引值(例如上面代码中的 &slice[index])。

如果不需要索引值,也可以使用下划线_来忽略这个值,代码如下所示:

// 创建一个整型切片,并赋值
slice := []int{10, 20, 30, 40}
// 迭代每个元素,并显示其值
for _, value := range slice {
    fmt.Printf("Value: %d\n", value)
}

【示例 2】使用空白标识符(下划线)来忽略索引值

// 创建一个整型切片,并赋值
slice := []int{10, 20, 30, 40}
// 迭代每个元素,并显示其值
for _, value := range slice {
    fmt.Printf("Value: %d\n", value)
}

输出结果为:

Value: 10
Value: 20
Value: 30
Value: 40

关键字 range 总是会从切片头部开始迭代。如果想对迭代做更多的控制,则可以使用传统的 for 循环,代码如下所示。:

【示例 3】使用传统的 for 循环对切片进行迭代

// 创建一个整型切片,并赋值
slice := []int{10, 20, 30, 40}
// 从第三个元素开始迭代每个元素
for index := 2; index < len(slice); index++ {
    fmt.Printf("Index: %d Value: %d\n", index, slice[index])
}

输出结果为:

Index: 2 Value: 30
Index: 3 Value: 40

在前面的学习中我们了解了两个特殊的内置函数 len() 和 cap(),可以用于处理数组、切片和通道,对于切片,函数 len() 可以返回切片的长度,函数 cap() 可以返回切片的容量,在上面的示例中,使用到了函数 len() 来控制循环迭代的次数。

当然,range 关键字不仅仅可以用来遍历切片,它还可以用来遍历数组、字符串、map 或者通道等。

Go语言多维切片简述

Go语言中同样允许使用多维切片,声明一个多维数组的语法格式如下:

// sliceName 为切片的名字
// sliceType为切片的类型
// 每个[ ]代表着一个维度,切片有几个维度就需要几个[ ]
var sliceName [][]...[]sliceType

下面以二维切片为例,声明一个二维切片并赋值,代码如下所示:

//声明一个二维切片
var slice [][]int
//为二维切片赋值
slice = [][]int{{10}, {100, 200}}

上面的代码也可以简写为下面的样子:

// 声明一个二维整型切片并赋值
slice := [][]int{{10}, {100, 200}}

上面的代码中展示了一个包含两个元素的外层切片,同时每个元素包又含一个内层的整型切片,切片 slice 的值如下图所示:
请添加图片描述
通过上图可以看到外层的切片包括两个元素,每个元素都是一个切片,第一个元素中的切片使用单个整数 10 来初始化,第二个元素中的切片包括两个整数,即 100 和 200。

这种组合可以让用户创建非常复杂且强大的数据结构,前面介绍过的关于内置函数 append() 的规则也可以应用到组合后的切片上,如下所示:
【示例】组合切片的切片 :

// 声明一个二维整型切片并赋值
slice := [][]int{{10}, {100, 200}}
// 为第一个切片追加值为 20 的元素
slice[0] = append(slice[0], 20)

Go语言里使用 append() 函数处理追加的方式很简明,先增长切片,再将新的整型切片赋值给外层切片的第一个元素,当上面代码中的操作完成后,再将切片复制到外层切片的索引为 0 的元素,如下图所示:
请添加图片描述
即便是这么简单的多维切片,操作时也会涉及众多的布局和值,在函数间这样传递数据结构会很复杂,不过切片本身结构很简单,可以用很小的成本在函数间传递。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐