基础语法 | 青训营笔记

35 阅读9分钟

这是我参与「第五届青训营 」笔记创作活动的第11天

基础语法别人说的很清除了,这里我参考了他人文章,加上我自己的理解说一下slice的底层知识

数组与切片

因为切片(slice)比数组更好用,也更安全,Go推荐使用slice而不是数组。

1.数组和切片有何异同

Go语言中的切片(slice)结构的本质是对数组的封装,它描述一个数组的片段。无论是数组还是切片,都可以通过下标来访问单个元素。 数组就是一片连续的内存,是定长的,长度定义好之后,不能再更改。在Go语言中,数组是不常见的,因为其长度是类型的一部分,限制了它的表达能力,比如[3]int和[5]int就是不同的类型,他们无法比较,而[3]int和[3]int是相同类型,可以比较,此外数组的大小取决于数组长度

func main() {
	arr1 := [3]int{1, 2, 3}
	arr2 := [3]int{1, 2, 3}
	arr3 := [5]int{1, 2, 3, 4, 5}
	fmt.Println(arr1 == arr2)        // true 可以比较,类型相同,值也相等
	fmt.Println(arr1) == arr3)       // 编译错误 类型不同
	fmt.Println(unsafe.Sizeof(arr3)) // 40 数组占用内存大小为元素大小的总和,`int`占`8`个字节,所以5*8 = 40
}
复制代码

切片则非常灵活,它可以动态地扩容,且切片的类型和长度无关。

slice 实际上是一个结构体,包含三个字段:长度容量底层数组

// runtime/slice.go
type slice struct {
	array unsafe.Pointer // 元素指针
	len   int // 长度 
	cap   int // 容量
}
复制代码

slice 的数据结构如下:

切片数据结构

我们可以知道,只要底层数组的指针不一样,那么两个slice就是不相等的,所以Go定义slice是无法比较的

我们还能知道切片只有3个字段,1个指针,2个int参数,所以一个切片的大小固定为3*8 = 24

2.切片如何被截取

截取是一种常见的创建切片的方式,可以从数组或者slice直接截取,需要指定起始索引和终止索引的位置

截取用如下方式:

data := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
slice := data[2:4:6]
复制代码
data[left,right,max]

截取的索引范围为 left - right-1,也就是前闭后开,新slice的lenright-left

max代表截取后的容量,也是开区间,capmax-left,不写则为原数组或切片容量

要满足left <= right<= max,当left == right时,slice为空

基于已有slice创建的新slice对象会和老slice共用底层数组,对新老slice底层数组的更改会影响到彼此,截取数组也同理。

但是如果执行append操作使得slice底层数组扩容,底层数组改变了,那么在做修改就不会互相影响了,是否是影响关键在于是否共用底层数组

看下面一段代码:

func main() {
	slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
	s1 := slice[2:5]
	s2 := s1[2:6:7]

	s2 = append(s2, 100)
	s2 = append(s2, 200)

	s1[2] = 20

	fmt.Println(s1)    // [2 3 20]
	fmt.Println(s2)    // [4 5 6 7 100 200]
	fmt.Println(slice) // [0 1 2 3 20 5 6 7 100 9]
}
复制代码

s1截取了slice索引2到4的位置,len为5-2=3,cap默认到数组结尾也就是10-2=8

s2截取了slice索引2到5的位置,len为6-2=4,cap为7-2=5

slice origin

接着,向 s2 尾部追加一个元素 100:

s2 = append(s2, 100)
复制代码

s2容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 s1 都可以看得到。

append 100

再次向 s2 追加元素200:

s2 = append(s2, 100)
复制代码

这时,s2 的容量不够用,该扩容了。s2开辟了新数组,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 append 带来的再一次扩容,s2 会在此次扩容的时候多留一些 buffer,将新的容量将扩大为原始容量的2倍,也就是10了。

append 200

最后,修改 s1索引为2位置的元素:

s1[2] = 20
复制代码

s1[2]=20

此时就不会影响s2

打印只能打印len以内的元素,s1只能打印3个元素,虽然底层数组后面还有元素。

3.切片是如何扩容的

我们通过调用append实现切片添加元素,append 函数的参数长度可变,因此可以追加多个值到 slice 中,还可以用 ... 传入 slice,直接追加一个切片。

