likes
comments
collection
share

一文总结Go语言切片核心知识点和坑

作者站长头像
站长
· 阅读数 22

前言

都说Go的切片用起来丝滑得很,Java中的List怎么用,切片就怎么用,作为曾经的Java选手,这切片简直就是我的舒适区呀,结果就是不出意外的出意外了,因为切片的使用不得当,喜提缺陷若干。好吧我承认之前是大意了没有闪,现在沉下心好好学习并总结一下切片。

正文

一. 切片结构说明

切片(slice),是Go语言中对可变数组的抽象,相较于数组,具有可动态追加元素,可动态扩容等更加实用的特性。切片在Go语言中对应的结构体源码如下。

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

字段含义说明如下。

  • array,指向数组内存地址的指针。切片底层存储数据的结构就是数组,切片使用一个指向数组的指针来操作这个数组;
  • len,切片的长度。len表示切片中可用的元素个数,所谓可用,就是内存空间被分配,并且通过当前切片能够访问;
  • cap,切片的容量。cap表示切片最大的元素个数,通常cap大于等于len,如果cap大于len,表示当前切片有部分元素不可用,而不可用的意思就是内存空间被分配,但是当前切片无法访问,访问这些不可用元素会导致panic

一个切片的示意图如下。

一文总结Go语言切片核心知识点和坑

二. 切片创建

切片的创建有多种方式,本节结合示例和图示,对切片的不同创建方式进行说明。

1. 直接创建切片

示例代码如下所示。

// 直接创建切片
func directlyCreate() {
    slice := []int{1, 2, 3, 4, 5, 6, 7}
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(slice), cap(slice), &slice, slice)
}

上述示例代码运行打印如下。

len=7, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014240

此时创建出来的切片对应图示如下。

一文总结Go语言切片核心知识点和坑

直接创建切片时,会为切片的底层数组开辟内存空间并使用指定的元素对数组完成初始化,且创建出来的切片的len等于cap等于初始化元素的个数。

2. 从整个数组切得到切片

切片,顾名思义,其实就是可以从已经存在的数组上切一段作为切片,本小节演示直接切整个数组得到切片,示例代码如下所示。

// 从整个数组切得到切片
func sliceFromWholeArray() {
    originArray := [7]int{0, 1, 2, 3, 4, 5, 6}

    slice := originArray[:]

    fmt.Printf("originArrayAddress=%p\n", &originArray)
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(slice), cap(slice), &slice, slice)
}

上述示例代码运行打印如下。

originArrayAddress=0xc000014240
len=7, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014240

此时创建出来的切片对应图示如下。

一文总结Go语言切片核心知识点和坑

从整个数组切,实际就是切片直接使用了这个数组作为底层的数组。

3. 从前到后切数组得到切片

本小节演示从前到后切数组得到切片,示例代码如下所示。

// 从前到后切数组得到切片
func sliceFromArrayFrontToBack() {
    originArray := [7]int{0, 1, 2, 3, 4, 5, 6}

    slice := originArray[:4]

    fmt.Printf("origin array address is: [%p]\n", &originArray)
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(slice), cap(slice), &slice, slice)
}

上述在切数组时,没有指定数组的开始索引,表示从索引0开始切(inclusive),指定了数组的结束索引,表示切到结束索引的位置(exclusive),运行示例代码,打印如下。

originArrayAddress=0xc000014240
len=4, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014240

此时创建出来的切片对应图示如下。

一文总结Go语言切片核心知识点和坑

从前到后切数组得到的切片,len等于切的范围的长度,对应示例中索引0(inclusive)到索引4(exclusive)的长度4,而cap等于切的开始位置(inclusive)到数组末尾(inclusive)的长度7。

4. 从数组中间切到最后得到切片

本小节演示从数组中间切到最后得到切片,示例代码如下所示。

// 从数组中间切到最后得到切片
func sliceFromArrayMiddleToLast() {
    originArray := [7]int{0, 1, 2, 3, 4, 5, 6}

    slice := originArray[4:]

    fmt.Printf("originArrayAddress=%p\n", &originArray)
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(slice), cap(slice), &slice, slice)
}

上述在切数组时,指定了数组的开始索引,表示从索引4(inclusive)开始切,没有指定数组的结束索引,表示切到数组的末尾(inclusive),运行示例代码,打印如下。

