Go 知识点(15)— 切片长度和容量

1. 切片声明时默认的长度和容量

1.1 切片声明时不指定容量

切片在使用 make 声明时,如果没有显式的说明切片的容量,那么默认容量和切片的长度保持一致。

func main() {
	s1 := make([]int, 3)
	fmt.Println("s1 length: ", len(s1))   // s1 length:  3
	fmt.Println("s1 capacity: ", cap(s1)) // s1 capacity:  3
	fmt.Printf("s1 value: %#v\n", s1)     // s1 value: []int{0, 0, 0}
}

由上面可以看出来,当只声明切片的长度时,容量是和长度保持一致的。

1.2 切片声明时指定容量

而在声明切片时,如果指定切片的容量,那么容量就为指定的值

func main() {
	s2 := make([]int, 3, 7)
	fmt.Println("s2 length: ", len(s2))   // s2 length:  3
	fmt.Println("s2 capacity: ", cap(s2)) // s2 capacity:  7
	fmt.Printf("s2 value:  %#v\n", s2)    // s2 value:  []int{0, 0, 0}
}

2. 指定切片区间作为新切片时,新切片的长度和容量

看下面代码

func main() {
	s3 := []int{1, 2, 3, 4, 5, 6, 7, 8}
	s4 := s3[3:6]
	fmt.Printf("s4 length is: %d\n", len(s4))   // s4 length is: 3
	fmt.Printf("s4 capacity is: %d\n", cap(s4)) // s4 capacity is: 5
	fmt.Printf("s4 value is: %d\n", s4)         // s4 value is: [4 5 6]

	s5 := s4[:cap(s4)]
	fmt.Printf("s5 length is: %d\n", len(s5))   // s5 length is: 5
	fmt.Printf("s5 capacity is: %d\n", cap(s5)) // s5 capacity is: 5
	fmt.Printf("s5 value is: %d\n", s5)         // s5 value is: [4 5 6 7 8]
}

切片 s4 是在切片 s3 的基础上通过索引划分出来一个新的切片,所以切片 s4 的长度是 6-3=3,而切片 s4 的容量 cap(s4)=8-3=5 ,其中 8 为切片 s3 的长度,3 为切片s4s3 的起始位置。

cap(s4)=5,所以 s4[:cap(s4)] 就等于 s4[:5] 由于 s4s3 共用同一个数组,所以 s4[:5] 就相当于从 s4 起始的位置开始到第 5 个元素,也就是从 s3 的第三个元素开始一直到第 3 + 5 = 8 个元素,所以 s5=[4 5 6 7 8]

3. 切片扩容时容量的变化

切片通过 append 扩容时,如果切片长度小于当前的容量,那么切片不会扩容,如果追加元素后切片长度大于当前的容量时,切片就会扩容,扩容机制如下:

  • 当扩容之后的元素长度小于 1024 时会以原切片容量的 2 倍的进行扩容;
  • 当扩容之后的元素长度大于 1024 时会以原切片容量的 1.25 倍的进行扩容;
func main() {
	s6 := make([]int, 0)
	fmt.Printf("The capacity of s6: %d\n", cap(s6))
	for i := 1; i <= 10; i++ {
		s6 = append(s6, i)
		fmt.Printf("s6(%d): len: %d, cap: %d, address %p\n", i, len(s6), cap(s6), s6)
	}
	fmt.Println()
}

输出结果如下:

The capacity of s6: 0
s6(1): len: 1, cap: 1, address 0xc0000160e8
s6(2): len: 2, cap: 2, address 0xc000016130
s6(3): len: 3, cap: 4, address 0xc0000145c0
s6(4): len: 4, cap: 4, address 0xc0000145c0
s6(5): len: 5, cap: 8, address 0xc00001c200
s6(6): len: 6, cap: 8, address 0xc00001c200
s6(7): len: 7, cap: 8, address 0xc00001c200
s6(8): len: 8, cap: 8, address 0xc00001c200
s6(9): len: 9, cap: 16, address 0xc00007a080
s6(10): len: 10, cap: 16, address 0xc00007a080

通过上面代码我们可以得知:

  • 当不需要扩容时,append 函数返回的是原底层数组的原切片(内存地址不变);
  • 当切片需要扩容时,append 函数返回的是新底层数组的新切片(切片内存地址发生了改变);

