本文目录结构:
1、切片的数据结构
2、切片的创建
2.1 理解切片的数据结构
2.2 切片的创建方式
2.3 特殊状态的三种切片
3、切片的元素添加与容量扩容
3.1 append()函数增加元素
3.2 append()函数如何扩容
4、总结
5、参考资料
切片Slice在Go语言中是一种用途广泛的重要数据结构。数组由于其固定长度的限制,在使用中有很大的局限性。而切片Slice是一个拥有相同元素的可变序列,是基于数据的一层封装,支持扩容。
一、切片的数据结构
在Go语言中,切片的数据结构由3部分组成:
- 指针:指向切片第一个元素的指针,不一定是底层数组的第一个元素
- 长度:切片可访问的元素数量
- 容量:切片可容纳的元素数量 ```go //源码路径:go/1.14.1/libexec/src/runtime/slice.go:13
type slice struct { array unsafe.Pointer len int cap int }
通过下面的内容,我们将逐步理解切片数据结构的组成。<a name="PCzgb"></a># 二、切片的创建上面说过,切片的底层实际上是对数组的引用,为了能更好的理解切片的数据结构,我们通过截取一个数组去获得一个切片。<a name="IAtQ4"></a>## 2.1 理解切片的底层结构```goa := [10]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}s1 := a[3:6]s2 := a[5:9]fmt.Printf("s1:%#v, s1_len:%d, s1_cap:%d\n", s1, len(s1), cap(s1))//打印结果:s1:[]string{"d", "e", "f"}, s1_len:3, s1_cap:7fmt.Printf("s2:%#v, s2_len:%d, s2_cap:%d\n", s2, len(s2), cap(s2))//打印结果:s2:[]string{"f", "g", "h", "i"}, s2_len:4, s2_cap:5
通过对数组a的截取,我们获取到了切片s1和s2,此时切片s1和s2底层共用一个数组a。
上图中,s1和s2共用一个底层数组a,且切片有相同的元素f。
所以请注意:当我们对某个切片的数据进行修改时,可能会影响到底层数组共用的其他切片。
2.2 切片的创建方式
当然,且的创建方法有很多种,我们下面总结一下创建切片的方式:
//1、var 声明,此时未初始化,无法进行s1[i]这样的取值赋值var s1 []int//2、new() 声明,由于new()返回一个指针,所以*取指针的值// 未初始化,无法进行s1[i]这样的取值赋值s2 := *new([]int)//3、直接创建并初始化s3 := []int{1, 2, 3}//4、make() 创建并初始化 长度5,容量10 其中容量可不指定,默认为长度的值s4 := make([]int, 5, 10)//5、从数组或切片截取s5 := array[3:6]
值得注意的是使用make()函数对切片进行初始化时,我们需要预估要使用的切片的大概容量,初始化一个合适容量的大小可以保证在使用时,切片不会进行频繁的内存扩容从而影响程序的性能。
2.3 特殊状态的三种切片
切片有三种状态特殊状态需要我们注意:
- 零切片 :底层元素都是元素类型的零值的切片
- 空切片:len和cap都为空,但是空切片已分配指针地址
- nil切片:len和cap都为空,但是nil切片未分配指针地址
其中零切片比较容易理解,就是我们创建了一个切片,初始化了这个切片,但是我们未对切片元素进行赋值:
零切片的声明方式:
var s = make([]int, 3)fmt.Printf("%v, %d, %d\n", s, len(s), cap(s))//int空切片打印结果:[0 0 0], 3, 3var s1 = make([]bool, 3)fmt.Printf("%v, %d, %d\n", s1, len(s1), cap(s1))//bool空切片打印结果:[0 0 0], 3, 3var s2 = make([]*int, 3)fmt.Printf("%v, %d, %d\n", s2, len(s2), cap(s2))//int指针空切片打印结果:[<nil> <nil> <nil>], 3, 3
对于空切片和nil切片来说,其len和cap都是0,其中nil切片可以通过==nil进行判断,空切片不等于nil。
空切片的声明方式:
//空切片的声明方式//1、var 声明的var s1 = []int{}fmt.Printf("%#v len:%d cap:%d s1==nil:%v\n", s1, len(s1), cap(s1), s1 == nil)//打印结果:[]int{} len:0 cap:0 s1==nil:false//2、make() 声明的s2 := make([]int, 0)fmt.Printf("%#v len:%d cap:%d s2==nil:%v\n", s2, len(s2), cap(s2), s2 == nil)//打印结果:[]int{} len:0 cap:0 s2==nil:false
nil切片的声明方式:
//nil切片声明方式://1、var 声明的var s1 []intfmt.Printf("%#v len:%d cap:%d s1==nil:%v\n", s1, len(s1), cap(s1), s1 == nil)//打印结果:[]int(nil) len:0 cap:0 s1==nil:true//2、new() 声明的s2 := *new([]int)fmt.Printf("%#v len:%d cap:%d s2==nil:%v\n", s2, len(s2), cap(s2), s2 == nil)//打印结果:[]int(nil) len:0 cap:0 s2==nil:true
那么空切片和nil切片在使用上有什么不一样呢?答案是:几乎没什么不一样,但是为了不引起混淆,官方建议使用nil切片,而不是空切片。
前面我们提到,空切片的指针元素分配了指针地址,而nil切片的的指针元素没有分配指针地址。
接下来我们就探究一下Go语言是如何创建空切片的,以及是如何为空切片指针元素分配指针地址的:
注意:nil切片未初始化,所以不会执行到makeslice()函数
//源码路径:go/1.14.1/libexec/src/runtime/slice.go:34func makeslice(et *_type, len, cap int) unsafe.Pointer {mem, overflow := math.MulUintptr(et.size, uintptr(cap))if overflow || mem > maxAlloc || len < 0 || len > cap {// NOTE: Produce a 'len out of range' error instead of a// 'cap out of range' error when someone does make([]T, bignumber).// 'cap out of range' is true too, but since the cap is only being// supplied implicitly, saying len is clearer.// See golang.org/issue/4085.mem, overflow := math.MulUintptr(et.size, uintptr(len))if overflow || mem > maxAlloc || len < 0 {panicmakeslicelen()}panicmakeslicecap()}return mallocgc(mem, et, true)}
//源码路径:go/1.14.1/libexec/src/runtime/malloc.go:891// Allocate an object of size bytes.// Small objects are allocated from the per-P cache's free lists.// Large objects (> 32 kB) are allocated straight from the heap.func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {if gcphase == _GCmarktermination {throw("mallocgc called with gcphase == _GCmarktermination")}if size == 0 {return unsafe.Pointer(&zerobase)}......}
在mallocgc()函数中,我们会发现在size==0时,会返回一个zerobase指针变量,在Go语言中,对zerobase指针变量的定义是:所有0字节分配的基准地址,也就是说所有空切片切片最后的地址都是这个变量。
//源码路径:go/1.14.1/libexec/src/runtime/malloc.go:827// base address for all 0-byte allocations 所有0字节分配的基准地址var zerobase uintptr
所以我们可以通过下图来表现出来空切片和nil切片的异同之处:
以上我们讲解了切片的创建方式以及三种特殊状态的切片,接下来我们看一下切片是如何进行扩容的。
三、切片的元素添加与容量扩容
3.1 append()函数增加元素
切片元素的添加使用append()函数。通过该函数我们可以添加一个、多个元素或者将添加其他切片的元素。
//声明一个nil切片var s []int//1、向切片中添加一个元素s = append(s, 1) //[1]//2、向切片中一次性添加多个元素s = append(s, 2, 3, 4) //[1,2,3,4]//3、向切片中添加另一个切片的所有元素tmp := []int{5, 6, 7}s = append(s, tmp...) //[1,2,3,4,5,6,7]
注意:append()函数对切片元素的添加是安全的,即使切片是一个nil切片,也能正常添加。
append()函数对切片追加元素,实际上是对切片指向的底层数组进行元素的添加,但是我们知道在Go语言中,数组的长度是固定的,当底层数组放不下更多的元素的时候,就会触发到切片的扩容。
切片的扩容就是根据策略创建一个新的底层数组,然后将切片的底层元素迁移到新的底层数组中。
新的底层数组会有一定的内存冗余,防止append()操作时,频繁开辟新的内容并进行数据迁移。
3.2 append()函数如何扩容
当切片容量不足时,需要对切片进行扩容,切片扩容时会调用到growslice()函数
//源码路径:go/1.14.1/libexec/src/runtime/slice.go:76// growslice handles slice growth during append.// It is passed the slice element type, the old slice, and the desired new minimum capacity,// and it returns a new slice with at least that capacity, with the old data// copied into it.// The new slice's length is set to the old slice's length,// NOT to the new requested capacity.// This is for codegen convenience. The old slice's length is used immediately// to calculate where to write new values during an append.// TODO: When the old backend is gone, reconsider this decision.// The SSA backend might prefer the new length or to return only ptr/cap and save stack space.func growslice(et *_type, old slice, cap int) slice {......if cap < old.cap {panic(errorString("growslice: cap out of range"))}if et.size == 0 {// append should not create a slice with nil pointer but non-zero len.// We assume that append doesn't need to preserve old.array in this case.return slice{unsafe.Pointer(&zerobase), old.len, cap}}newcap := old.capdoublecap := newcap + newcapif 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}}}switch......lenmem = uintptr(old.len) * sys.PtrSizenewlenmem = uintptr(cap) * sys.PtrSizecapmem = roundupsize(uintptr(newcap) * sys.PtrSize)overflow = uintptr(newcap) > maxAlloc/sys.PtrSizenewcap = int(capmem / sys.PtrSize)......memmove(p, old.array, lenmem)return slice{p, old.len, newcap}}
growslice()函数的3个参数分别是:元素的类型,旧的 slice,新 slice 最小的容量
根据if条件判断新newcap的大小是oldcap的2倍还是1.25倍,小于1024是2倍,大于1024是1.25倍,但是请注意并不一定newcap肯定是oldcap的2倍或者1.25倍。
因为接下来有一个switch的判断语句,其中一个case对newcap进行了roundupsize()函数操作,重新计算了newcap大小。
//源码路径:go/1.14.1/libexec/src/runtime/msize.go:13// 返回mallocgc在请求时将分配的内存块的大小// Returns size of the memory block that mallocgc will allocate if you ask for the size.func roundupsize(size uintptr) uintptr {if size < _MaxSmallSize {if size <= smallSizeMax-8 {return uintptr(class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]])} else {return uintptr(class_to_size[size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]])}}if size+_PageSize < size {return size}return alignUp(size, _PageSize)}
感兴趣的朋友可以好好研究一下growslice()函数。
四、总结
通过以上对切片的数据结构,切片的创建以及切片元素的添加的描述,我们应该对切片有以下的认知:
- 切片是引用类型,底层引用一个数组;
- 多个切片可能共用同一个底层数组,对切片元素修改时需要考虑对其他切片的影响;
- 创建len==0的切片时,为避免混淆,应创建nil切片而不是空切片;
- 初始化切片时,应根据需求指定合适的容量,避免频繁触发扩容而影响程序执行效率;
- 切片通过
append()函数进行添加元素,当容量不足时会自动进行扩容,且扩容后的新切片底层数组与旧的底层数组没有任何关系。
最后,切片还有一个函数是copy()函数,用于将a切片内的数据复制到b切片空间内,b是自己独有的空间,不和a共用底层数组。
参考资料:
深度解密Go语言之Slice
https://www.liwenzhou.com/posts/Go/06_slice/
https://juejin.cn/post/6844903712654098446
https://books.studygolang.com/gopl-zh/ch4/ch4-02.html
