「Golang」不谈底层源码只谈使用,万字长文话说Golang的数组与切片

作者注:本篇所有代码采用go1.15+版本编写。

一、前言

之前一直在写一些同步原语和其他一些东西的的源代码分析,感觉写起来很麻烦,今天开始我决定写几期关于go中常用数据结构的解析、用法以及相关的可能会踩到的坑,正好也当作自己的知识体系巩固,今天开始第一个数据结构的解析—数组与切片。

二、数组

1、什么是数组

数组(Array)是有序的元素序列。 若将有限个类型相同的变量的集合命名,那么这个名称为数组名。组成数组的各个变量称为数组的分量,也称为数组的元素,有时也称为下标变量。用于区分数组的各个元素的数字编号称为下标。数组是在程序设计中,为了处理方便, 把具有相同类型的若干元素按有序的形式组织起来的一种形式。 这些有序排列的同类数据元素的集合称为数组。摘自百度百科【数组】

也许各位看官是专业的Go开发者,也许是其他语言转来Go的开发者,我想各位从开发程序哪天起,就没少接触数组这个数据结构。如果看官是C/C++开发者,我想对数组的接触就更多了。正如上面的信息所说,数组是一个有序的元素序列,这个元素的类型都是相同的(排除python,php等动态类型检查的语言除外),那么如何说数组是一个有序的元素序列呢?是其保存的数组都是从大到小或者从小到大排序的意思吗?No!有序指的是数组中的每个元素在内存地址中的顺序是相邻的,计算机会为数组分配一块连续的内存来保存其中的元素,我们可以利用数组中元素的索引快速访问特定元素,如下图所示,这是一个数组a,其有四个元素,这四个元素所占的内存位置分别是【0xc000016140,0xc000016148,0xc000016150,0xc000016158】,其间隔为8字节,为什么为8字节呢,因为int类型在64位计算机上的占用空间是8个字节,这就能体现出数组中所谓的有序元素序列的意义。
在这里插入图片描述

2、Go中的数组

(1)、数组的初始化

每个语言都有自己的数组的声明和定义方式,再次我就不在叙述了,我要给各位看官介绍的是在Go中的数组相关的声明以及定义的方式,如下代码:

	var a [4]int
	b := [4]int{1, 2, 3, 4}
	var c = [4]int{1, 2, 3, 4}
	d := [...]int{1, 2, 3, 4}

上面三种种方式都没什么问题,跟其他语言都大差不差,主要我们来看第三种,即定义数组d的时候,在[...]符号种间的 ... 是个什么玩意。
d := [...]int{1, 2, 3, 4}这种声明方式,会在编译期间将前面的[...]根据后面的元素个数(比如现在一共4个元素)自动推导成为[4]int{1, 2, 3, 4},也就是说在编译期间b := [4]int{1, 2, 3, 4}d := [...]int{1, 2, 3, 4}方式是等同的,只不过变量名是不相同的。

(2)、数组的元素获取

Go中针对数组元素的获取与其他语言也是相同的,均通过下标获取元素,下标的起始位置也仍然为0,但是要注意的是

与C/C++不同的是,虽然Go中数组的首地址即是数组下标为0的元素的地址,但是Go中无法像C/C++中使用数组的首地址进行++的操作,因为Go中不支持地址计算操作(使用unsafe包除外)。

数组中如果想获取一个数组的长度,可以使用内置函数len()来获取

func main() {
	var a [4]int
	a[0] = 5
	a[1] = 2
	a[2] = 5
	a[3] = 7
	// len 获取数组长度
	fmt.Println(len(a))
}
(3)数组的参数传递

在Go中,调用函数(方法)时,所有的操作均为传值操作,只不过区别是复制的是指针还是复制的是值本身,所以在对所调用的函数进行传递数组时,会将数组整个复制到函数内,而且在函数内部对形参数组的修改对外部不会产生影响,根据下面的代码以及产生的输出可以看到,数组的传递是复制整个数组的行为,在函数内对其修改对外部不会产生影响:


func ModifyArray(array [4]int) {
	fmt.Println("modify array before:", array)
	array[0] = 5
	array[1] = 2
	array[2] = 5
	array[3] = 7
	fmt.Println("modify array after:", array)
}