slice 追加元素的时候,若容量不够,会调用 growslice 函数

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)

重点看最后两行代码。
newcap 作了一个内存对齐,这个和内存分配策略相关。所以最终结果不一定是 1.25的整数倍。

如下:

func main() {
	s7 := make([]int, 1024)
	fmt.Printf("The capacity of s7: %d\n", cap(s7))
	s7e1 := append(s7, make([]int, 200)...)
	fmt.Printf("s7e1: len: %d, cap: %d\n", len(s7e1), cap(s7e1))
	s7e2 := append(s7, make([]int, 400)...)
	fmt.Printf("s7e2: len: %d, cap: %d\n", len(s7e2), cap(s7e2))
	s7e3 := append(s7, make([]int, 600)...)
	fmt.Printf("s7e3: len: %d, cap: %d\n", len(s7e3), cap(s7e3))
	fmt.Println()
}

最终结果为:

The capacity of s7: 1024
s7e1: len: 1224, cap: 1280
s7e2: len: 1424, cap: 1696
s7e3: len: 1624, cap: 2048

参考:https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/

4. 多个切片指向同一个数组,对切片元素的修改会影响到整个数组

如果多个切片指向同一底层数组,引用相同片段的底层数组会因为其中一个切片的改变影响整个底层数组, 因此需要特别注意。

func main() {
	a1 := [7]int{1, 2, 3, 4, 5, 6, 7}
	fmt.Printf("a1: %v (len: %d, cap: %d)\n", a1, len(a1), cap(a1))
	s9 := a1[1:4]

	fmt.Printf("s9: %v (len: %d, cap: %d)\n", s9, len(s9), cap(s9))
	for i := 1; i <= 5; i++ {
		s9 = append(s9, i)
		fmt.Printf("s9(%d): %v (len: %d, cap: %d)\n", i, s9, len(s9), cap(s9))
	}
	fmt.Printf("a1: %v (len: %d, cap: %d)\n", a1, len(a1), cap(a1))
}

输出结果为:

a1: [1 2 3 4 5 6 7] (len: 7, cap: 7)
s9: [2 3 4] (len: 3, cap: 6)
s9(1): [2 3 4 1] (len: 4, cap: 6)
s9(2): [2 3 4 1 2] (len: 5, cap: 6)
s9(3): [2 3 4 1 2 3] (len: 6, cap: 6)
s9(4): [2 3 4 1 2 3 4] (len: 7, cap: 12)
s9(5): [2 3 4 1 2 3 4 5] (len: 8, cap: 12)
a1: [1 2 3 4 1 2 3] (len: 7, cap: 7)

可以看到对切片 s9 进行增加元素扩容后原始数组 a1 也发生了变化。

func main() {
	// 底层数组
	s2 := [4]int{10, 20, 30, 40}

	s3 := s2[:3]
	s4 := s2[2:]

	fmt.Printf("s3:%d\n", s3)
	fmt.Printf("s4:%d\n", s4)
	fmt.Println()

	// 修改其中一个切片
	s4[0] = 60
	fmt.Printf("s3:%d\n", s3)
	fmt.Printf("s4:%d\n", s4)
}

输出结果:

s3:[10 20 30]
s4:[30 40]

s3:[10 20 60]
s4:[60 40]

5. 切片底层原理

Go 中的切片是一个复合结构。一个切片在运行时由指针(data)、长度(len)和容量(cap)三部分构成。


type SliceHeader struct {
  Data uintptr
  Len int
  Cap int
}
  • 指针 data 指向切片元素对应的底层数组元素的地址。
  • 长度 len 对应切片中元素的数目,总长度不能超过容量。
  • 容量 cap 提供了额外的元素空间,可以在之后更快地添加元素。容量的大小一般指的是从切片的开始位置到底层数据的结尾位置的长度。

Go 知识点(15)— 切片长度和容量_第1张图片

5.1 切片的截取

切片在被截取时的一个特点是,截取后的切片长度和容量可能会发生变化。

