在前面的文章中,我和大家一起学习了一下关于 Go 语言中数组的知识,当时有提到过一个知识点:在函数中传递数组是非常耗资源的一件事,所以更推荐大家使用切片(slice)来这么做。
那么切片又是一个怎样的东西呢?看完这篇文章你就知道了!
上一篇传送门:Go by Example-图解数组
初识切片
切片(slice)也是一种数据结构,它和数组非常相似,但它是围绕动态数组的概念设计的,可以按需自动改变大小。
切片的动态增长是通过内置函数 append 来实现的,这个函数可以快速且高效地增长切片。
使用切片后,不仅可以更方便地管理和使用数据集合,还可以在切片的基础上继续使用切片来缩小一个切片的长度范围。
因为切片的底层就是一个数组,所以切片和数组的一些操作类似。比如:获得切片索引、迭代切片等。
切片的对象非常小,它是一个只有 3 个字段的数据结构,分别是:
1.指向底层数组的指针2.切片的长度3.切片的容量
了解了一下切片的好处和特性之后,我们再来看看如何创建切片吧。
创建切片
创建切片的方式有两种,一种是使用内置的 make 函数,一种是使用切片的字面量。
下面我们来看一下这两种创建方式的使用方法。
使用 make 创建
在创建之前我们先简单地了解一下 make 函数的作用。
make 只能应用于三种数据类型:本文中的 slice、以及后面要说的 map 和 chan。
make 会为它们分配内存、初始化一个对应类型的对象并返回。注意,返回值的类型依然是被 make 的那个类型,make 只对其做了一个引用。
make 主要是用来做初始化的,记住这点,这在后面的学习中非常重要(但不是本章的后面)。
使用长度和容量声明整型切片
slice := make([]int, 4, 5)
在使用 make 创建切片的时候,一共可以传入三个参数:
1.声明一个 int 类型的切片2.指定切片的长度3.指定切片的容量,也就是底层数组的长度
由于切片可以按需改变大小,所以在声明类型的时候并不需要指定长度(即
[123]int
这种写法),也不需要让编译器自己去推断数组的大小(即[...]int
这种写法)。所谓的容量其实就是切片可以增加到的最大长度。如果基于这个切片创建新的切片,新切片会和原有切片共享底层数组,也能通过后期操作来访问多余容量的元素。
长度和容量一样的情况
如果你在使用 make 函数创建切片的时候只使用了两个参数的话,那么这个切片的长度和容量将会是一样的。
例如这里我声明了一个 string 类型的切片,并将它的长度和容量都设置为 6。
slice := make([]string, 6)
注意:切片的容量不能小于其长度
slice: = make([]int, 3, 1)
这行代码在编译的时候是会报错的,因为切片的容量不能小于长度,需要注意。
通过字面量创建
// 创建一个字符串切片并赋值,其长度和容量都是5。slice := []string {"I", "Love", "My", "祖", "国"}// 创建一个整型切片并赋值,其长度和容量都是3。slice := []int {10, 20, 30}
另外,在使用切片字面量时,我们可以设置初始长度和容量。
// 创建一个整形切片,使用整形数字 8 初始化第 10 个元素slice := []int {9: 8}// 注:上面的这个 9 代表下标 9,也就是第 10 个元素
上面的代码表示声明一个长度和容量都为 10 的数组,并把第 10 个元素的值设定为 8,其他位置的元素此时因为没有赋值,所以是对应类型的零值。这里的话因为声明类型是 int 的关系,所以是一个 int 值的 0。
用哪种方式创建呢?
这个主要得看在声明切片的时候,是否知道里面部分元素的值。
如果不知道的话,不管使用哪种方式都可以。
但如果想在声明的时候顺带给元素赋值,那么就可以选择使用字面量的方式。
为什么切片会和底层数组有关系呢?
这个实际上和它的数据结构声明是有关系的,切片实际上是一个结构体类型的数据结构,看一下切片类型的源码就知道了:
type slice struct { array unsafe.Pointer // 底层数组 len int cap int}
不过由于本文主要说的是切片的关系,结构体等内容就先暂且不谈了,现在我们只需要知道切片底层有个数组就可以了。
图解切片结构
下面通过一张图来对切片做一个直观的理解,就拿下面这个例子来说:
slice := make([]int, 4, 5)slice[2] = 9
把它画成图的话就是这个样子的:
因为切片存的只是指向底层数组的指针而已,所以切片占用的内寸空间是很小的。
我们来做个简单的计算,我的电脑是 64 位的,unsafe.Pointer 类型变量占用 8 个字节,int 类型变量占用 8 个字节,所以算起来这个切片所占的内存空间大小只有 8 + 8 * 2 = 24 字节,非常小。
操作切片
通过切片创建切片
除了上面的方式以外,我们还可以使用切片来创建切片。
// 创建一个整型切片,其长度和容量都是 5 个元素slice := []int{1, 2, 3, 4, 5}// 创建一个新切片,其长度为 2 个元素,容量为 4 个元素newSlice := slice[1:3]
执行完这段操作以后,我们就有两个切片了,它们共享一个底层数组,我们通过一个图来帮助理解一下这个过程。
新切片的下标 [0]
对应的实际是底层数组的下标 [1]
。
如果没有限定容量的大小,那么可以得知:
•新切片的长度为 3-1=2。•新切片的容量为 5-1=4。(这个5代表原来切片总长度)
说到容量,还需要注意一点,它一般只是用来增加切片长度用的,我们无法通过下标去取里面的内容。
例如:
package mainimport "fmt"func main () { slice := make([]int, 4, 5) newSlice := slice[1:3] fmt.Println(newSlice[1]) fmt.Println(newSlice[2])}
运行结果会是:
panic: runtime error: index out of range
使用切片创建新的切片的时候,实际上一共是有三个参数的,前面我们已经使用了两个,现在我们来看看第三个参数。
第三个参数是用来限定新的切片的最大容量的,这个最大容量计算是从索引位置开始,加上希望容量中包含的元素的个数得到的。
举个例子:
// 创建一个整型切片,其长度和容量都是 5 个元素slice := []int{1, 2, 3, 4, 5}// 创建一个新切片,其长度为 3-1=2 个元素,容量为 4-1=3 个元素newSlice := slice[1:3:4]
这里我们要获取的新切片要求是从底层数组索引 1 的位置开始,然后取其后面最多 3 个数。
执行后得到的就是我们要写的第三个参数:4。
此时如果用图表示的话是这个样子:
灰色部分是新切片不能拓展到的部分,原因很简单,因为我们把最大容量设置为了 3。
注意:如果设置的容量比可用的容量还大,就会得到一个语言运行时错误。
nil 切片
在声明切片时不做任何初始化,就会创建一个 nil 切片,nil 切片可以用于很多标准库和内置函数。
// 创建 nil 整型切片var slice [] int
nil 切片长度为 0,容量也为 0。
那么这个东西有什么用呢?
比如说我们调用了一个函数,我们希望它返回一个切片,但是运行期间发生了异常,这个时候我们就可以通过判断结果是否为 nil,得知程序是否出现异常了。
切片赋值
对切片赋值就很简单了,直接通过它的下标进行赋值即可。举个例子:
slice := []int{10, 20, 30}slice[1] = 3
得到结果如下:
[10,3,30]
注意:赋值的时候使用的索引,不能超过切片的最大索引,也就是切片的长度 - 1。
如果你需要给通过切片创建的切片进行赋值,那么你需要注意了!前面说过新旧切片是共享同一个底层数组的,而修改切片的值实际上是在修改底层数组的值,所以这就产生了一个问题,如果对新的切片赋值,那么旧的切片的值也会发生变化。
举个例子:
package mainimport "fmt"func main () { slice := make ([]int, 4, 5) newSlice := slice[1:3] fmt.Printf("旧切片赋值之前:% d\n", newSlice) fmt.Printf("新切片赋值之前:% d\n", slice) newSlice[1] = 666 fmt.Printf("旧切片赋值之前:% d\n", slice) fmt.Printf("新切片赋值之前:% d\n", newSlice)}
得到结果如下:
新切片赋值之前:[0 0]旧切片赋值之前:[0 0 0 0]新切片赋值之后:[0 0 666 0]旧切片赋值之后:[0 666]
看到了吗?旧切片的值也被改变了!那么同理,如果对旧切片赋值,新切片也是会发生变化,这个就不做演示了,和上面这个结果类似。
切片增长
Go 语言内置的 append 函数可以增加切片的长度,使用 append 需要一个被操作的切片和一个要追加的值。
append 必定会增加新切片的长度,而切片容量的变化则取决于被操作的切片的可用容量,可增长可不增长。
简单来说,如果 append 操作完之后,切片内的元素个数不大于容量数,那么新的切片就不增加容量。同样,此时的新切片还是和之前的切片共享同一个底层数组。但是这种做法我并不建议你使用!
举个例子:
package mainimport "fmt"func main() { slice := []int{1,2,3,4,5} newSlice := slice[1:3:4] fmt.Printf("新切片之前元素:% d\n", newSlice) fmt.Printf("旧切片之前元素:% d\n", slice) newSlice = append(newSlice, 6) fmt.Printf("旧切片增加之后元素:% d\n", slice) fmt.Printf("新切片此时元素:% d\n", newSlice)
得到结果如下:
新切片赋值之前:[2 3]旧切片赋值之前:[1 2 3 4 5]旧切片增加之后元素:[1 2 3 6 5]新切片此时元素:[2 3 6]
让我们通过下面这张图来分析一下,上面都发生了什么。
看图可以发现,我们在增加新切片的元素的时候,无意中修改了底层的数组元素,这导致旧的切片的值也发生了变化,所以我更推荐你使用下面这种方式对新的切片进行元素的增加:
还记得前面说的:“使用切片创建切片的时候,可以使用第三个参数限制其最大容量”么?
这里我们要说的就是使用 append 之后,新的切片容量增大的情况。
如果切片的底层数组没有足够的可用容量,append 函数会创建一个新的底层数组,然后将被引用的现有的值复制到新数组里,再追加新的值。
也就是说,这种情况新的切片可以单独享用底层的数组,这样的话即使你修改了新切片的值,对旧的切片也不会造成任何影响,因为它们不再共享同一个底层数组。
举个例子:
slice := []int{1, 2, 3, 4, 6}newSlice := append(slice, 6)
函数 append 会智能地处理底层数组的容量增长,在切片的容量小于 1000 个元素时,它总是会成倍地增加容量。一旦元素个数超过 1000,容量的增长倍数就会被设为 1.25,也就是说会每次增加 25% 的容量。
append 除了可以添加元素以外,还可以在一个切片中追加另一个切片。只需要通过 ...
来将第二个切片的元素做一个拆分就行了,这里的 ...
就类似于 Python 里的解包操作。
s1 := []int {1, 2}s2 := []int {3, 4}fmt.Printf("% v\n", append(s1, s2...))输出:[1 2 3 4]
切片迭代
对了,切片还可以像数组那样去迭代,只需要这样就行了:
slice := []int {1, 2, 3, 4}for index, value := range slice { fmt.Printf("Index: % d Value: % d\n", index, value)}
在函数间传递切片
切片在函数间进行传递的时候,只是复制了切片的本身,不会涉及底层的数组。
前面我们计算了一下,在 64 位的机器上,切片仅占用了 24 个字节,而在函数间传递 24 字节的数据是非常简单、快速的事情。
这正是切片效率高的地方,不需要传递指针,也不需要处理复杂的语法,只需要复制切片,按想要的方式修改数据,然后传递回一份新的切片副本就可以了,非常的简单快捷。
参考资料
•https://www.jb51.net/article/126703.htm•https://www.cnblogs.com/chenpingzhao/p/9918062.html•https://blog.csdn.net/qq_19018277/article/details/100578553•http://www.meirixz.com/archives/80658.html
文章作者:「夜幕团队 NightTeam」 - 陈祥安
润色、校对:「夜幕团队 NightTeam」 - Loco
夜幕团队成立于 2019 年,团队包括崔庆才、周子淇、陈祥安、唐轶飞、冯威、蔡晋、戴煌金、张冶青和韦世东。
涉猎的编程语言包括但不限于 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发、对象存储等。团队非正亦非邪,只做认为对的事情,请大家小心。