slice = append(slice, elem1, elem2)
slice = append(slice, anotherSlice...)
复制代码

我们在append元素时,实际就是往索引为len的位置添加元素,当len-1为底层数组最后一个元素时就会自动扩容,开辟一个大数组,把原数组的数拷贝过去,然后在往len的位置添加元素,这些是内部实现的,我们不会感知到

1.18版本之前,网上说的扩容策略是这样的:

当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的1.25倍。

1.18中,扩容策略是这样的:

当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3*256)/4

事实真是这样的吗?做个测试

/*
我先创建了一个空的 slice,然后,在一个循环里不断往里面 append 新的元素。然后记录容量的变化,
并且每当容量发生变化的时候,记录下老的容量,以及添加完元素之后的容量,同时记下此时 slice 里的元素。
这样,我就可以观察,新老 slice 的容量变化情况,从而找出规律。
*/
func main() {
	s := make([]int, 0)

	oldCap := cap(s)

	for i := 0; i < 2048; i++ {
		s = append(s, i)

		newCap := cap(s)

		if newCap != oldCap {
			fmt.Printf("[%d -> %4d] cap = %-4d  |  after append %-4d  cap = %-4d\n", 0, i-1, oldCap, i, newCap)
			oldCap = newCap
		}
	}
}
复制代码

运行结果(1.18版本之前):

[0 ->   -1] cap = 0     |  after append 0     cap = 1   
[0 ->    0] cap = 1     |  after append 1     cap = 2   
[0 ->    1] cap = 2     |  after append 2     cap = 4   
[0 ->    3] cap = 4     |  after append 4     cap = 8   
[0 ->    7] cap = 8     |  after append 8     cap = 16  
[0 ->   15] cap = 16    |  after append 16    cap = 32  
[0 ->   31] cap = 32    |  after append 32    cap = 64  
[0 ->   63] cap = 64    |  after append 64    cap = 128 
[0 ->  127] cap = 128   |  after append 128   cap = 256 
[0 ->  255] cap = 256   |  after append 256   cap = 512 
[0 ->  511] cap = 512   |  after append 512   cap = 1024
[0 -> 1023] cap = 1024  |  after append 1024  cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1696
[0 -> 1695] cap = 1696  |  after append 1696  cap = 2304
复制代码

运行结果(1.18版本):

[0 ->   -1] cap = 0     |  after append 0     cap = 1
[0 ->    0] cap = 1     |  after append 1     cap = 2   
[0 ->    1] cap = 2     |  after append 2     cap = 4   
[0 ->    3] cap = 4     |  after append 4     cap = 8   
[0 ->    7] cap = 8     |  after append 8     cap = 16  
[0 ->   15] cap = 16    |  after append 16    cap = 32  
[0 ->   31] cap = 32    |  after append 32    cap = 64  
[0 ->   63] cap = 64    |  after append 64    cap = 128 
[0 ->  127] cap = 128   |  after append 128   cap = 256 
[0 ->  255] cap = 256   |  after append 256   cap = 512 
[0 ->  511] cap = 512   |  after append 512   cap = 848 
[0 ->  847] cap = 848   |  after append 848   cap = 1280
[0 -> 1279] cap = 1280  |  after append 1280  cap = 1792
[0 -> 1791] cap = 1792  |  after append 1792  cap = 2560
复制代码

根据上面的结果我们可以看出在1.18版本之前:

在原来的slice容量oldcap小于1024的时候,新 slice 的容量newcap的确是oldcap的2倍。

但是,当oldcap大于等于 1024 的时候,情况就有变化了。当向 slice 中添加元素 1280 的时候,原来的slice 的容量为 1280,之后newcap变成了 1696,两者并不是 1.25 倍的关系(1696/1280=1.325)。添加完 1696 后,新的容量 2304 当然也不是 16961.25 倍。

1.18版本之后:

在原来的slice 容量oldcap小于256的时候,新 slice 的容量newcap的确是oldcap 的2倍。

