本文始发于个人公众号:TechFlow,原创不易,求个关注
今天是golang专题的第五篇,这一篇我们将会了解golang中的数组和切片的使用。
数组与切片
golang当中数组和C++中的定义类似,除了变量类型写在后面。
比如我们要声明一个长度为10的int型的数组,会写成这样:
var a [10]int
数组的长度定义了之后不能改变,这点和C++以及Java是一样的。但是在我们日常使用的过程当中,除非我们非常确定数组长度不会发生变化,否则我们一般不会使用数组,而是使用切片(slice)。
切片有些像是数组的引用,它的大小可以是动态的,因此更加灵活。所以在我们日常的使用当中,比数组应用更广。
切片的声明源于数组,和Python中的list切片类似,我们通过指定左右区间的范围来声明一个切片。这里的范围和Python一样,左闭右开。我们来看个例子:
var a [10]int
var s []int = a[0:4]
这是标准的声明写法,我们也可以不用var来声明,而是直接利用数组给切片赋值,比如上面的语句可以写成这样:
s := a[:4]
在Python当中,当我们使用切片的时候,解释器会为我们将切片对应的数据复制一份。所以切片之后和之前的结果是不同的,但是golang当中则不同。切片和数据对应的是同一份数据,切片只是数组的一个引用,如果原数组的数据发生变化,那么会连带着切片中的数据一起变化。
还是刚才那个例子:
var a [10]int
var s []int = a[0:4]
fmt.Println(s)
这样我们输出得到的结果是[0 0 0 0],因为数组初始化默认值为0。而假如我们修改一个a中的元素,我们再来打印s,得到的结果就不同了:
var a [10]int
var s []int = a[0:4]
a[0] = 4
fmt.Println(s)
这样得到的结果就是[4 0 0 0],虽然我们并没有修改s当中的数据,由于s本质是a的引用,所以a中发生变化会连带着s一起变化。
进阶用法
前面说了,因为切片比数组更加方便,所以我们日常使用当中都倾向于使用切片,而不是数组。但是根据目前的语法,切片都是从数组当中产生的,这岂不是意味着,我们如果想要使用切片,必须先要创建出一个对应的数组来吗?
golang的设计者考虑到了这个问题,为了方便我们的使用,golang设计了直接定义切片的方法。
这是一个数组的声明,我们固定了数组的长度,并且用指定的元素对它进行了初始化。
var a = [3]int{0, 1, 2}
如果我们去掉长度的声明,那么它就成了一个切片的声明:
var a = []int{0, 1, 2}
这样是同样可以运行的,在golang的内部下面的语句同样创建了数组,我们获取的a是这个数组的一个切片。但是这个数组对我们是不可见的,golang编译器替我们省略了这个逻辑。
长度和容量
理解了切片和数组之间的关系之后,我们就可以来看它的长度和容量这两个概念了。
这个单词的英文分别是length和capability,长度指的是切片本身包含的元素的个数,而容量则是切片对应的数组从开始到末尾包含的元素个数。我们可以用len操作来获取切片的长度,用cap操作来获取它的容量。
我们来看一个例子,首先我们创建一个切片,然后写一个函数来打印出一个切片的长度和容量:
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5, 6}
printSlice(s)
}
func printSlice(s []int) {
fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}
当我们运行之后得到的结果是这样的:
这个和我的预期应该是一致的,我们创建出了6个元素的切片,自然它的容量和长度应该都是6,但接下来的操作可能就会有点出入了。
我们对这个切片再进行切片,继续输出切片之后的容量和长度:
s = s[:2]
printSlice(s)
运行之后会得到下面这个结果:
我们发现它的长度变成了2,但是容量还是6,这个也不是特别难理解。因为虽然当前的切片长度变小了,但是它对应的数组并没有任何变化,所以它的容量应该还是6。
我们继续,我们继续切片:
s := []int{1, 2, 3, 4, 5, 6}
s = s[:2]
s = s[:4]
printSlice(s)
得到这样的结果:
事情开始有点不一样了,比较令人关注的点有两个。一个是s在之前切片结束之后的结果长度是2,但是我们居然可以对它切片到下标4的位置。这说明我们在执行切片的时候,执行的对象并不是切片本身,而是切片背后对应的数组。这一点非常重要,如果不能理解这点,那么切片的很多操作看起来都会觉得匪夷所思难以理解。
第二个点是切片的容量依然没有发生变化,这样不会发生变化,那么我们再换一种切片的方法试试,看看会不会有什么不同。
s = s[2:]
printSlice(s)
这一次得到的结果就不同了,它是这样的:
这一次发生变化了,切片的容量变成了4,也就是说变小了,这是为什么呢?
原因很简单,因为数组的头指针的位置移动了。数组原本的长度是6,往右移动了两位,剩下的长度自然就是4了。但是剩下的问题是,为什么数组的头指针会移动呢?
因为数组的头指针和切片的位置是挂钩的,我们前面的切片操作虽然会改变切片中的元素和它的长度,但是都没有改变切片指针的位置。而这一次我们进行的切片是[2:],当我们执行这个操作的时候,本质上是指针的位置向右移动到了2。
这也是为什么切片的容量定义是它对应的数组从开始到末尾元素的个数,而不是对应的数组元素的个数。因为指针向右移动会改变容量的大小,但是数组本身的长度是没有变化的。
我们来看个例子就明白了:
var a = [6]int{1, 2, 3, 4, 5, 6}
s := a[:]
//printSlice(s)
s = s[:2]
printSlice(s)
s = s[2:]
printSlice(s)
//s[0] = 4
fmt.Println(a)
我们这一次使用显性的切片,我们对s进行一系列切片之后,它的容量变成了4,但是a当中的元素个数还是6,并没有变化。所以不能简单将容量理解成数组的长度,而是切片位置到数组末尾的长度。因为切片操作会改变切片指针的位置,从而改变容量,但是数组的大小是没有变化的。
make操作
一般在我们使用切片的时候,我们都是把它当做动态数组用的,也就是Python中的list。所以我们一方面不希望关心切片背后数组,另一方面希望能够有一个区分度较大的构造方法,和创建数组做一个鲜明的区分。
所以基于以上考虑,golang当中为我们提供了一个make方法,可以用来创建切片。由于make还可以用来创建其他的类型,比如map,所以我们在使用make的时候,需要传入我们想要创建的变量类型。这里我们想要创建的是切片,所以我们要传入切片的类型,也就是[]int,或者是[]float等等。之后,我们需要传入切片的长度和容量。
比如:
s := make([]int, 0, 5)
我们就得到了一个长度为0,容量是5的切片。我们也可以只传入一个参数,如果只传入一个参数的话,表示切片的长度和容量相等。
像是这样:
s := make([]int, 5)
我们如果打印这个s的话,会得到[0 0 0 0 0],也就是说golang会为我们给切片填充零值。
append方法
前面说了和数组比起来切片的使用更加灵活,意味着切片的长度是可变的,我们可以通过使用append方法向切片当中追加元素。
golang中的append方法和Python已经其他语言不同,golang中的append方法需要传入两个参数,一个是切片本身,另一个是需要添加的元素,最后会返回一个切片。
所以我们应该写成这样:
s := make([]int, 4)
s = append(s, 4)
这么做的目的也很简单,因为切片的长度是动态的,也就意味着切片对应的数组的长度也是可变的,至少是可能增大的。如果当前的数组容量不足以存储切片的时候,golang会分配一个更大的数组,这时候会返回一个指向新数组的切片。也就是说由于切片底层实现机制的关系,导致了append方法不能做成inplace的,所以必须要进行返回。我猜,这也是由于性能的考虑。
二维切片
最后我们来看看二维切片在golang当中应该怎么实现,只能要能理解二维,拓展到多维也是一样。
golang创造二维切片的方式和C++创建二维的vector有些类似,我们一开始先直接定义一个二维的切片,然后用循环往里面填充。我们定义二维切片的方法和一维的切片类似,只是多了一个方括号而已,之后我们用循环往其中填充若干个一维切片:
mat := make([][]int, 10)
for i := 0; i < 10; i++ {
mat[i] = make([]int, 10)
}
结尾
到这里,golang中关于数组和切片的常见的用法就介绍完了。不仅如此,关于切片底层的实现原理,我们也有了一点浅薄的理解。刚开始接触切片这个概念的时候可能会觉得有点怪,总觉得好像和我们之前学习的语言对不上号,关于容量的概念也不太容易理解,这个是非常正常的,本质上来说,这一切看起来不太正常或者是不太舒服的地方,背后都有创作者的思考,以及为了性能的权衡。所以,如果你觉得想不通的话,可以多往这个方面思考,也许会有不一样的收获。
今天的文章就到这里,原创不易,扫码关注我,获取更多精彩文章。