func main() {
	var a [4]int
	a[0] = 1
	a[1] = 2
	a[2] = 3
	a[3] = 4
	fmt.Println("call [ModifyArray] before =>", a)
	ModifyArray(a)
	fmt.Println("call [ModifyArray] after =>", a)
}
// out:
// call [ModifyArray] before => [1 2 3 4]
// modify array before: [1 2 3 4]
// modify array after: [5 2 5 7]
// call [ModifyArray] after => [1 2 3 4]

正是数组由于这种特性,当传递数组时如果数组过大,也会将整个数组复制到函数中,因此会产生很大的性能损耗和内存损耗。另外数组在声明时必须确定整个数组的容量并且无法在运行期间动态的修改,这导致有些时候的灵活性不足,因此Go为了解决这种问题,引入了另一种可以在运行时动态增减长度的数据结构,即切片,也可以称其为动态数组,其长度并不固定,我们可以向切片中追加元素,它会在容量不足时自动扩容。

三、切片

1、什么是切片

理论上来讲数组和切片在数据结构上是一种结构,都是顺序表结构,但是由于数组的固定长度特性,在有些时候对于需要动态的长度的使用情况很不友好,此时就需要利用切片进行对固定长度数组的代替,切片在官方的官方解释如下:
Slices wrap arrays to give a more general, powerful, and convenient interface to sequences of data. Except for items with explicit dimension such as transformation matrices, most array programming in Go is done with slices rather than simple arrays.
大概意思是:
切片是一个经过包装的数组,其可为数据序列提供更通用,更强大和更方便的接口。 除了具有明确维数的项(例如转换矩阵)外,Go中的大多数数组编程都是使用切片而不是简单数组完成的。

2、切片的初始化

切片的初始化方式与数组的初始化方式不尽相同,切片的初始化不需要指定其具体的长度:

	a := []int{1, 2, 3, 4}
	var b []int
	c := make([]int, 4, 10)
	d := make([]int, 5)
	e := c[2:3]

上述代码中,我们主要要理解的就是切片c,d,emake()是一个内置函数,其可以用来创建一个切片用来使用,其函数签名为:func make(t Type, size ...IntegerType) Type

  1. 一个参数传递的是一个可以用于make()函数使用的数据类型,目前能使用make()函数的类型只有slice,chan,map
  2. 第二个参数是切片的长度,即目前切片有多少个元素,参数是必须填写的,并且不能为负数,可以为零,代表当前切片长度为0,在运行时可以通过len()来获取当前切片的长度。
  3. 第三个参数为切片的容量,即当前切片最大可以存储多少个元素,该参数可以不传递,当该参数不传递的时候,使用第二个参数来作为第三个参数的默认值,如果传递该参数,则必须保证该参数的值大于等于第二个参数的值,在运行时可以通过cap()来获取当前切片的容量。

3、切片的结构

什么是切片一节说过,切片是一个经过包装的数组,那么这个切片被包装成了什么样子呢?下面的结构体是在reflect/value.go中的切片的结构体的一个运行期间表示的切片结构体(切片的最底层结构体是在编译期间被转换的),这个结构体可以清楚的展现出切片在底层是一个什么样子的结构体。

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

首先介绍一下这三个字段的用处:Data uintptr是一个指向底层数组的指针,这也体现出切片的底层其实实际上也是一个数组,Len int是一个用于标记当前切片元素个数的字段,可以理解为在初始化切片时make([]int, 4, 10)时的第二个参数,Cap int是用于标记当前切片的容量的字段可以理解为在初始化切片时make([]int, 4, 10)时的第三个参数。

4、切片的追加

切片与数组不同的一点就是可以在运行时动态的向切片内增加或者减少数据,首先我们先来说一下如何向切片中追加数据,请看如下代码:

func main() {
	a := make([]int, 0, 10)
	a = append(a, 1, 2, 3)
	fmt.Println(a)
}
// out:
// [1 2 3]

