一篇文章彻底弄懂理解和高效运用切片(slice)

九路 等级 496 1 0

slice,中文多译为“切片”,是 Go 语言在数组之上提供的一个重要的抽象数据类型。在 Go 语言中,绝大多数需要使用数组的场合,切片都实现了完美替代。并且和数组相比,切片提供了更通用、功能更强大且便捷的数据序列访问接口。

1. 切片究竟是什么

在对切片一探究竟之前,我们先来简略了解一下 Go 语言中的数组。

Go 语言数组是一个固定长度的、容纳同构类型元素的连续序列。因此 Go 数组类型具有两个属性:元素类型和数组长度。但凡这两个属性相同的数组类型是等价的。比如下面变量 a、b、c 对应的数组类型是三个不同的数组类型:

var a [8]int 
var b [8]byte
var c [9]int 

变量 a、b 对应的数组类型长度属性相同,但元素类型不同(一个是 int,一个是 byte);变量 a、c 对应的数组类型的元素类型相同,都是 int,但数组类型的长度不(一个是 8,一个是 9)。

Go 的数组是值语义,这意味着一个数组变量表示的是整个数组,这点与 C 语言完全不同。在 C 语言中,数组变量可视为指向数组第一个元素的指针。因此,在 Go 语言中传递数组是纯粹的值拷贝,对于元素类型 Size 较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。这时很多人会使用数组指针类型来定义函数参数,然后将数组地址传进函数,这样做的确可以避免性能损耗,但这是 C 语言的惯用法,在 Go 语言中,更地道的方式是使用切片。

切片之于数组就像是文件描述符之于文件。在 Go 语言中,数组更多是“退居幕后”,承担的是底层存储空间的角色;而切片则走向“前台”,为底层的存储(数组)打开了一个访问的“窗口”。

一篇文章彻底弄懂理解和高效运用切片(slice)

因此,我们可以称切片是数组的“描述符”。切片之所以能在函数参数传递时避免较大性能损耗也是因为它是“描述符”的特性,切片这个描述符是固定大小的,无论底层的数组元素类型有多大和切片打开的窗口长度有多长。

下面是切片在 Go 运行时(runtime)层面的内部表示:

//$GOROOT/src/runtime/slice.go

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

我们看到每个切片包含三个字段:

  • array:是指向下层数组某元素的指针,该元素也是切片的起始元素;
  • len:是切片的长度,即切片中当前元素的个数;
  • cap:是切片的最大容量,cap >= len。

在运行时中,每个切片变量都是一个 runtime.slice 结构体的实例。我们用下面语句创建一个 slice 实例:

s := make([]byte, 5)

下面的图展示了切片的内部表示:

一篇文章彻底弄懂理解和高效运用切片(slice)

我们看到通过上述语句创建的切片,编译器会自动为切片建立一个底层数组,如果没有在 make 中指定 cap 参数,那么 cap = len,即编译器建立的数组长度为 len。

我们还可以创建对已存在数组进行操作的切片,这种语法 u[low:max] 称为数组的切片化:

u := [10]byte{11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
s := u[3:7]

我们看到切片 s 为我们打开了一个操作数组 u 的窗口,我们通过 s 看到的第一个元素是 u[3],我们通过 s 能看到并操作的数组元素为 4 个(max-low)。切片的 cap 值取决于底层数组的长度。我们看到从切片 s 的第一个元素 s[0],即 u[3] 到数组末尾一共有 7 个元素,因此切片 s 的 cap 为 7。

我们可以为一个已存在数组建立多个操作数组的切片。

一篇文章彻底弄懂理解和高效运用切片(slice)

我们看到既然上图中的三个切片 s1、s2、s3 都是数组 u 的“描述符”,那么无论通过哪个切片对数组进行的修改操作都会反映到其他切片中。比如:将 s3[0] 置为 24,那么 s1[2] 也会变成 24,因为 s3[0]直接操作的是底层数组 u 的第四个元素 u[3]。

当切片作为函数参数传递给函数时,实际传递的就是切片的内部表示,也就是上面的 runtime.slice 结构体实例,因此无论切片“描述”的底层数组有多大,切片作为参数传递带来的性能损耗都是小到可以忽略不计的。这就是为什么函数在参数中多使用切片而不用数组指针的原因之一。当然另外一个原因就是切片可以提供比指针更为强大的功能,比如下标访问、边界溢出校验、动态扩容等。指针本身在 Go 语言中的功能也受到的限制,比如不支持指针算术运算。

2. 切片的高级特性:动态扩容

如果仅仅是提供通过下标值来操作元素的类数组操作接口,那么切片就不会在 Go 中占据重要的位置。Go 切片还支持一个重要的高级特性:动态扩容。尽量定义零值可用的类”一节中我们提到过切片类型是“部分”满足零值可用理念的,即零值切片也可以通过 append 预定义函数进行元素赋值操作:

var s []byte // s被赋予零值nil
s = append(s, 1) 

由于初值为零值,s 这个“描述符”并没有绑定对应的底层数组。而经过 append 操作后,s 显然已经“绑定”了属于它的底层数组。为了方便查看切片是如何动态扩容的,我们打印出每次 append 后的 s 的 len 和 cap 值:

// slice_append.go
var s []int  // s被赋予零值nil
s = append(s, 11) 
fmt.Println(len(s), cap(s)) //1 1
s = append(s, 12) 
fmt.Println(len(s), cap(s)) //2 2
s = append(s, 13) 
fmt.Println(len(s), cap(s)) //3 4
s = append(s, 14) 
fmt.Println(len(s), cap(s)) //4 4
s = append(s, 15) 
fmt.Println(len(s), cap(s)) //5 8

我们看到 s 的 len 值是线性增长的,但 cap 值却呈现出不规则的变化。通过下图我们可以更容易看清楚多次 append 操作究竟是如何让 slice 进行动态扩容的。

一篇文章彻底弄懂理解和高效运用切片(slice)

我们看到 append 会根据 slice 对底层数组容量的需求对底层数组进行动态调整:

  • 最初 s 初值为零值(nil),此时 s 没有“绑定”底层数组;
  • 通过 append 操作向切片 s 添加一个元素 11,此时 append 会首先分配底层数组 u1(数组长度 1),然后将 s 内部表示中的 array 指向 u1,并设置 len = 1, cap = 1;
  • 通过 append 操作向切片 s 再添加一个元素 12,此时 len(s) = 1,cap(s) = 1,append 判断底层数组剩余空间不足以满足添加新元素的要求,于是创建了一个新的底层数组 u2,长度为 2(u1 数组长度的 2 倍),并将 u1 中的元素拷贝到 u2 中,最后将 s 内部表示中的 array 指向 u2,并设置 len = 2, cap = 2;
  • 通过 append 操作向切片 s 再添加一个元素 13,此时 len(s) = 2,cap(s) = 2,append 判断底层数组剩余空间不足以满足添加新元素的要求,于是创建了一个新的底层数组 u3,长度为 4(u2 数组长度的 2 倍),并将 u2 中的元素拷贝到 u3 中,最后将 s 内部表示中的 array 指向 u3,并设置 len = 3, cap 为 u3 数组长度,即 4 ;
  • 通过 append 操作向切片 s 再添加一个元素 14,此时 len(s) = 3, cap(s) = 4,append 判断底层数组剩余空间可以满足添加新元素的要求,于是将 14 放在下一个元素的位置(数组 u3 末尾),并将 s 内部表示中的 len 加 1,变为 4;
  • 通过 append 操作向切片 s 添加最后一个元素 15,此时 len(s) = 4,cap(s) = 4,append 判断底层数组剩余空间不足以满足添加新元素的要求,于是创建了一个新的底层数组 u4,长度为 8(u3 数组长度的 2 倍),并将 u3 中的元素拷贝到 u4 中,最后将 s 内部表示中的 array 指向 u4,并设置 len = 5, cap 为 u4 数组长度,即 8。

我们看到 append 会根据 slice 的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按一定规律扩展(这里针对元素是 int 型的数组,新数组的容量为当前数组的 2 倍。其他类型的扩展系数可能有所不同),新数组建立后,append 会把旧数组中的数据 copy 到新数组中,之后新数组便成为了 slice 的底层数组,旧数组会被垃圾回收掉。

append 操作有些时候会给 Gopher 带来一些困惑,比如通过语法 u[low:max] 形式进行数组切片化而创建的切片,一旦切片 cap 触碰到数组的上界,再对切片进行 append,切片就会和原数组的解除“绑定”:

package main

import "fmt"

func main() {
        u := []int{11, 12, 13, 14, 15}
        fmt.Println("array:", u) // [11, 12, 13, 14, 15]
        s := u[1:3]
        fmt.Printf("slice(len=%d, cap=%d): %v\n", len(s), cap(s), s) // [12, 13]
        s = append(s, 24)
        fmt.Println("after append 24, array:", u)
        fmt.Printf("after append 24, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
        s = append(s, 25)
        fmt.Println("after append 25, array:", u)
        fmt.Printf("after append 25, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
        s = append(s, 26)
        fmt.Println("after append 26, array:", u)
        fmt.Printf("after append 26, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)

        s[0] = 22
        fmt.Println("after reassign 1st elem of slice, array:", u)
        fmt.Printf("after reassign 1st elem of slice, slice(len=%d, cap=%d): %v\n", len(s), cap(s), s)
}

运行这段代码,我们得到如下结果:

array: [11 12 13 14 15]
slice(len=2, cap=4): [12 13]
after append 24, array: [11 12 13 24 15]
after append 24, slice(len=3, cap=4): [12 13 24]
after append 25, array: [11 12 13 24 25]
after append 25, slice(len=4, cap=4): [12 13 24 25]
after append 26, array: [11 12 13 24 25]
after append 26, slice(len=5, cap=8): [12 13 24 25 26]
after reassign 1st elem of slice, array: [11 12 13 24 25]
after reassign 1st elem of slice, slice(len=5, cap=8): [22 13 24 25 26]

我们看到当 append 25 之后,切片的元素已经触碰到了底层数组 u 的边界;此后再 append 26 后,append 发现底层数组已经无法满足 append 的要求,于是新创建了一个底层数组(数组长度为 cap(s)的 2 倍,即 8),并将 slice 的元素拷贝到新数组中了。这之后,我们即便再修改 slice 的第一个元素值,原数组 u 的元素也没有发生任何改变了,因为此时切片 s 与数组 u 已经解除了“绑定关系”,s 已经不再是数组 u 的“描述符”了。

3. 尽量使用 cap 参数创建 slice

我们看到 append 操作是一并利器,它让 slice 类型部分满足了“零值可用”的理念。但从 append 的原理中我们也能看到重新分配底层数组并拷贝元素的操作代价还是蛮大的,尤其是当元素较多的情况下。那么如何减少或避免为过多内存分配和拷贝付出的代价呢?一种有效的方法就是根据 slice 的使用场景在为新创建的 slice 赋初值时使用 cap 参数。

s := make([]T, 0, cap)

下面是一个使用 cap 和不使用 cap 参数的 slice 的基准测试:

package main

import "testing"

const sliceSize = 10000

func BenchmarkSliceInitWithoutCap(b *testing.B) {
        for n := 0; n < b.N; n++ {
                sl := make([]int, 0)
                for i := 0; i < sliceSize; i++ {
                        sl = append(sl, i)
                }
        }
}

func BenchmarkSliceInitWithCap(b *testing.B) {
        for n := 0; n < b.N; n++ {
                sl := make([]int, 0, sliceSize)
                for i := 0; i < sliceSize; i++ {
                        sl = append(sl, i)
                }
        }
}

下面是基本测试运行的结果(go 1.12.7,macbook pro i5 8 核,16G 内存):

goos: darwin
goarch: amd64
BenchmarkSliceInitWithoutCap-8          50000         36484 ns/op      386297 B/op          20 allocs/op
BenchmarkSliceInitWithCap-8            200000          9250 ns/op       81920 B/op           1 allocs/op
PASS
ok      command-line-arguments    4.163s

通过结果我们看到,使用 cap 参数创建的 slice 进行 append 操作的平均性能(9250ns)是不带 cap 参数的 slice(36484ns)的 4 倍左右,并且每操作平均仅需一次内存分配。

因此,如果可以预估出切片底层数组需要承载的元素数量,强烈建议在创建 slice 时带上 cap 参数。

4. 小结

切片是 Go 语言提供的重要数据类型,也是 Gopher 日常 Go 编码是最常使用的类型之一。切片是数组的“描述符”,在大多数场合替代了数组,并减少了数组指针作为函数参数的使用。

append 在切片上的运用让切片类型部分支持了零值可用的理念,并且 append 对 slice 的动态扩充将 Gopher 们从手工管理底层存储的工作中解放了出来。

在可以预估出元素容量的前提下,使用 cap 参数创建 slice 可以提升 append 的平均操作性能,减少或消除因动态扩容带来的性能损耗。

收藏
评论区

相关推荐

golang 中神奇的 slice
声明:本文仅限于简书发布,其他第三方网站均为盗版,原文地址: golang 中神奇的 slice(https://links.jianshu.com/go?tohttps%3A%2F%2Fliqiang.io%2Fpost%2Fimagesliceingolang) 在 golang 中,似乎人们都不太喜欢使用 Linked List,甚至于原
/deep/和>>>和::v-deep
/deep/ 在style经常用scoped属性实现组件的私有化时,但是要改变elementui某个深层元素(eg:.elinput__inner)或其他深层样式时,需要使用/deep/,如下: .conBox /deep/ .elinput__inner{ padding:0 10px; } ::vdeep 注意:/deep/在vu
协变和逆变
本文同步发表于我的微信公众号,在微信搜索 OpenCV or Android 即可关注。 协变、逆变 概念 许多程序设计语言的类型系统支持子类型。例如,如果Cat是Animal的子类型,那么Cat类型的表达式可用于任何出现Animal类型表达式的地方。所谓的变型(variance)是指如何根据组成类型之间的子类型关系,来确定更复杂的类型之间(例如C
Go语言中new()和make()的区别
1. Go语言中的值类型和引用类型 值类型:int,float,bool,string,struct和数组 (数组要特别注意,别搞混了) 变量直接存储值,分配栈区的内存空间,这些变量所占据的空间在函数被调用完后会自动释放。 引用类型:slice,map,chan和值类型对应的指针 变量存储的是一个地址(或者理解为指针),指针指向内存中真
go语言中,数组与切片的区别?
切片是Go 语言核心的数据结构,然而刚接触 Go 的程序员经常在切片的工作方式和行为表现上被绊倒。比如,明明说切片是引用类型但在函数内对其做的更改有时候却保留不下来,有时候却可以。究其原因是因为我们很多人用其他语言的思维来尝试猜测 Go 语言中切片的行为,切片这个内置类型在 Go 语言底层有其单独的类型定义,而不是我们通常理解的其他语言中数组的概念。 文章
深入理解 Go Slice
(https://imghelloworld.osscnbeijing.aliyuncs.com/0ce8a8773a658d4b843e5796a0dbf001.png) image 原文地址:深入理解 Go Slice(https://github.com/EDDYCJY/blog/blob/master/golang/pkg/20
mongodb和python
MongoDB的安装与使用 当前学习环境的 Ubuntu 16.04 中已经安装好了MongoDB,版本为3.2.8,可以直接跳过此节. 下载mongodb的版本,两点注意1. 根据业界规则,偶数为稳定版,如3.2.X;奇数为开发版,如3.3.X2. 32bit的mongodb最大只能存放2G的数据,64bit就没有限制 MongoDB官网安装
LinkedBlockingQueue和ArrayBlockingQueue区别和注意点
LinkedBlockingQueue和ArrayBlockingQueue 俩个使用注意我们创建一个全局线程池的时候会传一个这样的类型进去,这里就需要注意下俩个的区别通俗来说LinkedBlockingQueue会同步ArrayBlockingQueue 则是你的正常思维异步,所以前者也会更占用内存。使用时机你要自己注意了。另外LinkedBloc
[go-linq]-Go的.NET LINQ式查询方法
关于我 开发者的福音,go也支持linq了 坑爹的集合go在进行集合操作时,有很不舒服的地方,起初我真的是无力吐槽,又苦于找不到一个好的第三方库,只能每次写着重复代码。举个栗子类 学生{姓名 年龄性别}1、现在有10个学生的数组,如果我要统计所有年龄大于20岁的人,那我需要一、遍历二、自定义条件三、再append数组添加。2、接着我又
一篇文章彻底弄懂理解和高效运用切片(slice)
slice,中文多译为“切片”,是 Go 语言在数组之上提供的一个重要的抽象数据类型。在 Go 语言中,绝大多数需要使用数组的场合,切片都实现了完美替代。并且和数组相比,切片提供了更通用、功能更强大且便捷的数据序列访问接口。 1. 切片究竟是什么在对切片一探究竟之前,我们先来简略了解一下 Go 语言中的数组。Go 语言数组是一个固定长度的、容纳同构类型元素的
js堆和栈
浅析js中堆内存和栈内存我们常遇见的 var let const区别cont定义的基本类型不能改变,但是定义的对象是可以通过修改对象属性等方法来改变的 const a 1;console.log(a) //a;cosnole.log(a 2) //报错const b ;b.name 1;console.log(b) //name : 1con
人工智能数学基础1:三角函数的定义、公式及固定角三角函数值
一、三角函数的定义及名称在直角三角形中,当平面上的三点A、B、C的连线,AB、AC、BC,构成一个直角三角形,其中∠ACB为直角。对∠BAC(在此简称为θ)而言,对边(opposite)aBC、斜边(hypotenuse)cAB、邻边(adjacent)bAC,则三角函数定义如下:二、三角函数的变化趋势及图像  正弦值在 \[2kππ/2,2kπ+π/2
关于根据颜色刷选图像内容的问题
在CSDN本人博文《OpenCVPython图像处理:用inRange刷选图像中指定颜色对象案例》(请点击文章底部最下方的“阅读原文”跳转CSDN阅读原文)中介绍了根据颜色刷选图像内容相关的概念及实现,介绍了通过使用inRange在HSV颜色空间中识别制定颜色的图像内容,文中概要介绍了HSV颜色空间中进行制定颜色对象识别的要点,使用的inRange函数的语法
盘点Python基础之列表的那些事儿
大家好,我是蔡同学,今天给大家分享列表的知识一、列表的格式示例: namesList ['xiaoWang','xiaoZhg','xiaa']比C语言的数组强大的地方在于列表中的元素可以是不同类型的。 testList [1, 'a']二、列表的相关操作("增"、"删"、"改",“查”) <1 添加元素append()通过append可以向列表添
Qt文件和json
qt中创建普通c++类头文件cppifndef OPERATIONHdefine OPERATIONHinclude <QFile// 默认配置文件路径const QString FilePath "./config.json";typedef struct FileConfig QString ip; QString port; QS