但是,当oldcap容量大于等于 256 的时候,情况就有变化了。当向 slice 中添加元素 512 的时候,老 slice 的容量为 512,之后变成了 848,两者并没有符合newcap = oldcap+(oldcap+3*256)/4 的策略(512+(512+3*256)/4)=832。添加完 848 后,新的容量 1280 当然也不是 按照之前策略所计算出的的1252。

可见网上的扩容策略并不正确,看growslice的扩容源码

1.16

// go 1.16.5 src/runtime/slice.go:82
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 {
			for newcap < cap {
				newcap += newcap / 4
			}
		}
	}
	// ……
	
	capmem = roundupsize(uintptr(newcap) * ptrSize)
	newcap = int(capmem / ptrSize)
}
复制代码

1.18

// go 1.18 src/runtime/slice.go:178
func growslice(et *_type, old slice, cap int) slice {
    // ……
    newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		const threshold = 256
		if old.cap < threshold {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
                // Transition from growing 2x for small slices
				// to growing 1.25x for large slices. This formula
				// gives a smooth-ish transition between the two.
				newcap += (newcap + 3*threshold) / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
	// ……
    
	capmem = roundupsize(uintptr(newcap) * ptrSize)
	newcap = int(capmem / ptrSize)
}
复制代码

如果只看前半部分,现在网上各种文章里说的 newcap 的规律是对的。现实是,后半部分还对 newcap 作了一个内存对齐,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于 按照前半部分生成的newcap。之后,向 Go 内存管理器申请内存,将老 slice 中的数据复制过去,并且将 append 的元素添加到新的底层数组中。最后,向 growslice 函数调用者返回一个新的 slice,这个 slice 的长度并没有变化,而容量却增大了。

总结:扩容策略并不是单纯的满足扩容策略公式,容量很大之后续进行内存对齐,具体扩到多大无法得知。

4.切片作为函数参数

网上很多人说切片是引用类型,那么传到函数里做修改就一定会改变原切片吗?

实则不是,前文说过,切片实际就是一个包含三个元素的结构体,作为参数也就是传递了一个普通的结构体,

指针lencap这些字段是复制到函数里的。

另外,值得注意的是,Go 语言的函数参数传递,只有值传递,没有引用传递。

函数作为参数有以下两种情况:

  • 当我们做普通赋值修改时,类似 s[i]=10 这种操作会改变 slice 底层数组元素值,也就影响了原切片
  • 当我们做append时,改变了lencap,但这些原切片是不会改变的,所以当原切片遍历也就不会变,这时就需要我们传递切片的指针做修改,或者直接返回修改后的切片

例子1:

func f(s []int) {
	// i只是一个副本,不能改变s中元素的值
	/*for _, i := range s {
		i++
	}
	*/

	for i := range s {
		s[i] += 1
	}
}

func main() {
	s := []int{1, 1, 1}
	f(s)
	fmt.Println(s) //[2 2 2]
}
复制代码

函数里对切片每一个元素+1,改变了底层数组的值,也就影响到了原切片

例子2:

func myAppend(s []int) []int {
	// 这里 s 虽然改变了,但并不会影响外层函数的 s
	s = append(s, 100)
	return s
}

func myAppendPtr(s *[]int) {
	// 会改变外层 s 本身
	*s = append(*s, 100)
	return
}

func main() {
	s := []int{1, 1, 1}
	newS := myAppend(s)

	fmt.Println(s)
	fmt.Println(newS)

	s = newS

	myAppendPtr(&s)
	fmt.Println(s)
}
复制代码

结果:

[1 1 1]
[1 1 1 100]
[1 1 1 100 100]
复制代码

myAppend 函数里,虽然改变了 s,但它只是一个值传递,改变的是副本的lencap,并不会影响外层的 s,因此第一行打印出来的结果仍然是 [1 1 1]

newS 是一个新的 slice,它是基于 s 得到的。因此它打印的是追加了一个 100 之后的结果: [1 1 1 100]

最后,将 newS 赋值给了 ss 这时才真正变成了一个新的slice。之后,再给 myAppendPtr 函数传入一个 s 指针,这回它真的被改变了:[1 1 1 100 100]

总结:当切片作为函数参数时,如果只做赋值修改,例如一些排序算法,不会涉及append,那么可以传递切片。如果在函数里需要append,需要传递切片的指针,或者传递切片,但要返回修改后的切片,让原切片接收它