上述代码中,内置函数func append(slice []Type, elems ...Type) []Type就是用于向切片中追加元素的函数,其第二个参数是可变参数,可变参数的含义是可以根据需要传入0或多个值,上述代码中,make([]int, 0, 10)代表创建一个容量为10,目前元素为0个的切片,如果第二个参数设为大于0的值,则代表当前切片已经有了多少元素,这些元素将被默认的置为零值(数值类型的零值为0,字符串为“”,指针或者interface,map,chan等类型为nil),元素追加操作,会在当前切片元素长度len的后面增加对应数量的新元素,上述代码中追加之前长度为0,追加后长度为3,那么问题来了如果追加的长度超过了设置容量的数值时会怎样?这就引出了切片的另一个特性,动态扩容。

5、切片的扩容

当在调用append()对切片进行追加时,如果添加元素的个数加上原有切片长度大于原有容量的话就会触发扩容操作,扩容操作时,会根据以下的几个条件进行对扩容大小的选择:

  1. 当期望容量(即当前元素长度加上要添加的元素个数)大于当前容量的两倍时,按照期望容量的大小作为目标容量大小。
  2. 如果条件1不满足,并且当前切片长度小于1024时(在下一个大版本即1.16时这个条件会被改变为:当前容量小于1024时)目标容量大小为当前容量大小的两倍。
  3. 如果条件1,2都不满足则循环计算并增加目标容量,每次循环增加原有容量的25%,直至目标容量超过期望容量。

上述扩容条件只是针对长度与容量来做的计算,实际扩容比这更复杂, 还会考虑到根据切片的元素类型所占用的内存空间以及目标容量的大小做乘积计算后按照元素类型所占用空间为1字节,8字节或者2的倍数字节,做内存对齐操作并向上取整,也就是说最终切片的扩容大小不一定是上述三条中得出的目标容量(此处有些复杂就不再文章中详细表述了,如果有想了解的可以与作者联系一起交流学习)。

切片扩容后,原先切片的底层指向数组的指针会被替换为一个指向新的、长度足够的数组的指针,同时原有的数据也会被拷贝到新的数组地址中。

6、切片传递以及扩容带来的问题

当我们想把一个切片传入一个函数或方法中时,由于切片的特性,其复制的仅仅是这个切片的结构体,也就是在3中所讲的切片的结构,其指向数组的指针以及长度,容量均会被复制到函数内。所以在函数内对切片进行修改是会影响到外部切片的。事例代码如下:

func ModifySlice(slice []int) {
	slice[0] = 2
	slice[1] = 3
}

func main() {
	a := make([]int, 3, 5)
	fmt.Println("传入函数前的切片内容:", a)
	ModifySlice(a)
	fmt.Println("传入函数后的切片内容:", a)
}
// out:
// 传入函数前的切片内容: [0 0 0]
// 传入函数后的切片内容: [2 3 0]

从代码中可以看出,切片在传入函数后被修改对外部产生了影响,所以在使用切片作为参数传入函数时,一定要注意这一点。

有同学可能想问,如果在函数内部对切片进行追加会发生什么呢?请看下列代码:


func AppendSlice(slice []int) {
	slice = append(slice, 1)
	fmt.Println("函数内追加后的切片内容:", slice)
}

func main() {
	a := make([]int, 2, 5)
	fmt.Println("传入函数前的切片内容:", a)
	AppendSlice(a)
	fmt.Println("传入函数后的切片内容:", a)
}

// out:
// 传入函数前的切片内容: [0 0]
// 函数内修改完毕的切片内容: [0 0 1]
// 传入函数后的切片内容: [0 0]

诶?为什么会这样,说好的切片在函数内部对其修改会影响外部切片呢?请听我如实道来:

  1. 首先的确,切片传入函数内并对其修改的确会对外部产生影响,但是仅限于使用下标操作。
  2. 在函数内使用append()函数追加元素的确也会对外部切片产生影响,**但是!**在函数内append()之后,虽然在切片内部指向底层数组的指针的确也增加了新的元素,并且其切片的长度字段也增加了1,但问题是切片的长度字段是值复制,在传递切片的时候把整个切片结构体复制进来的时候,由于长度字段和容量字段都不是指针,所以他复制的只是传入那一刻的切片元素长度,由于是值复制,在函数内对这个长度字段进行修改就完全不会影响外部的切片,所以我们看到的结果就成了这个样子。
  3. 但是有人要问了,那到底这个元素是否被追加到切片了,答案是的确追加到了,但是由于外部切片的长度字段并没有改变,在输出的时候只是输出了从0到长度字段-1 这么多的元素,所以哪怕是被追加到了底层数组指针的对应位置,由于长度字段的值没有达到那个对应位置,因此就不能显示出那个位置的元素。

