这是golang系列的第一篇, 简单介绍一下golang的数组和切片(也可能不那么简单).
简介
golang中的数组类似于C中的定长数组, 需要在使用时指定数组的长度. 如
var a [2]int 是一个长度为2的数组, a是变量名, 2是长度, int是数组元素类型.
但是这种定长的数组不便于使用, 因此, golang中提供了变长的slice供我们使用, 熟悉cpp的同学可能会很快意识到, slice和vector非常相似.
var a []int 定义了一个slice, []int是slice的类型, 其中int是slice种元素的类型. slice的长度可以通过len(a)方式获取, 表示slice中元素的数量.
由于切片的使用面更广, 除了特别声明, 在本文之外的地方, 数组一词也指slice.
数据结构
数组表达一段连续的相同结构的元素, 这在各种编程语言中基本相同, 在这里也不例外.
array因为长度在编译器确定, 因此可以为array分配一段固定的内存空间, 对超过范围的元素的请求也会在编译器被发现并报错. 同时, var a [2]int 这种写法直接获得了对应的空间, 不需要做额外的申请空间操作, 这点和切片有所不同.
slice的底层也是一段连续的空间, 但是因为slice的大小可变, 因此当原本的空间不够时, 需要申请新的空间, 并将原有的数据复制到新的空间. 每次向slice中添加元素都重新申请一次空间显然是非常低效的, 这里go使用了和vector相似的策略, 先多申请一部分空间, 直到空间不够用了再申请下一次. 这里go的实现是, 当slice的空间不超过1024时, 每当空间满, 都会申请两倍的当前空间的内存, 并且把现有的数据移动到新的位置(如果需要的话).
一些容易混淆的概念
零切片,空切片,nil切片
nil切片很好理解,就是声明了还没来得及定义的切片,我们知道,在go中,一个变量在被生命出来时是对应的零值,比如基本数值类型就是0,引用和struct就是nil,字符串是"",于是,直接使用var定义并且没有在定义时赋值的切片就是nil切片。
空切片指已经赋值,但是其长度为0的切片。我们经常使用make来初始化一个切片,如果初始化的时候定义长度为0,那么这个切片就是一个空切片。例如 a := make([]int, 0)。我们也可以使用make的第三个参数来定义底层的容量,但只要长度为0,这就是一个空切片。
空切片和nil切片的使用基本相同,二者都可以正常使用append方法添加元素,a = append(a, newItem),都能正常返回一个长度为1的slice。不同点有两个,1是和nil的比较,a == nil在nil切片上为true,在空切片上为false,因为空切片已经有了赋值语句。另一个是,在json编码的时候,空切片会被编码成"[]",而nil切片会被编码成nil或者null。
零切片是长度不为0,但是元素都是0的切片,常见于使用make定义特定长度的切片。
理解slice
slice结构
运行时的slice结构如下:
// SliceHeader is the runtime representation of a slice.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
这里的uintptr可以先理解为一个unsafe.Pointer,关于相应的引用和垃圾回收的内容我们稍后再说。
我们看到,slice本质上是一个struct,而在go中,默认传参是传值的,这对理解slice的一些行为至关重要。
func a() {
x := []int{1,2,3}
b(x)
}
func b(x []int) {
x = append(x, 4, 5, 6)
}
我们知道,在向切片中加入一个元素时,一定对应了Len和Data指向的内容的变化,可能也伴随着Cap的变化,当我们使用slice作为一个参数调用一个函数时,其实是复制了slice这个结构的一个副本参与计算,因为这个副本中的指针和原来的指针指向的是同一个地址,因此可以认为在被调函数里修改slice中元素的内容和在原函数里修改的效果相同。这也是为什么说slice是引用类型。但是不同的是,一旦在目标函数中出现了元素的数量变化,或者发生了扩容导致底层数据迁移,这部分是无法反映到调用方的。因此,如果在调用的时候对元素数量有调整,需要把调整过的数据返回回来,并覆盖原来的变量。这也是为什么slice的append方法要使用 a = append(a, 0)这种写法。
这里有个问题是为什么不直接在slice这个类上添加一个方法
func (\*slice) append(a int) {},这里我的想法是,一旦需要修改slice的内容,就需要指针接收器,需要传参的时候传递指针,写起来颇为不便。
进一步地,我们注意到,如果在被调用处发生了扩容,那么被调用处的slice的Data指针会指向新的底层数组,但是原函数的slice的data指针仍旧指向旧的底层数组。在调用返回后,原slice的容量和底层地址都不变,而在被调函数中申请的空间由于不再有指针指向,会被gc回收掉。
并发
由于没有并发检验,对数组和slice的并发读写都不会报错(仅指不会panic,不保证数据正常),特别地,对于slice,如果有多个routine同时尝试对一个slice进行append,其实可以理解为多个routine尝试先读再写slice,类似于自增操作。具体的结果和运行时状态及调度有关。
应用
排序
go内部封装了排序方法,在sort包中,最简单的int数组的排序可以直接调用 sort.Ints(a),会排成从小到大的顺序。但并不是stable的。
如果需要自定义大小关系,还可以使用sort.Slice(a, func Less(i, j int)bool{}), 这里也是会排成从小到大的顺序,只是这里的大小被重新定义了,如果你把大说成小,那排出来的就是从大到小的。
堆
堆也是数据结构中常用的概念,在go中,堆的底层数据结构可以使用数组。
堆是一个interface,如果一个数据结构同时满足可排序(sort.Interface)并且含有Push和Pop两个方法,就可以成为一个堆。
最简单的堆结构可以这么表示
type MyHeap struct {
sort.IntSlice
}
func (h *MyHeap) Push(a interface{}) {
h.IntSlice = append(h.IntSlice, a.(int))
}
func (h *MyHeap) Pop() interface{} {
last := h.IntSlice[h.Len()-1]
h.IntSlice = h.IntSlice[:h.Len()-1]
return last
}
这里几个要注意的点,一个是因为要改内部数据,所以Push和Pop都是使用的指针接收器,第二是因为使用了匿名成员,在使用的时候直接使用类名,第三个是,这里的Push和Pop都不是我们在使用堆的时候的操作,我们在向堆中存取数据时,使用的是heap.Push(myHeap, data) 和 data = heap.Pop(myHeap).(type)这两个方法。
其他
以后想到再写。