切片是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)
}
结果如下:
根据打印的结果,我们可以发现,在数组赋值的时候,并没有改变数组的内存地址,所以数组的赋值是值传递,意味着函数传参使用数组的话,每次传参都要复制一遍,极大的消耗了内存。当然我们可以使用指针的方式来进行参数传递。
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)
}
运行结果如下:
数组指针的确没有开辟新的内存空间,但是数组指针的功能也比较有限,没有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))
}
运行结果图:
从图上我们可以很容易的看出,新的切片和之前的切片已经不同了,因为新的切片更改了一个值,并没有影响到原来的数组,新切片指向的数组是一个全新的数组。并且 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/