你不知道的 Go 之 slice

简介

切片(slice)是 Go 语言提供的一种数据结构,使用非常简单、便捷。但是由于实现层面的原因,切片也经常会产生让人疑惑的结果。掌握切片的底层结构和原理,可以避免很多常见的使用误区。

底层结构

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

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

[图片上传失败...(image-8752db-1622475715538)]

我们可以通过下面的代码输出切片的底层结构:

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的底层结构如下:

[图片上传失败...(image-8f4838-1622475715538)]

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的长度了。要记住,使用切片操作符切取切片时,上界是切片的容量,而非长度。这时两个切片的底层结构有重叠,如下图:

[图片上传失败...(image-687c90-1622475715538)]

这时输出s2为:

[0, 0, 0, 0, 0]

然后向切片s1中添加元素 6,这时结构如下图,其中切片s1s2共享元素 6:

[图片上传失败...(image-2467db-1622475715538)]

这时输出的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,这时结构如下图所示:

[图片上传失败...(image-c59a71-1622475715538)]

执行s1 = append(s1, 6)时,发现没有空闲容量了(因为len == cap == 5),重新创建一个底层数组再执行添加。这时结构如下图,s1s2互不干扰:

[图片上传失败...(image-eb729e-1622475715538)]

总结

了解了切片的底层数据结构,知道了切片传递的是结构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】,共同学习,一起进步~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,470评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,393评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,577评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,176评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,189评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,155评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,041评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,903评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,319评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,539评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,703评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,417评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,013评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,664评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,818评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,711评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,601评论 2 353

推荐阅读更多精彩内容