originArrayAddress=0xc000014240
len=3, cap=3, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014260

此时创建出来的切片对应图示如下。

一文总结Go语言切片核心知识点和坑

从数组中间切到最后得到的切片,len等于cap等于切的范围的长度,对应示例中索引4(inclusive)到数组末尾(inclusive)的长度3。并且由上述图示可以看出,切片使用的底层数组其实还是被切的数组,只不过使用的是被切数组的一部分。

5. 从数组切一段得到切片

本小节演示从数组切一段得到切片,示例代码如下所示。

// 从数组切一段得到切片
func sliceFromSelectionOfArray() {
    originArray := [7]int{0, 1, 2, 3, 4, 5, 6}

    slice := originArray[2:4]

    fmt.Printf("originArrayAddress=%p\n", &originArray)
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(slice), cap(slice), &slice, slice)
}

上述在切数组时,指定了数组的开始索引,表示从索引2(inclusive)开始切,也指定了数组的结束索引,表示切到数组的索引4的位置(exclusive),运行示例代码,打印如下。

originArrayAddress=0xc000014240
len=2, cap=5, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014250

此时创建出来的切片对应图示如下。

一文总结Go语言切片核心知识点和坑

从数组切一段得到的切片,len等于切的范围的长度,对应示例中索引2(inclusive)到索引4(exclusive)的长度2,cap等于切的开始位置(inclusive)到数组末尾(inclusive)的长度5。并且,切片使用的底层数组还是被切数组的一部分。

6. 从切片切得到切片

除了切数组得到切片,还能切切片来得到切片,示例代码如下所示。