综上所述,切片传入函数中并且对齐进行append()操作时虽然的确增加进去了但是外部却无法显示,因此,不要在函数内对切片进行append()除非你传入的是一个指向切片的指针。

关于切片与函数内扩容,还有一个坑需要大家注意,请看下述代码:

func AppendSlice(slice []int) {
	slice = append(slice, 1)
	slice[0] = 1
	slice[1] = 2
	fmt.Println("函数内追加后的切片内容:", slice)
}

func main() {
	a := make([]int, 2, 2)
	fmt.Println("传入函数前的切片内容:", a)
	AppendSlice(a)
	fmt.Println("传入函数后的切片内容:", a)
}

// out:
// 传入函数前的切片内容: [0 0]
// 函数内修改完毕的切片内容: [1 2 1]
// 传入函数后的切片内容: [0 0]


有人会问这不科学,我知道了append()在函数内追加对外部切片没有影响,但是为啥使用下标修改还是没有影响?答案就在于append()的扩容操作,上面说过append()扩容操作会

将原先切片的底层指向数组的指针会被替换为一个指向新的、长度足够的数组的指针,同时原有的数据也会被拷贝到新的数组地址中

那么这就好解释为什么在上面的代码中在追加完之后对其下标修改还是对外部没有影响了,答案就是在此append()时由于原本的容量并不足以添加新的元素,所以产生了扩容,扩容就会将原有的底层数组切片被替换掉。既然被替换了也就代表跟外部的切片完全没有关系了,因此,上面代码的输出也就合情合理了。

总而言之,并不建议在函数内部对外部传入的切片进行任何的append()操作,因为有时候有可能会产生出各种意想不到的情况,如果必须在函数内进行append()操作的话,请传递指向切片的指针。

7、切片的截取复制

有时候我们可能需要把一个切片复制给另一个变量去做某些事情,此时我们就需要了解关于切片的复制,切片的复制有两种方式,一种是与原切片关联的截取,一种是与原切片无关的复制(这块是我自己起的名字,实在不知道该怎么称呼了)

1、与原切片有关的截取

go语言提供了一种切片的复截取方式,代码如下:

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

	sliceB := sliceA[1:3]

	fmt.Println("Slice A:", sliceA)
	fmt.Println("Slice B:", sliceB)
}
// out:
// Slice A: [1 2 3 4 5]
// Slice B: [2 3]

上述代码中,sliceB := sliceA[1:3]代表把sliceA切片中,下标从1开始到2的元素复制给sliceB,其中[x:y]的取值范围是一个左闭右开区间(数学不好应该是这样称呼吧),即: [ x : y ) [x:y) [x:y) 这个样子,如果x为不填代表从0开始到y-1的范围内的元素,如果y不填代表从x到切片len()-1的范围内的元素。

截取切片有一个可能会踩到的坑希望各位可以避开,请看如下代码:

func main() {
	sliceA := []int{1, 2, 3, 4, 5}
	sliceB := sliceA[1:3]
	fmt.Println("Slice A:", sliceA)
	fmt.Println("Slice B:", sliceB)
	sliceB[0] = 333
	fmt.Println("Modify Slice A:", sliceA)
	fmt.Println("Modify Slice B:", sliceB)
}

// out:
// Slice A: [1 2 3 4 5]
// Slice B: [2 3]
// Modify Slice A: [1 333 3 4 5]
// Modify Slice B: [333 3]

我对sliceB的下标为0的元素进行了赋值为333,但是为什么sliceA下标为1的元素也同时发生了改变呢?这就要从切片截取的截取方式来说明了。

切片的截取是在原切片的底层数组之中直接复制对应起始位置的指针和截取长度赋值给新的切片的,用上面的代码举例,实际上sliceBsliceA指向的是同一块的数组地址空间,只不过sliceB指向的是sliceA[1]的地址,我们打印一下地址就可以看出来:

func main() {
	sliceA := []int{1, 2, 3, 4, 5}
	sliceB := sliceA[1:3]
	fmt.Println("Slice A [1] addr:", &sliceA[1])
	fmt.Println("Slice B [0] addr:", &sliceB[0])

}
// out:
// Slice A [1] addr: 0xc000018128
// Slice B [0] addr: 0xc000018128

从上述代码的输出就可以看到sliceA[1]的地址和sliceB[0]的地址相同,也就是说切片的截取操作并不复制切片指向的元素。它创建一个新的切片并复用原来切片的底层数组。 这使得切片操作和数组索引一样高效。因此,通过一个新切片修改元素会影响到原始切片的对应元素。

但是虽然说对新的切片进行修改会影响原切片,但是有一种方式是例外的

func main() {
	sliceA := []int{1, 2, 3, 4, 5}
	sliceB := sliceA[1:3]
	sliceB = append(sliceB, 1, 2, 3, 4)
	fmt.Println("Append after slice A :", sliceA)
	fmt.Println("Append after slice B :", sliceB)
}

// out:
// Append after slice A : [1 2 3 4 5]
// Append after slice B : [2 3 1 2 3 4]

诶?这是为什么,为什么没有修改sliceA,答案是:

在切片截取时,新切片的容量是按照[x:y]截取时中cap(sliceA)-x的值计算的,也就是说在上述代码中,sliceB的容量是cap(sliceA)-1也就是5-1等于4,那么在下面进行append操作时,需要添加进去的4个元素加上原有的长度2已经超过了容量4,所以产生了扩容,上面扩容章节说了:当发生扩容时会产生一个新的底层数组并将原有的数据拷贝将原先切片的底层指向数组的指针会被替换为一个指向新的、长度足够的数组的指针,同时原有的数据也会被拷贝到新的数组地址中,由于变成了新的底层数组,所以对原有的sliceA切片也就没有影响了。

那么有人想问了,有没有一种方法能让我复制出的新切片在对其修改的时候,不影响原切片呢?答案是有的,go提供了一个内嵌函数copy()

2、与原切片无关的copy()

如果想把 sliceA完整的复制到sliceB并且在修改sliceB时不修改 sliceA,该怎么办呢?go提供了一个内嵌函数copy()

func copy(dst, src []Type) int

该函数接受两个切片作为参数并且返回复制了多少个元素,第一个参数是目标切片,第二个参数是原切片,即把第二个切片里面的值复制给第一个参数的切片。请看下面代码:

func main() {
	sliceA := []int{1, 2, 3, 4, 5}
	sliceB := make([]int, 2, 3)
	copy(sliceB, sliceA)
	fmt.Println("slice A :", sliceA)
	fmt.Println("slice B :", sliceB)
	sliceB[0] = 213123
	fmt.Println("modify slice A :", sliceA)
	fmt.Println("modify slice B :", sliceB)
	fmt.Println("slice A [0] addr:", &sliceA[0])
	fmt.Println("slice B [0] addr:", &sliceB[0])

}

// out:
// slice A : [1 2 3 4 5]
// slice B : [1 2]
// modify slice A : [1 2 3 4 5]
// modify slice B : [213123 2]
// slice A [0] addr: 0xc000018120
// slice B [0] addr: 0xc000016140

有上述代码可以看出,我复制了 sliceAsliceB,为什么只复制了前两个呢?因为copy()最终的复制长度,取决于目标切片当前的元素长度,**即 sliceA能够复制给 sliceB多少个值,取决于 len(sliceB)len(sliceA)哪个比较小。**而复制操作是从下标0开始往后复制,直到复制到len(sliceB)len(sliceA)最小的那个值的地方停止(这里可能没太说清楚,自己尝试一下就能了解了)。

在上述代码中,对复制完的sliceB进行了修改,修改后发现并没有影响sliceA,然后打印其地址发现并不相同,因为两者并不是引用的同一个底层数组。

三、总结

这篇文章写了大约八个小时,因为第一次写这么长的东西,所以有些东西可能解释的不太清楚或者表述的不太明白,希望大家可以见谅,另外如果文中哪里有说错的,请各位批评指正,如果能帮到各位,是我的荣幸。

  • 4
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值