Go的奇特之处2--Go语言的slice

Go语言中的slice,你可以将它简单地看作一个动态数组。动态数组在很多语言中都有实现,比如C++中的vector<>,Java中的Array<>,Python中的[]。那为什么要说Go语言的slice奇怪?首先来回顾一下slice的基本知识:

slice由三部分组成:首地址、长度len、容量cap

那么,根据slice的这些特性,slice有啥奇特的呢?
首先,举个例子:

1
2
3
4
5
6
7
8
9
func main() {
a := []int{1, 2, 3}
process(a)
fmt.Println(a)
}

func process(a []int) {
a[0] = 100
}

对Go语言有些了解的人都知道,Go语言的slice传参虽然都是值传递,但是会把首地址也一起传过去,所以上面代码的输出结果是这样的:

1
[100 2 3]

接下来,可以想想以下代码的输出是什么:

1
2
3
4
5
6
7
8
9
func main() {
a := []int{1, 2, 3}
process(a)
fmt.Println(a)
}

func process(a []int) {
a = append(a, 4)
}

可能对Go有些了解的程序员会想到:append操作时,如果cap不够,则会申请一个新地址,在process()中的a的地址已经变了,所以返回的是:

1
[1 2 3]

结果不错,那再看一个例子:

1
2
3
4
5
6
7
8
9
10
func main() {
a := make([]int, 3, 4)
a[0], a[1], a[2] = 1, 2, 3
process(a)
fmt.Println(a)
}

func process(a []int) {
a = append(a, 4)
}

按照上面的思路,如果cap够的话,append不会申请新地址,那么上面的代码会返回[1 2 3 4],但很遗憾,以上代码的输出是这样的:

1
[1 2 3]

虽然fmt.Println将数组a打印出来了,但是fmt.Println实际上打印的是在数组a长度范围内的元素。换句话说,并没有打印在len之外但在cap之内的元素。并且,这些元素也是不能够通过下标访问的,如果写a[3]会直接在编译时报错:panic: 数组越界
其实,4已经写到了数组a中,那既然不能通过下标访问,怎样才能访问到呢?这就是Go语言一个奇特之处,可以通过对slice进行切片操作访问到:

1
2
3
4
5
6
7
8
9
10
func main() {
a := make([]int, 3, 4)
a[0], a[1], a[2] = 1, 2, 3
process(a)
fmt.Println("a[3]: ", a[3:4][0])
}

func process(a []int) {
a = append(a, 4)
}

以上代码的输出:

1
a[3]:  4

当然,如果是不通过函数的方式对数组a进行append操作,alen也会随着其实际长度做出改变,比如:

1
2
3
4
5
6
func main() {
a := make([]int, 3, 4)
a[0], a[1], a[2] = 1, 2, 3
a = append(a, 4)
fmt.Println("a: ", a, ", len:", len(a), ", cap:", cap(a))
}

输出就是预期的这样:

1
a:  [1 2 3 4] , len: 4 , cap: 4

这就是Go语言slice的一个十分奇特的地方,对于slice来说,除了长度len以外,还有容量cap,并且超出len并在cap内的元素不能通过下标的方式访问,只能通过对slice进行切片操作来访问。在上面的几个例子中,process函数接收到了数组a的首地址、长度len和容量cap,由于是值传递,process只能改变地址上的值,对于alencap就无能为力了,所以会出现修改了a中超出len但在cap中的值的情况。

Go语言的slice还有一个神奇的地方在于其append方法。上文说过,往一个数组中append一个元素时,如果超出了其cap,则会生成一个新的slice。奇特的地方在于,新生成slice的cap会比其len大。举个例子:

1
2
3
4
5
a := []int{1, 2, 3}
fmt.Printf("addressBefore: %p\n", a)
a = append(a, 4)
fmt.Printf("addressAfter: %p\n", a)
fmt.Println("len: ", len(a), ", cap: ", cap(a))

以上代码的输出为:

1
2
3
addressBefore: 0xc0000123c0
addressAfter: 0xc00000e3c0
len: 4 , cap: 6

由于新生成slice的caplen大,于是再往其中append一个新元素时,数组的首地址便不会变。这个特性很重要,因为在写递归的时候很容易忽略这个特性,造成结果出错,例如:

1
2
3
4
5
6
7
8
9
10
// path := []int{}
func dfs(path, nums []int, res [][]int, start int) {
if len(path) > 0 {
res = append(res, path)
}
//...skipped
for i:=start; i<len(nums); i++ {
dfs(append(path, nums[i]), nums, res, i+1)
}
}

以上这段代码是典型的dfs代码,我之前为了不写回退过程,打算利用append生成新slice的特性,重新生成一个slice。感觉逻辑上没啥问题,结果却是错的。在了解了append的特性后就很容易知道原因:以上代码中的path可能在append时由于caplen大,没有生成新的slice,造成结果错误。所以,用Go语言写dfs或者类似的递归调用的时候,要么事先声明足够的cap,然后老老实实地写回退:

1
2
3
4
5
6
7
8
9
10
11
12
// path := make([]int, 0, size)
func dfs(path, nums []int, res [][]int, start int) {
if len(path) > 0 {
res = append(res, path)
}
//...skipped
for i:=start; i<len(nums); i++ {
path = append(path, nums[i])
dfs(path, nums, res, i+1)
path = path[:len(path)-1]
}
}

要么调用copy方法拷贝一份后,再加入到res中:

1
2
3
4
5
6
7
8
9
10
11
12
// path := []int{}
func dfs(path, nums []int, res [][]int, start int) {
if len(path) > 0 {
tmp := make([]int, len(path))
copy(tmp, path)
res = append(res, tmp)
}
//...skipped
for i:=start; i<len(nums); i++ {
dfs(append(path, nums[i]), nums, res, i+1)
}
}

以上就是Go语言slice中一些比较奇特并且值得注意的地方,slice的设计初衷感觉就是为了让大家不要写诸如*[]int这样的代码(这种代码看到过很多),所以了解slice的特性,有利于我们日常优雅地搬砖。

分享到