本文目录结构:
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 }

  1. 通过下面的内容,我们将逐步理解切片数据结构的组成。
  2. <a name="PCzgb"></a>
  3. # 二、切片的创建
  4. 上面说过,切片的底层实际上是对数组的引用,为了能更好的理解切片的数据结构,我们通过截取一个数组去获得一个切片。
  5. <a name="IAtQ4"></a>
  6. ## 2.1 理解切片的底层结构
  7. ```go
  8. a := [10]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
  9. s1 := a[3:6]
  10. s2 := a[5:9]
  11. fmt.Printf("s1:%#v, s1_len:%d, s1_cap:%d\n", s1, len(s1), cap(s1))
  12. //打印结果:s1:[]string{"d", "e", "f"}, s1_len:3, s1_cap:7
  13. fmt.Printf("s2:%#v, s2_len:%d, s2_cap:%d\n", s2, len(s2), cap(s2))
  14. //打印结果:s2:[]string{"f", "g", "h", "i"}, s2_len:4, s2_cap:5

通过对数组a的截取,我们获取到了切片s1和s2,此时切片s1和s2底层共用一个数组a。
切片.jpg
上图中,s1和s2共用一个底层数组a,且切片有相同的元素f。
所以请注意:当我们对某个切片的数据进行修改时,可能会影响到底层数组共用的其他切片。

2.2 切片的创建方式

当然,且的创建方法有很多种,我们下面总结一下创建切片的方式:

  1. //1、var 声明,此时未初始化,无法进行s1[i]这样的取值赋值
  2. var s1 []int
  3. //2、new() 声明,由于new()返回一个指针,所以*取指针的值
  4. // 未初始化,无法进行s1[i]这样的取值赋值
  5. s2 := *new([]int)
  6. //3、直接创建并初始化
  7. s3 := []int{1, 2, 3}
  8. //4、make() 创建并初始化 长度5,容量10 其中容量可不指定,默认为长度的值
  9. s4 := make([]int, 5, 10)
  10. //5、从数组或切片截取
  11. s5 := array[3:6]

值得注意的是使用make()函数对切片进行初始化时,我们需要预估要使用的切片的大概容量,初始化一个合适容量的大小可以保证在使用时,切片不会进行频繁的内存扩容从而影响程序的性能。

2.3 特殊状态的三种切片

切片有三种状态特殊状态需要我们注意:

  • 零切片 :底层元素都是元素类型的零值的切片
  • 空切片:len和cap都为空,但是空切片已分配指针地址
  • nil切片:len和cap都为空,但是nil切片未分配指针地址

其中零切片比较容易理解,就是我们创建了一个切片,初始化了这个切片,但是我们未对切片元素进行赋值:
零切片的声明方式:

  1. var s = make([]int, 3)
  2. fmt.Printf("%v, %d, %d\n", s, len(s), cap(s))
  3. //int空切片打印结果:[0 0 0], 3, 3
  4. var s1 = make([]bool, 3)
  5. fmt.Printf("%v, %d, %d\n", s1, len(s1), cap(s1))
  6. //bool空切片打印结果:[0 0 0], 3, 3
  7. var s2 = make([]*int, 3)
  8. fmt.Printf("%v, %d, %d\n", s2, len(s2), cap(s2))
  9. //int指针空切片打印结果:[<nil> <nil> <nil>], 3, 3

对于空切片和nil切片来说,其len和cap都是0,其中nil切片可以通过==nil进行判断,空切片不等于nil。
空切片的声明方式:

  1. //空切片的声明方式
  2. //1、var 声明的
  3. var s1 = []int{}
  4. fmt.Printf("%#v len:%d cap:%d s1==nil:%v\n", s1, len(s1), cap(s1), s1 == nil)
  5. //打印结果:[]int{} len:0 cap:0 s1==nil:false
  6. //2、make() 声明的
  7. s2 := make([]int, 0)
  8. fmt.Printf("%#v len:%d cap:%d s2==nil:%v\n", s2, len(s2), cap(s2), s2 == nil)
  9. //打印结果:[]int{} len:0 cap:0 s2==nil:false

nil切片的声明方式:

  1. //nil切片声明方式:
  2. //1、var 声明的
  3. var s1 []int
  4. fmt.Printf("%#v len:%d cap:%d s1==nil:%v\n", s1, len(s1), cap(s1), s1 == nil)
  5. //打印结果:[]int(nil) len:0 cap:0 s1==nil:true
  6. //2、new() 声明的
  7. s2 := *new([]int)
  8. fmt.Printf("%#v len:%d cap:%d s2==nil:%v\n", s2, len(s2), cap(s2), s2 == nil)
  9. //打印结果:[]int(nil) len:0 cap:0 s2==nil:true

那么空切片和nil切片在使用上有什么不一样呢?答案是:几乎没什么不一样,但是为了不引起混淆,官方建议使用nil切片,而不是空切片

前面我们提到,空切片的指针元素分配了指针地址,而nil切片的的指针元素没有分配指针地址。
接下来我们就探究一下Go语言是如何创建空切片的,以及是如何为空切片指针元素分配指针地址的:
注意:nil切片未初始化,所以不会执行到makeslice()函数

  1. //源码路径:go/1.14.1/libexec/src/runtime/slice.go:34
  2. func makeslice(et *_type, len, cap int) unsafe.Pointer {
  3. mem, overflow := math.MulUintptr(et.size, uintptr(cap))
  4. if overflow || mem > maxAlloc || len < 0 || len > cap {
  5. // NOTE: Produce a 'len out of range' error instead of a
  6. // 'cap out of range' error when someone does make([]T, bignumber).
  7. // 'cap out of range' is true too, but since the cap is only being
  8. // supplied implicitly, saying len is clearer.
  9. // See golang.org/issue/4085.
  10. mem, overflow := math.MulUintptr(et.size, uintptr(len))
  11. if overflow || mem > maxAlloc || len < 0 {
  12. panicmakeslicelen()
  13. }
  14. panicmakeslicecap()
  15. }
  16. return mallocgc(mem, et, true)
  17. }
  1. //源码路径:go/1.14.1/libexec/src/runtime/malloc.go:891
  2. // Allocate an object of size bytes.
  3. // Small objects are allocated from the per-P cache's free lists.
  4. // Large objects (> 32 kB) are allocated straight from the heap.
  5. func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
  6. if gcphase == _GCmarktermination {
  7. throw("mallocgc called with gcphase == _GCmarktermination")
  8. }
  9. if size == 0 {
  10. return unsafe.Pointer(&zerobase)
  11. }
  12. ......
  13. }

mallocgc()函数中,我们会发现在size==0时,会返回一个zerobase指针变量,在Go语言中,对zerobase指针变量的定义是:所有0字节分配的基准地址,也就是说所有空切片切片最后的地址都是这个变量。

  1. //源码路径:go/1.14.1/libexec/src/runtime/malloc.go:827
  2. // base address for all 0-byte allocations 所有0字节分配的基准地址
  3. var zerobase uintptr

所以我们可以通过下图来表现出来空切片和nil切片的异同之处:
切片-第 2 页.jpg

以上我们讲解了切片的创建方式以及三种特殊状态的切片,接下来我们看一下切片是如何进行扩容的。

三、切片的元素添加与容量扩容

3.1 append()函数增加元素

切片元素的添加使用append()函数。通过该函数我们可以添加一个、多个元素或者将添加其他切片的元素。

  1. //声明一个nil切片
  2. var s []int
  3. //1、向切片中添加一个元素
  4. s = append(s, 1) //[1]
  5. //2、向切片中一次性添加多个元素
  6. s = append(s, 2, 3, 4) //[1,2,3,4]
  7. //3、向切片中添加另一个切片的所有元素
  8. tmp := []int{5, 6, 7}
  9. s = append(s, tmp...) //[1,2,3,4,5,6,7]

注意:append()函数对切片元素的添加是安全的,即使切片是一个nil切片,也能正常添加。

append()函数对切片追加元素,实际上是对切片指向的底层数组进行元素的添加,但是我们知道在Go语言中,数组的长度是固定的,当底层数组放不下更多的元素的时候,就会触发到切片的扩容。
切片的扩容就是根据策略创建一个新的底层数组,然后将切片的底层元素迁移到新的底层数组中。
新的底层数组会有一定的内存冗余,防止append()操作时,频繁开辟新的内容并进行数据迁移。

那么append()是如何进行扩容的呢?

3.2 append()函数如何扩容

当切片容量不足时,需要对切片进行扩容,切片扩容时会调用到growslice()函数

  1. //源码路径:go/1.14.1/libexec/src/runtime/slice.go:76
  2. // growslice handles slice growth during append.
  3. // It is passed the slice element type, the old slice, and the desired new minimum capacity,
  4. // and it returns a new slice with at least that capacity, with the old data
  5. // copied into it.
  6. // The new slice's length is set to the old slice's length,
  7. // NOT to the new requested capacity.
  8. // This is for codegen convenience. The old slice's length is used immediately
  9. // to calculate where to write new values during an append.
  10. // TODO: When the old backend is gone, reconsider this decision.
  11. // The SSA backend might prefer the new length or to return only ptr/cap and save stack space.
  12. func growslice(et *_type, old slice, cap int) slice {
  13. ......
  14. if cap < old.cap {
  15. panic(errorString("growslice: cap out of range"))
  16. }
  17. if et.size == 0 {
  18. // append should not create a slice with nil pointer but non-zero len.
  19. // We assume that append doesn't need to preserve old.array in this case.
  20. return slice{unsafe.Pointer(&zerobase), old.len, cap}
  21. }
  22. newcap := old.cap
  23. doublecap := newcap + newcap
  24. if cap > doublecap {
  25. newcap = cap
  26. } else {
  27. if old.len < 1024 {
  28. newcap = doublecap
  29. } else {
  30. // Check 0 < newcap to detect overflow
  31. // and prevent an infinite loop.
  32. for 0 < newcap && newcap < cap {
  33. newcap += newcap / 4
  34. }
  35. // Set newcap to the requested cap when
  36. // the newcap calculation overflowed.
  37. if newcap <= 0 {
  38. newcap = cap
  39. }
  40. }
  41. }
  42. switch
  43. ......
  44. lenmem = uintptr(old.len) * sys.PtrSize
  45. newlenmem = uintptr(cap) * sys.PtrSize
  46. capmem = roundupsize(uintptr(newcap) * sys.PtrSize)
  47. overflow = uintptr(newcap) > maxAlloc/sys.PtrSize
  48. newcap = int(capmem / sys.PtrSize)
  49. ......
  50. memmove(p, old.array, lenmem)
  51. return slice{p, old.len, newcap}
  52. }

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大小。

  1. //源码路径:go/1.14.1/libexec/src/runtime/msize.go:13
  2. // 返回mallocgc在请求时将分配的内存块的大小
  3. // Returns size of the memory block that mallocgc will allocate if you ask for the size.
  4. func roundupsize(size uintptr) uintptr {
  5. if size < _MaxSmallSize {
  6. if size <= smallSizeMax-8 {
  7. return uintptr(class_to_size[size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv]])
  8. } else {
  9. return uintptr(class_to_size[size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv]])
  10. }
  11. }
  12. if size+_PageSize < size {
  13. return size
  14. }
  15. return alignUp(size, _PageSize)
  16. }

感兴趣的朋友可以好好研究一下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