golang数组和切片的区别

187 阅读7分钟

数组是由相同类型元素的集合组成的数据结构,计算机会为数组分配一块连续的内存来保存其中的元素,我们可以利用数组中元素的索引快速访问特定元素。goalng中的数组在定义时必须指定长度,创建之后长度是不可变的。因为在数组创建过程中,golang会根据定义的长度去申请连续的内存空间。与其他编程语言一样,数组的指针指向数组开头元素。

golang的切片类型是基于数组实现的,可以理解为一个管理数组自动扩容的结构,具体的结构体定义如下:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

array是一个指向数组结构的指针,len和cap字段用于维护数组的长度和容量。当我们定义一个切片类型时,切片的初始容量为0,当我们逐渐append变量时,切片会依据某种策略进行扩容。具体的扩容策略比较复杂,具体可以查看go/src/runtime/slice.go的源码。

func growslice(et *_type, old slice, cap int) slice {
	......
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			// Check 0 < newcap to detect overflow
			// and prevent an infinite loop.
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			// Set newcap to the requested cap when
			// the newcap calculation overflowed.
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
	......
}

简单来说:如果需要的cap > oldcap*2 ,就直接分配需要的cap。否则,如果oldcap< 1024, 直接加倍。如果old <= 1024,反复增加25%。直到足够存储全部内容。实际上的逻辑更加复杂,最终的cap还需要根据数据类型所占的空间进行调整,具体可以参考源码。接下来,通过如下实验验证两种简单的情况。首先是不指定默认长度和容量时。

func TestNewSlice(T *testing.T){
	var slice []int
	fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))

	for i:=0;i<100;i++{
		slice = append(slice, i)
		fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))
	}
}

实验结果如下:

slice len = 0 		 slice cap = 0
slice len = 1 		 slice cap = 1
slice len = 2 		 slice cap = 2
slice len = 3 		 slice cap = 4
slice len = 4 		 slice cap = 4
slice len = 5 		 slice cap = 8
slice len = 6 		 slice cap = 8
slice len = 7 		 slice cap = 8
slice len = 8 		 slice cap = 8
......

接下来是通过make指定初始的长度和容量时。

func TestMakeSlice(T *testing.T){
	var slice = make([]int,0,10)
	fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))

	for i:=0;i<100;i++{
		slice = append(slice, i)
		fmt.Printf("slice len = %d \t\t slice cap = %d\n",len(slice),cap(slice))
	}
}

实验结果如下:

slice len = 0 		 slice cap = 3
slice len = 1 		 slice cap = 3
slice len = 2 		 slice cap = 3
slice len = 3 		 slice cap = 3
slice len = 4 		 slice cap = 6
slice len = 5 		 slice cap = 6
slice len = 6 		 slice cap = 6
slice len = 7 		 slice cap = 12
......

因此,如果我们知道切片大致需要的容量时,最好通过 make方法,指定cap值。这样可以有效避免数组的频繁扩容。从而避免切片在扩容时导致的性能损失。这部分损失包括扩容时重新申请内存、数据的拷贝以及后续的垃圾回收。

golang数组和切片的区别除了体现在是否支持扩容外,还体现在传值操作上。具体我们通过如下实验来说明。