// 从切片切得到切片
func sliceFromSlice() {
    originArray := [7]int{0, 1, 2, 3, 4, 5, 6}

    originSlice := originArray[:]

    derivedSlice := originSlice[2:4]

    fmt.Printf("originArrayAddress=%p\n", &originArray)
    fmt.Printf("originSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(originSlice), cap(originSlice), &originSlice, originSlice)
    fmt.Printf("derivedSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(derivedSlice), cap(derivedSlice), &derivedSlice, derivedSlice)
}

上述示例代码中,originSlice是切数组originArray得到的切片,derivedSlice是切切片originSlice得到的切片,运行示例代码,打印如下。

originArrayAddress=0xc000014240
originSlice: len=7, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014240
derivedSlice: len=2, cap=5, sliceAddress=0xc000004090, sliceArrayAddress=0xc000014250

此时创建出来的切片对应图示如下。

一文总结Go语言切片核心知识点和坑

从切片切得到切片后,两个切片会使用同一个底层数组,区别就是可能使用的是底层数组的不同区域,因此如果其中一个切片更改了数据,而这个数据恰好另一个切片可用访问,那么另一个切片访问该数据时就会发现数据发生了更改。但是请注意,虽然两个切片使用同一个底层数组,但是切片的lencap都是独立的,也就是假如其中一个切片通过类似于append() 函数导致len或者cap发生了更改,此时另一个切片的len或者cap是不会受影响的。

7. 使用make函数得到切片

make() 函数专门用于为slicemapchan这三种引用类型分配内存并完成初始化,make() 函数返回的就是引用类型对应的底层结构体本身,使用make() 函数创建slice的示例代码如下所示。

// 使用make函数得到切片
func initializeByMake() {
    slice := make([]int, 5, 7)

    fmt.Printf("len=%d, cap=%d, slice=%v\n",
            len(slice), cap(slice), slice)
}

上述示例代码中,会使用make() 函数创建一个int类型的切片,并指定len为5(第二个参数指定),cap为7(第三个参数指定),其中可以不指定cap,此时cap会取值为len。运行示例代码,打印如下。

len=5, cap=7, slice=[0 0 0 0 0]

此时访问索引5或索引6的元素,会引发panic,示例代码修改如下。

// 使用make函数得到切片
func initializeByMake() {
    slice := make([]int, 5, 7)

    fmt.Printf("len=%d, cap=%d, slice=%v\n",
            len(slice), cap(slice), slice)

    // 访问索引5或索引6会引发panic
    defer func() {
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()

    fmt.Println(slice[5])
}

运行示例代码,打印如下。

len=5, cap=7, slice=[0 0 0 0 0]
runtime error: index out of range [5] with length 5

那么索引5和索引6的元素怎么才能使用呢,就需要使用到内建函数append(),示例代码修改如下。

// 使用make函数得到切片
func initializeByMake() {
    slice := make([]int, 5, 7)

    fmt.Printf("len=%d, cap=%d, slice=%v\n",
            len(slice), cap(slice), slice)

    slice = append(slice, 1)
    slice = append(slice, 2)

    fmt.Printf("len=%d, cap=%d, slice=%v\n",
            len(slice), cap(slice), slice)
}

运行示例代码,打印如下。

len=5, cap=7, slice=[0 0 0 0 0]
len=7, cap=7, slice=[0 0 0 0 0 1 2]

append() 函数的使用,会在本文后面章节进行说明。

三. 声明切片占用内存分析

使用如下方式声明切片。

var slice []int

然后使用如下方式打印仅声明的切片占用的内存大小。

fmt.Printf("memory=%d\n", unsafe.Sizeof(slice))

占用内存大小打印如下。

memory=24

也就是仅声明的切片会占用24字节的内存大小,这个大小实际就是切片底层结构体占用的大小,如下所示。

type slice struct {
    array unsafe.Pointer      // 64位操作系统下占8字节
    len   int		    // 64位操作系统下占8字节
    cap   int		    // 64位操作系统下占8字节
}

三. append函数

append() 函数用于将元素附加到切片的末尾,比如一个切片len为5,cap为7,此时可以使用append() 函数附加一个元素到切片的末尾使得切片的len变为6(cap此时不会改变),从而切片索引为5的位置的元素也可以读和写了。本节将对append() 函数的使用进行说明。

1. 基本使用

append() 函数声明如下所示。

func append(slice []Type, elems ...Type) []Type

append() 函数会将附加了元素后的切片返回,所以我们需要用切片变量来存储append() 函数的返回值。

append() 函数的基本使用示例如下所示。

// 向切片附加元素
func appendItem() {
    slice := make([]int, 2, 7)

    fmt.Printf("len=%d, cap=%d, slice=%v\n",
            len(slice), cap(slice), slice)

    slice = append(slice, 10)

    fmt.Printf("len=%d, cap=%d, slice=%v\n",
            len(slice), cap(slice), slice)
}

运行示例代码,打印如下。

len=2, cap=7, slice=[0 0]
len=3, cap=7, slice=[0 0 10]

对应图示如下所示。

一文总结Go语言切片核心知识点和坑

2. 触发扩容

已知切片有lencaplen表示可以读和写的元素个数,cap表示当前切片最大的元素个数,那么cap减去len就是当前切片还没有使用的元素个数,这部分元素需要通过append() 函数来附加到切片上。

当附加元素到切片上后,会让len加1,但是如果附加元素到切片之前len已经等于cap,那么此时会先触发扩容再附加元素,扩容的流程简图如下所示。

一文总结Go语言切片核心知识点和坑

一旦触发扩容,会创建新容量大小的数组,然后将老数组的数据拷贝到新数组上,再然后将附加元素添加到新数组中,最后切片的array指向新数组。也就是说,切片扩容会导致切片使用的底层数组地址发生变更,如下是示例代码。

// 扩容导致切片数组地址变更
func expansionCausedArrayAddressChange() {
    // 原始数组
    originArray := [7]int{0, 1, 2, 3, 4, 5, 6}
    // 原始切片
    originSlice := originArray[0:6]
    // 打印原始切片和原始数组的信息
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p, originArrayAddress=%p\n",
            len(originSlice), cap(originSlice), &originSlice, originSlice, &originArray)

    // 第一次append不会触发扩容
    firstAppendSlice := append(originSlice, 7)
    // 打印第一次Append后的切片和原始数组的信息
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p, originArrayAddress=%p\n",
            len(firstAppendSlice), cap(firstAppendSlice), &firstAppendSlice, firstAppendSlice, &originArray)

    // 第二次append会触发扩容
    secondAppendSlice := append(firstAppendSlice, 8)
    // 打印第二次Append后的切片和原始数组的信息
    fmt.Printf("len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p, originArrayAddress=%p\n",
            len(secondAppendSlice), cap(secondAppendSlice), &secondAppendSlice, secondAppendSlice, &originArray)
}

运行示例代码,打印如下。

len=6, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014240, originArrayAddress=0xc000014240
len=7, cap=7, sliceAddress=0xc0000040a8, sliceArrayAddress=0xc000014240, originArrayAddress=0xc000014240
len=8, cap=14, sliceAddress=0xc0000040d8, sliceArrayAddress=0xc0000100e0, originArrayAddress=0xc000014240

在示例代码中,切数组originArray得到的切片如下所示。

一文总结Go语言切片核心知识点和坑

第一次append元素后,切片如下所示。

一文总结Go语言切片核心知识点和坑

第二次append元素时,会触发扩容,扩容后的切片如下所示。

一文总结Go语言切片核心知识点和坑

可见,扩容后切片使用了另外一个数组作为了底层数组。

四. 切片注意事项

1. 问题演示

如下是一个踩坑的反面案例,代码如下所示。

// 将slice作为参数传递到函数中
func appendTriggerExpansionCausingProblems() {
    outerSlice := make([]int, 6, 7)
    fmt.Printf("OuterSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(outerSlice), cap(outerSlice), &outerSlice, outerSlice)

    helpAppendNums(outerSlice)

    fmt.Printf("OuterSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(outerSlice), cap(outerSlice), &outerSlice, outerSlice)
}

func helpAppendNums(innerSlice []int) {
    for i := 0; i < 3; i++ {
        innerSlice = append(innerSlice, i)
    }
    fmt.Printf("InnerSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(innerSlice), cap(innerSlice), &innerSlice, innerSlice)
}

运行示例代码,打印如下。

OuterSlice: len=6, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014240
InnerSlice: len=9, cap=14, sliceAddress=0xc0000040a8, sliceArrayAddress=0xc0000100e0
OuterSlice: len=6, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014240

反面案例的代码实现中,把一个len为6,cap为7的切片outterSlice作为参数传递到了一个helpAppendNums() 函数中,并在这个函数中进行了3次append元素的操作,然后发现outterSlicelencap和底层数组的地址都没有发生改变。

下面将结合图示,对反面案例代码整个流程进行说明。

outerSlice创建出来时,outerSlice结构如下所示。

一文总结Go语言切片核心知识点和坑

因为Go语言中都是值传递,且切片变量本质就是一个结构体,所以把outerSlice作为实参调用helpAppendNums() 函数时,会把outerSlice值拷贝给到helpAppendNums() 函数的形参innerSlice,此时两个切片的结构如下所示。

一文总结Go语言切片核心知识点和坑

这里其实就是一个容易踩坑特别是Go语言新手()容易踩坑的地方,因为在学习Go中的数据类型时,各种资料总是会告诉我在Go语言中有值类型引用类型的区分,而引用类型就是slice切片,map字典,interface接口,func函数和chan管道,此时如果这个Go语言新手()还会一点Java,那么就会认为Go里面的sliceJava中的List一样,毕竟都是引用嘛,那么把一个引用类型的slice传递到一个函数中,当然在函数中操作的slice就应该是传递的这个slice

上面用删除线划掉的部分,如果你真的有这样的认识,要么就是对Go的引用类型理解得有偏差(),要么就是对Java中声明一个指向对象的变量时的内存结构没有清晰的认识(),或者都是()。先说Java中声明一个指向对象的变量,就像下面这样。

List list = new ArrayList();

那么按照我们的正常认知,上面的list是一个引用对吧,但是这里的list实际是一个指向堆上一个ArrayList对象的指针,存在于线程栈帧的局部变量表里,我们无论如何传递list变量(Java中也是值传递),实际都是传递的指向堆上ArrayList对象的指针,最终操作的都是堆上的ArrayList对象。

回到Go中的切片,我们声明并创建一个切片,就像下面这样。

slice := []int{1, 2, 3, 4, 5, 6, 7}

上面的slice字段就是一个切片结构体,那么把这个slice字段传递到函数中时,是会直接拷贝这个切片给到函数的形参,此时实参和形参实际是两个不同的切片,在内存中有自己的空间和地址,只不过底层使用的是同一个数组而已,这里的拷贝,也称作浅拷贝

上面的坑踩完后,再回到反面案例代码中,在helpAppendNums() 函数中会执行3次append元素的操作,在执行完第一次append操作后,outerSliceinnerSlice的结构如下所示。

一文总结Go语言切片核心知识点和坑

执行完第一次append操作后,对于innerSlice来说len已经等于cap了,又因为append的操作目标是innerSlice,所以尽管底层数组数据发生了变更,但outerSlicelen是没有发生变动的,并且也无法访问索引为6的元素。

执行完第二次append操作后,outerSliceinnerSlice的结构如下所示。

一文总结Go语言切片核心知识点和坑

因为第一次append后,innerSlicelen已经和cap相等,所以第二次append时,innerSlice使用的底层数组是一个新的且容量翻倍的数组,那么从这时起,outerSliceinnerSlice使用的底层数组也不同了。

执行完第三次append操作后,outerSliceinnerSlice的结构如下所示。

一文总结Go语言切片核心知识点和坑

2. 解决方式

既然把切片直接传到函数中存在一些坑,那么相应的就需要一些手段来解决。

第一种解决方式就是不直接传递切片,而是传递切片的指针,改进代码如下所示。

// 将slice作为参数传递到函数中
func appendTriggerExpansionCausingProblems() {
    outerSlice := make([]int, 6, 7)
    fmt.Printf("OuterSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(outerSlice), cap(outerSlice), &outerSlice, outerSlice)

    helpAppendNumsUsePointer(&outerSlice)

    fmt.Printf("OuterSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(outerSlice), cap(outerSlice), &outerSlice, outerSlice)
}

func helpAppendNumsUsePointer(innerSlice *[]int) {
    for i := 0; i < 3; i++ {
        *innerSlice = append(*innerSlice, i)
    }
    fmt.Printf("InnerSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(*innerSlice), cap(*innerSlice), innerSlice, *innerSlice)
}

运行改进代码,打印如下。

OuterSlice: len=6, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014200
InnerSlice: len=9, cap=14, sliceAddress=0xc000004078, sliceArrayAddress=0xc0000100e0
OuterSlice: len=9, cap=14, sliceAddress=0xc000004078, sliceArrayAddress=0xc0000100e0

这种方式的好处从内存的角度来说,仅进行了一次指针的值传递,对内存更友好。

第二种解决方式就是在函数中将处理后的切片返回,改进代码如下所示。

// 将slice作为参数传递到函数中
func appendTriggerExpansionCausingProblems() {
    outerSlice := make([]int, 6, 7)
    fmt.Printf("OuterSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(outerSlice), cap(outerSlice), &outerSlice, outerSlice)

    outerSlice = helpAppendNumsAndReturn(outerSlice)

    fmt.Printf("OuterSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(outerSlice), cap(outerSlice), &outerSlice, outerSlice)
}

func helpAppendNumsAndReturn(innerSlice []int) []int {
    for i := 0; i < 3; i++ {
        innerSlice = append(innerSlice, i)
    }
    fmt.Printf("InnerSlice: len=%d, cap=%d, sliceAddress=%p, sliceArrayAddress=%p\n",
            len(innerSlice), cap(innerSlice), &innerSlice, innerSlice)

    return innerSlice
}

运行改进代码,打印如下。

OuterSlice: len=6, cap=7, sliceAddress=0xc000004078, sliceArrayAddress=0xc000014200
InnerSlice: len=9, cap=14, sliceAddress=0xc0000040a8, sliceArrayAddress=0xc0000100e0
OuterSlice: len=9, cap=14, sliceAddress=0xc000004078, sliceArrayAddress=0xc0000100e0

相较于第一种方式,由指针的值传递变更为了结构体的值传递,内存相对不友好。

总结

一图流。

一文总结Go语言切片核心知识点和坑

后记

切片的结构简洁明了,名字形象生动,使用灵活方便,完美符合Go语言的设计原则,但是为啥在我手里就用出了这么多篓子呢。

我一再反思,发现根因还是对于Go语言中的引用类型不够理解,以及缺失对切片的底层源码实现的了解,所以本文只能从现象入手讨论切片的一些浅层次的使用,后续还是要深入源码,结合Go语言的内存模型详细学习切片。