深入解析GO语言中Slice的底层实现

168 阅读6分钟

切片是Go语言中的一个重要概念,也是使用最广泛的数据结构之一。它是一个由相同类型元素组成的可变长度序列,可以看作是对数组的封装。与数组不同,切片的长度是可变的,因此在处理动态数据集合时非常方便。切片的内部实现是基于底层数组的,当切片的容量不足时,会自动扩容并重新分配底层数组的存储空间。切片有三个属性:指向底层数组的指针、长度和容量。其中,容量表示底层数组从切片起始位置到末尾位置的元素个数,而长度则表示切片中当前已经包含的元素个数。通过这些属性,我们可以很方便地对切片进行操作,比如追加元素、删除元素等。

GO语言中的数组与切片

在 Go 语言中,数组是一种由固定长度的特定类型元素组成的序列。数组的长度必须是一个整数常量表达式,并且是数组类型的一部分。数组的定义格式如下:

var array [n]T

其中n表示数组的长度,T表示数组元素的类型。数组是值类型,赋值或者传参时会拷贝整个数组。因此,在函数调用时如果传递一个数组作为参数,实际上传递的是该数组的一份副本,而不是原数组本身。这可能会带来性能上的问题,因为拷贝数组需要额外的时间和空间。数组在 Go 语言中使用较少,通常更多地使用切片(slice)代替数组来处理动态数据集合。但是,在某些情况下,如需要固定长度且连续存储的数据结构时,仍然会使用数组。

对于数组的值属性,我们可以用以下代码进行试验:

func ArrayAndSlice() {
	// 定义
	array1 := [2]string{"go", "python"}
	var array2 [2]string
	fmt.Println("array1 => ", array1)
	fmt.Println("array2 => ", array2)

	// 赋值
	array2 = array1
	fmt.Printf("array1的地址 : %p ,value =  %v\n", &array1, array1)
	fmt.Printf("array2的地址 : %p ,value =  %v\n", &array2, array2)
}

结果如下:

image.png

根据打印的结果,我们可以发现,在数组赋值的时候,并没有改变数组的内存地址,所以数组的赋值是值传递,意味着函数传参使用数组的话,每次传参都要复制一遍,极大的消耗了内存。当然我们可以使用指针的方式来进行参数传递。

func ArrayAndSlice() {
	// 定义
	array1 := [2]string{"go", "python"}
	var array2 [2]string
	fmt.Println("array1 => ", array1)
	fmt.Println("array2 => ", array2)

	// 赋值
	array2 = array1
	fmt.Printf("array1的地址 : %p ,value =  %v\n", &array1, array1)
	fmt.Printf("array2的地址 : %p ,value =  %v\n", &array2, array2)

	a := func(array *[2]string) {
		fmt.Printf("func Array : %p , %v\n", array, *array)
		(*array)[0] = "og"
	}
	a(&array1)
	fmt.Printf("func Array : %p , %v\n", &array1, array1)
}

运行结果如下:

image.png

数组指针的确没有开辟新的内存空间,但是数组指针的功能也比较有限,没有slice那样的功能。

切片的数据结构

切片是 Go 语言中的一个重要概念,也是使用最广泛的数据结构之一。它是一个由相同类型元素组成的可变长度序列,可以看作是对数组的封装。

与数组不同,切片的长度是可变的,因此在处理动态数据集合时非常方便。切片的内部实现是基于底层数组的,当切片的容量不足时,会自动扩容并重新分配底层数组的存储空间。

切片有三个属性:指向底层数组的指针、长度和容量。其中,容量表示底层数组从切片起始位置到末尾位置的元素个数,而长度则表示切片中当前已经包含的元素个数。通过这些属性,我们可以很方便地对切片进行操作,比如追加元素、删除元素等。

切片的底层定义如下:

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

切片的结构体由3部分构成,Pointer 是指向一个数组的指针,len 代表当前切片的长度,cap 是当前切片的容量。cap 总是大于等于 len 的。

切片在使用中有一个最为关键的地方,切片是如何扩容的? 在 Go 中,切片的扩容是通过 append() 函数实现的。当向一个切片中追加元素时,如果该切片的容量不足以存储新元素,Go 会自动分配一块更大的内存来存储扩容后的切片,并将原始数据复制到新的内存空间中。

具体来说,当向一个切片追加元素时,Go 会检查当前切片容量是否足够。如果容量足够,则直接将元素添加到切片末尾;如果容量不够,则会创建一个新的底层数组,并将原始数据复制到新数组中。新数组的容量通常是原始数组的两倍,但在某些情况下也可能会有所调整。然后,新元素会被添加到新数组的末尾,而原始切片则会指向新的底层数组。

需要注意的是,由于底层数组的改变,向原始切片的引用也会发生改变,因此需要小心在扩容后操作原始切片的代码。

举个例子:

func sample() {
	slice := []int{10, 20, 30, 40}
	newSlice := append(slice, 50)
	fmt.Printf("Before slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
	fmt.Printf("Before newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
	newSlice[1] += 10
	fmt.Printf("After slice = %v, Pointer = %p, len = %d, cap = %d\n", slice, &slice, len(slice), cap(slice))
	fmt.Printf("After newSlice = %v, Pointer = %p, len = %d, cap = %d\n", newSlice, &newSlice, len(newSlice), cap(newSlice))
}

运行结果图:

image.png

从图上我们可以很容易的看出,新的切片和之前的切片已经不同了,因为新的切片更改了一个值,并没有影响到原来的数组,新切片指向的数组是一个全新的数组。并且 cap 容量也发生了变化。这之间究竟发生了什么呢?

Go 中切片扩容的策略是这样的:

  • 首先判断,如果新申请容量(cap)大于2倍的旧容量(old.cap),最终容量(newcap)就是新申请的容量(cap)
  • 否则判断,如果旧切片的长度小于1024,则最终容量(newcap)就是旧容量(old.cap)的两倍,即(newcap=doublecap)
  • 否则判断,如果旧切片长度大于等于1024,则最终容量(newcap)从旧容量(old.cap)开始循环增加原来的 1/4,即(newcap=old.cap,for {newcap += newcap/4})直到最终容量(newcap)大于等于新申请的容量(cap),即(newcap >= cap)
  • 如果最终容量(cap)计算值溢出,则最终容量(cap)就是新申请容量(cap)

当然,当slice大小没有超过cap时,slice的指针就指向原数组,此时对slice的操作都会影响到原数组,这样会带来巨大的安全隐患。所以一般来说尽量使用扩容的情况。

如果用 range 的方式去遍历一个切片,拿到的 Value 其实是切片里面的值拷贝。所以每次打印 Value 的地址都不变。由于 Value 是值拷贝的,并非引用传递,所以直接改 Value 是达不到更改原切片值的目的的,需要通过 &slice[index] 获取真实的地址。

Reference:halfrost.com/go_slice/