今天我们说说Go为数不多的一个“坑”。这个“坑”的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
arr1 := [5]int{1, 2, 3, 4, 5}
slice1 := arr1[1:2]
slice1 = append(slice1, 6, 7, 8)
fmt.Println("slice1:", slice1)
fmt.Println("arr1:", arr1)

arr2 := [5]int{1, 2, 3, 4, 5}
slice2 := arr2[1:3]
slice2 = append(slice2, 6, 7, 8)
fmt.Println("slice2:", slice2)
fmt.Println("arr2:", arr2)
}

上面代码输出结果是:

1
2
3
4
5
$ go run sliceTrap.go 
slice1: [2 6 7 8]
arr1: [1 2 6 7 8] //神奇地,原数组被改变了
slice2: [2 3 6 7 8]
arr2: [1 2 3 4 5] //一切正常

从上结果发现,原来的arr1的值被“莫名”改变了,但arr2的值并没有改变,这貌似并不符合我们的预期。当然,这是Go代码的一个“坑”,我们写代码时需要特别注意避免。接下来,探讨一下这个“坑”的背后原因。

首先,我们需要了解一个slice有三个基本组成元素:一个指向首元素的指针,一个长度值和一个容量值。我们可以下面这样的结构来表示slice。(go的内部类似实现可以在/src/pkg/runtime/runtime.h下查看)

1
2
3
4
5
type slice struct{
Ptr *int //指向分配的数组的指针
Len int // 长度
Cap int // 容量
}

可以调用make方法来创建一个slice

1
func make([]T, len, [cap]) []T

通过make方式的参数,可以看到,一个slice接收一个指定类型,一个指定长度和一个可选的容量参数。make方法调用后,它背后其实一样分配了一个指定类型的数组,并返回一个slice引用指向该数组。如:

1
2
3
slice:= make([]int, 5, 5)
//注意:Go的默认零值
// slice == []int{0, 0, 0, 0, 0}

调用make时,当cap参数未指定,那它的值与len相同。如:

1
2
3
slice:=make([]int,5)
//len(slice)==5
//cap(slice)==5

除了以上两种方式创建slice,我们也可以采用对原slice或数组进行切片的方式来创建,如:

1
2
3
4
5
6
7
8
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:4]//对数组进行切片
//len(s1)== 3 //len为切片开始位置到结束位置的个数
//cap(s1)=4 //容量为原数组总长度减开始位置

s2:=s1[2:] //对slice进行切片
//len(s2)==1 //len为切片开始位置到结束位置的个数
//cap(s2)==2 //容量为原slice总容量减开始位置

这里有一点需要特别了解,对slice进行切片操作,并不会新创建一个对象(分配内存),而只是在原来slice的基础上移动指针位置。了解对一点,对我们结合下文,理解本文开头提到的“坑”有帮助。我们用下面的图来说明这一点,更好理解。

1
2
slice := []int{1, 2, 3, 4, 5}
newslice:=slice[2:4]

slicing

slice的容量值,限定了slice可容纳元素的最多个数,当我们往slice里添加新元素,导致元素个数超过容量时(len>cap),则需要对slice进行扩容(Growing slices)。append方法的调用就是典型的扩容示例。我们来来模拟一下append方法的基本实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func AppendInt(slice []int, data ...int) []int {
m := len(slice)
n := m + len(data)
if n > cap(slice) {//判断是否需要扩容
//创建新的slice,其实也就是开辟了一个新的内存空间,
//并返回了指向新地址的指针(一般会是增加为总需要长度的两倍,加1是为了防止n=0的情况)
newSlice := make([]int, (n+1)*2)

//将旧的slice的元素值,copy到新创建的slice
copy(newSlice, slice)

//关键的一步,slice重新指向新分配的slice,这也就是本文开头的例子里arr2的值没有变化的原因
slice = newSlice
}
slice = slice[0:n]
//由于本步的copy操作,直接改变了原slice(如果没有重分配的话)里元素的值,
//所以导致了本文开头的例子里arr2的值的变化
copy(slice[m:n], data)
return slice
}

上面的代码,基本模拟了buitIn方法append的实现,具体的内部实现可以从Go的代码/src/pkg/runtime/slice.c里看到,也可以在文章最后附加内容里查看。

通过上面的模拟append函数的代码可以看出,当append进来的元素个数会导致超出原slice的容量限制时会执行下面步骤:

  1. 创建一个容量更大的slice(扩容)。与对slice进行切片操作不同,这个slice是全新的,它的数组也是全新的,指针也是指向新数组的首位置。

  2. 新slice创建好后,会将原来被append的slice的元素内容进行值复制到新的slice。

  3. 将要被append元素,追加到新slice的末尾。

从以上几步可以看出,对slice进行扩容后追加元素,原slice的状态不会发生任何改变。这也就解释了本文开头的代码里,arr2的值,为什么没有发生变化。

但与slice需要扩容不同的是,当原slice容量足够,不需要进行扩容时,那对slice元素的追加,都是发生在原slice里的(数组里),所以,原数组被“悄悄”改变了。这也解释了,为什么arr1的状态被改变了的原因。

附:/src/pkg/runtime/slice.c runtime·appendslice函数代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
void runtime·appendslice(SliceType *t, Slice x, Slice y, Slice ret)
{
intgo m;
uintptr w;
void *pc;
uint8 *p, *q;

m = x.len+y.len;
w = t->elem->size;

if(m < x.len)
runtime·throw("append: slice overflow");

if(m > x.cap)
growslice1(t, x, m, &ret);
else
ret = x;

if(raceenabled) {
// Don't mark read/writes on the newly allocated slice.
pc = runtime·getcallerpc(&t);
// read x[:len]
if(m > x.cap)
runtime·racereadrangepc(x.array, x.len*w, w, pc, runtime·appendslice);
// read y
runtime·racereadrangepc(y.array, y.len*w, w, pc, runtime·appendslice);
// write x[len(x):len(x)+len(y)]
if(m <= x.cap)
runtime·racewriterangepc(ret.array+ret.len*w, y.len*w, w, pc, runtime·appendslice);
}

// A very common case is appending bytes. Small appends can avoid the overhead of memmove.
// We can generalize a bit here, and just pick small-sized appends.
p = ret.array+ret.len*w;
q = y.array;
w *= y.len;
if(w <= appendCrossover) {
if(p <= q || w <= p-q) // No overlap.
while(w-- > 0)
*p++ = *q++;
else {
p += w;
q += w;
while(w-- > 0)
*--p = *--q;
}
} else {
runtime·memmove(p, q, w);
}
ret.len += y.len;
FLUSH(&ret);
}

static void growslice1(SliceType *t, Slice x, intgo newcap, Slice *ret)
{

intgo m;

m = x.cap;

// Using newcap directly for m+m < newcap handles
// both the case where m == 0 and also the case where
// m+m/4 wraps around, in which case the loop
// below might never terminate.
if(m+m < newcap)
m = newcap;
else {
do {
if(x.len < 1024)
m += m;
else
m += m/4;
} while(m < newcap);
}
makeslice1(t, x.len, m, ret);
runtime·memmove(ret->array, x.array, ret->len * t->elem->size);
}