func TestSliceObj(t *testing.T) {

	var a1 = [3]int{1,2,3}
	fmt.Printf("the a1 = %v \t\t  a1 ptr = %p \t\t a1[0] ptr = %p\n",a1,&a1,&a1[0])

	a2 := a1
	a1[0] = 10
	
	fmt.Printf("after change the a1 = %v \t\t  a1 ptr = %p \t\t a1[0] ptr = %p\n",a1,&a1,&a1[0])
	fmt.Printf("after change the a2 = %v \t\t  a2 ptr = %p \t\t a2[0] ptr = %p\n",a2,&a2,&a2[0])
	
	var s1 = []int{1,2,3}
	fmt.Printf("the s1 = %v \t\t  s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])

	s2 := s1
	s1[0] = 10

	fmt.Printf("after change the s1 = %v \t\t  s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])
	fmt.Printf("after change the s2 = %v \t\t  s2 ptr = %p \t\t s2[0] ptr = %p\n",s2,&s2,&s2[0])
}

实验结果如下:

=== RUN   TestSliceObj
the a1 = [1 2 3] 		  a1 ptr = 0xc0000ca0a0 		 a1[0] ptr = 0xc0000ca0a0
after change the a1 = [10 2 3] 		  a1 ptr = 0xc0000ca0a0 		 a1[0] ptr = 0xc0000ca0a0
after change the a2 = [1 2 3] 		  a2 ptr = 0xc0000ca0e0 		 a2[0] ptr = 0xc0000ca0e0
the s1 = [1 2 3] 		  s1 ptr = 0xc0000b40a0 		 s1[0] ptr = 0xc0000ca140
after change the s1 = [10 2 3] 		  s1 ptr = 0xc0000b40a0 		 s1[0] ptr = 0xc0000ca140
after change the s2 = [10 2 3] 		  s2 ptr = 0xc0000b40e0 		 s2[0] ptr = 0xc0000ca140
--- PASS: TestSliceObj (0.00s)

通过实验结果我们发现,对于数组array来说,数组的指针和数组第一个元素的第一个指针是相同的,说明数组指针确实指向数组第一个元素地址。接着我们将a1值赋值给a2并修改a1的值。我们发现a1和a2分别指向不同的内存空间,同时对a1的修改不会影响a2。说明赋值过程是值传递,在赋值过程中会重新申请一块空间。

对于切片slice来说,切片指针指向slice struct的位置,而切片第一个元素的地址位才是底层数组真正的地址位。然后我们将s1赋值给s2,我们发现是s1和s2指针分别指向两个不同的空间,说明该赋值过程同样也是值传递,即当前内存中存在两个slice结构体对象,分别为s1和s2。与数组不同的是,两个切片所对应的底层数组是同一个。当我们修改s1的值之后,s2也同样发生了变化。

那么这是不是就说明,golang中切片类型的赋值是指针复制或者说是浅拷贝呢?

答案是否定的,接下来介绍一个golang切片使用过程中常见的坑。首先做一组实验。

func TestAppendSlice(T *testing.T){
	var s1 = []int{1,2,3,4}
	fmt.Printf("the s1 = %v \t\t  s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])

	s2 := s1
	s1 = append(s1, 5)

	fmt.Printf("after change the s1 = %v \t\t  s1 ptr = %p \t\t s1[0] ptr = %p\n",s1,&s1,&s1[0])
	fmt.Printf("after change the s2 = %v \t\t  s2 ptr = %p \t\t s2[0] ptr = %p\n",s2,&s2,&s2[0])
}

实验结果如下:

=== RUN   TestAppendSlice
the s1 = [1 2 3 4] 		  s1 ptr = 0xc00000c0e0 		 s1[0] ptr = 0xc000018200
after change the s1 = [1 2 3 4 5] 		  s1 ptr = 0xc00000c0e0 		 s1[0] ptr = 0xc00001a180
after change the s2 = [1 2 3 4] 		  s2 ptr = 0xc00000c120 		 s2[0] ptr = 0xc000018200
--- PASS: TestAppendSlice (0.00s)

通过实验结果我们发现,当我们将s1赋值给s2之后,再修改s1,s2并未像之前一样也发生变化。另外我们发现s2对应的底层数组与s1是相同的,这和之前的实验结论一致。不同之处在于,s1在修改后指针指向的底层数组地址发生了变化,这是因为append操作恰好出发了一次数组扩容,从而导致切片重新申请了一块连续地址。

因此在使用切片时一定注意这一点,尤其是当发生参数传递时,需要确定自己期待的是值传递还是指针传递。否则可能会遇到难以解释的bug。