和数组一样,切片中的数据仍然是内存中一片连续的区域。要获取切片某一区域的连续数据,可以通过下标的方式对切片进行截断。被截取后的切片,它的长度和容量都发生了变化。就像下面这个例子,numbers 切片的长度为 8。number1 截取了 numbers 切片中的第 2、3 号元素。number1 切片的长度变为了 2,容量变为了 6(即从第 2 号元素开始到元素数组的末尾)。


numbers:= []int{1,2,3,4,5,6,7,8}
// 从下标2 一直到下标4,但是不包括下标4
numbers1 :=numbers[2:4]
// 从下标0 一直到下标3,但是不包括下标3
numbers2 :=numbers[:3]
// 从下标3 一直到结尾
numbers3 :=numbers[3:]

切片在被截取时的另一个特点是,被截取后的数组仍然指向原始切片的底层数据。例如之前提到的案例,bar 截取了 foo 切片中间的元素,并修改了 bar 中的第 2 号元素。

foo := []int{0,0,0,42,100}
bar := foo[1:4]
bar[1] = 99

底层结构图如下:
Go 知识点(15)— 切片长度和容量_第2张图片
这时,bar 的 cap 容量会到原始切片的末尾,所以当前 bar 的 cap 长度为 4。

这意味着什么呢?我们看下面的例子,bar 执行了 append 函数之后,最终也修改了 foo 的最后一个元素,这是一个在实践中非常常见的陷阱。

foo := []int{0, 0, 0, 42, 100}
bar := foo[1:4]
bar = append(bar, 99)
fmt.Println("foo:", foo) // foo: [0 0 0 42 99]
fmt.Println("bar:", bar) // bar: [0 0 42 99]

如果要解决这样的问题,其实可以在截取时指定容量:


foo := []int{0,0,0,42,100}
bar := foo[1:4:4]
bar = append(bar, 99)
fmt.Println("foo:", foo) // foo: [0 0 0 42 100]
fmt.Println("bar:", bar) // bar: [0 0 42 99]

foo[1:4:4] 这种方式可能很多人没有见到过。这里,第三个参数 4 代表 cap 的位置一直到下标 4,但是不包括下标 4。 所以当前 bar 的 Cap 变为了 3,和它的长度相同。当 bar 进行 append 操作时,将发生扩容,它会指向与 foo 不同的底层数据空间。

5.2 切片的扩容

Go 语言内置的 append 函数可以把新的元素添加到切片的末尾,它可以接受可变长度的元素,并且可以自动扩容。如果原有数组的长度和容量已经相同,那么在扩容后,长度和容量都会相 应增加。

如下所示,numbers 切片一开始的长度和容量都是 4,添加一个元素后,它的长度变为了 5,容量变为 8,相当于扩容了一倍。


numbers:= []int{1,2,3,4}
numbers = append(numbers,5)

不过,Go 语言并不会每增加一个元素就扩容一次,这是因为扩容常会涉及到内存的分配,频繁扩容会减慢 append 的速度。append 函数在运行时调用了 runtime/slice.go 文件下的 growslice 函数:


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 0 < newcap && newcap < cap {
        newcap += newcap / 4
      }
      if newcap <= 0 {
        newcap = cap
      }
    }
  }
  ...
}

上面这段代码显示了扩容的核心逻辑。Go 语言中切片扩容的策略为:

  • 如果新申请容量(cap)大于旧容量(old.cap)的两倍,则最终容量(newcap)是新申请的容量(cap);
  • 如果旧切片的长度小于 1024,则最终容量是旧容量的 2 倍,即“newcap=doublecap”;
  • 如果旧切片的长度大于或等于 1024,则最终容量从旧容量开始循环增加原来的 1/4,直到最终容量大于或等于新申请的容量为止;
  • 如果最终容量计算值溢出,即超过了 int 的最大范围,则最终容量就是新申请容量。

切片的这种扩容机制是深思熟虑的结果。一开始切片容量小,扩容得更多一些可以确保扩容不用太频繁。容量变大之后,按照比例扩容也会有足够多的元素空间被开辟出来。

切片动态扩容的机制启发我们,在一开始就要分配好切片的容量。否则频繁地扩容会影响程序的性能。

混合使用切片截取和 append 非常容易犯错,所以我们要尽量避免这种用法。如果必须使用,也要确认自己真的理解了切片的底层图像,防止切片的误操作

你可能感兴趣的:(#,知识总结,切片长度)