关于golang:你不知的-Go-之-slice

43次阅读

共计 3975 个字符,预计需要花费 10 分钟才能阅读完成。

简介

切片(slice)是 Go 语言提供的一种数据结构,应用非常简单、便捷。然而因为实现层面的起因,切片也常常会产生让人纳闷的后果。把握切片的底层构造和原理,能够防止很多常见的应用误区。

底层构造

切片构造定义在源码 runtime 包下的 slice.go 文件中:

// src/runtime/slice.go
type slice struct {
  array unsafe.Pointer
  len int
  cap int
}
  • array:一个指针,指向底层存储数据的数组
  • len:切片的长度,在代码中咱们能够应用 len() 函数获取这个值
  • cap:切片的容量,即在不扩容的状况下,最多能包容多少元素 。在代码中咱们能够应用cap() 函数获取这个值

咱们能够通过上面的代码输入切片的底层构造:

type slice struct {
  array unsafe.Pointer
  len   int
  cap   int
}

func printSlice() {s := make([]uint32, 1, 10)
  fmt.Printf("%#v\n", *(*slice)(unsafe.Pointer(&s)))
}

func main() {printSlice()
}

运行输入:

main.slice{array:(unsafe.Pointer)(0xc0000d6030), len:1, cap:10}

这里留神一个细节,因为 runtime.slice 构造是非导出的,咱们不能间接应用。所以我在代码中手动定义了一个 slice 构造体,字段与 runtime.slice 构造雷同。

咱们联合切片的底层构造,先回顾一下切片的基础知识,而后再逐个看看切片的常见问题。

基础知识

创立切片

创立切片有 4 种形式:

  1. var

var申明切片类型的变量,这时切片值为nil

var s []uint32

这种形式创立的切片,array字段为空指针,lencap 字段都等于 0。

  1. 切片字面量

应用切片字面量将所有元素都列举进去,这时切片长度和容量都等于指定元素的个数。

s := []uint32{1, 2, 3}

创立之后 s 的底层构造如下:

lencap 字段都等于 3。

  1. make

应用 make 创立,能够指定长度和容量。格局为make([]type, len[, cap]),能够只指定长度,也能够长度容量同时指定:

s1 := make([]uint32)
s2 := make([]uint32, 1)
s3 := make([]uint32, 1, 10)
  1. 切片操作符

应用切片操作符能够从现有的切片或数组中切取一部分,创立一个新的切片。切片操作符格局为[low:high],例如:

var arr [10]uint32
s1 := arr[0:5]
s2 := arr[:5]
s3 := arr[5:]
s4 := arr[:]

区间是左开右闭的,即[low, high),包含索引low,不包含high。切取生成的切片长度为high-low

另外 lowhigh都有默认值。low默认为 0,high默认为原切片或数组的长度。它们都能够省略,省略时,相当于取默认值。

应用这种形式创立的切片底层共享雷同的数据空间,在进行切片操作时可能会造成数据笼罩,要分外小心。

增加元素

能够应用 append() 函数向切片中增加元素,能够一次增加 0 个或多个元素。如果残余空间(即 cap-len)足够寄存元素则间接将元素增加到前面,而后减少字段len 的值即可。反之,则须要扩容,调配一个更大的数组空间,将旧数组中的元素复制过来,再执行增加操作。

package main

import "fmt"

func main() {s := make([]uint32, 0, 4)

  s = append(s, 1, 2, 3)
  fmt.Println(len(s), cap(s)) // 3 4

  s = append(s, 4, 5, 6)
  fmt.Println(len(s), cap(s)) // 6 8
}

你不晓得的 slice

  1. 空切片等于 nil 吗?

上面代码的输入什么?

func main() {var s1 []uint32
  s2 := make([]uint32, 0)

  fmt.Println(s1 == nil)
  fmt.Println(s2 == nil)
  fmt.Println("nil slice:", len(s1), cap(s1))
  fmt.Println("cap slice:", len(s2), cap(s2))
}

剖析:

首先 s1s2的长度和容量都为 0,这很好了解。比拟切片与 nil 是否相等,实际上要查看 slice 构造中的 array 字段是否是空指针。显然 s1 == nil 返回 trues2 == nil 返回 false。只管s2 长度为 0,然而 make() 为它调配了空间。所以,个别定义长度为 0 的切片应用 var 的模式

  1. 传值还是传援用?

上面代码的输入什么?

func main() {s1 := []uint32{1, 2, 3}
  s2 := append(s1, 4)

  fmt.Println(s1)
  fmt.Println(s2)
}

剖析:

为什么 append() 函数要有返回值?因为咱们将切片传递给 append() 时,其实传入的是 runtime.slice 构造。这个构造是按值传递的,所以函数外部对 array/len/cap 这几个字段的批改都不影响里面的切片构造。下面代码中,执行 append() 之后 s1lencap 放弃不变,故输入为:

[1 2 3]
[1 2 3 4]

所以咱们调用 append() 要写成 s = append(s, elem) 这种模式,将返回值赋值给原切片,从而覆写 array/len/cap 这几个字段的值。

初学者还可能会犯疏忽 append() 返回值的谬误:

append(s, elem)

这就更加大错特错了。增加的元素将会失落,认为函数外切片的外部字段都没有变动。

咱们能够看到,虽说切片是按援用传递的,然而实际上传递的是构造 runtime.slice 的值。只是对现有元素的批改会反馈到函数外,因为底层数组空间是共用的。

  1. 切片的扩容策略

上面代码的输入是什么?

func main() {var s1 []uint32
  s1 = append(s1, 1, 2, 3)
  s2 := append(s1, 4)
  fmt.Println(&s1[0] == &s2[0])
}

这波及到切片的扩容策略。扩容时,若:

  • 以后容量小于 1024,则将容量扩充为原来的 2 倍;
  • 以后容量大于等于 1024,则将容量逐次减少原来的 0.25 倍,直到满足所需容量。

我翻看了 Go1.16 版本 runtime/slice.go 中扩容相干的源码,在执行下面规定后还会依据切片元素的大小和计算机位数进行相应的调整。整个过程比较复杂,感兴趣能够自行去钻研。

咱们只须要晓得一开始容量较小,扩充为 2 倍,升高后续因增加元素导致扩容的频次。容量扩张到肯定水平时,再依照 2 倍来扩容会造成比拟大的节约。

下面例子中执行 s1 = append(s1, 1, 2, 3) 后,容量会扩充为 4。再执行 s2 := append(s1, 4) 因为有足够的空间,s2底层的数组不会扭转。所以 s1s2第一个元素的地址雷同。

  1. 切片操作符能够切取字符串

切片操作符能够切取字符串,然而与切取切片和数组不同。切取字符串返回的是字符串,而非切片。因为字符串是不可变的,如果返回切片。而切片和字符串共享底层数据,就能够通过切片批改字符串了。

func main() {
  str := "hello, world"
  fmt.Println(str[:5])
}

输入 hello。

  1. 切片底层数据共享

上面代码的输入是什么?

func main() {array := [10]uint32{1, 2, 3, 4, 5}
  s1 := array[:5]

  s2 := s1[5:10]
  fmt.Println(s2)

  s1 = append(s1, 6)
  fmt.Println(s1)
  fmt.Println(s2)
}

剖析:

首先留神到 s2 := s1[5:10] 上界 10 曾经大于切片 s1 的长度了。要记住,应用切片操作符切取切片时,上界是切片的容量,而非长度。这时两个切片的底层构造有重叠,如下图:

这时输入 s2 为:

[0, 0, 0, 0, 0]

而后向切片 s1 中增加元素 6,这时构造如下图,其中切片 s1s2共享元素 6:

这时输入的 s1s2为:

[1, 2, 3, 4, 5, 6]
[6, 0, 0, 0, 0]

能够看到因为切片底层数据共享可能造成批改一个切片会导致其余切片也跟着批改。这有时会造成难以调试的 BUG。为了肯定水平上缓解这个问题,Go 1.2 版本中提供了一个扩大切片操作符:[low:high:max],用来限度新切片的容量。应用这种形式产生的切片容量为max-low

func main() {array := [10]uint32{1, 2, 3, 4, 5}
  s1 := array[:5:5]

  s2 := array[5:10:10]
  fmt.Println(s2)

  s1 = append(s1, 6)
  fmt.Println(s1)
  fmt.Println(s2)
}

执行 s1 := array[:5:5] 咱们限定了 s1 的容量为 5,这时构造如下图所示:

执行 s1 = append(s1, 6) 时,发现没有闲暇容量了(因为 len == cap == 5),从新创立一个底层数组再执行增加。这时构造如下图,s1s2互不烦扰:

总结

理解了切片的底层数据结构,晓得了切片传递的是构造 runtime.slice 的值,咱们就能解决 90% 以上的切片问题。再联合图形能够很直观的看到切片底层数据是如何操作的。

这个系列的名字是我仿造《你不晓得的 JavaScript》起的😀。

参考

  1. 《Go 专家编程》,豆瓣链接:https://book.douban.com/subject/35144587/
  2. 你不晓得的 Go GitHub:https://github.com/darjun/you-dont-know-go

我的博客:https://darjun.github.io

欢送关注我的微信公众号【GoUpUp】,独特学习,一起提高~

正文完
 0