Press "Enter" to skip to content

Go 数据类型篇(八):切片使用入门与数据共享问题处理

前一篇教程里我们已经介绍过数组的一个特点:数组的长度在定义之后无法修改,数组长度是数组类型本身的一部分,是数组的一个内置常量,因此我们无法在数组上做动态的元素增删操作。

显然这种数据结构无法完全满足开发者的日常开发需求,尤其是从动态语言转过来的开发人员(如 PHP),为此,Go 语言提供了切片(slice)来弥补数组的不足,切片一个最强大的功能就是支持对元素做动态增删操作,在介绍动态增删元素之前,我们先来了解下切片的定义和创建。

切片的定义

在 Go 语言中,切片是一个新的数据类型,与数组最大的不同在于,切片的类型字面量中只有元素的类型,没有长度:

var slice []string = []string{"a", "b", "c"}

因此它是一个可变长度的、同一类型元素集合,切片的长度可以随着元素数量的增长而增长(但不会随着元素数量的减少而减少),不过切片从底层管理上来看依然使用数组来管理元素,可以看作是对数组做了一层简单的封装。基于数组,切片添加了一系列管理功能,可以随时动态扩充存储空间,下面我们就来看看数组切片的创建和使用。

创建切片

创建切片的方法主要有三种 —— 基于数组、切片和直接创建,下面我们来简要介绍一下这几种方法。

基于数组

切片可以基于一个已存在的数组创建。从这个层面来说,数组可以看作是切片的底层数组,而切片则可以看作是数组某个连续片段的引用。切片可以只使用数组的一部分元素或者整个数组来创建,甚至可以创建一个比所基于的数组还要大的切片:

// 先定义一个数组
months := [...]string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}

// 基于数组创建切片
q2 := months[3:6]    // 第二季度
summer := months[5:8]  // 夏季

fmt.Println(q2)
fmt.Println(summer)  

运行结果为:

[April May June]
[June July August]

Go 语言支持通过 array[start:end] 这样的方式基于数组生成一个切片,start 表示切片在数组中的下标起点,end 表示切片在数组中的下标终点,两者之间的元素就是切片初始化后的元素集合,通过上面的示例可以看到,和字符串切片一样,这也是个左闭右开的集合,下面几种用法也都是合法的:

基于 months 的所有元素创建切片(全年)

all := months[:]

基于 months 的前 6 个元素创建切片(上半年)

firsthalf := months[:6]

基于从第 6 个元素开始的后续元素创建切片(下半年)

secondhalf := months[6:]

另外,通过这个示例,还可以进一步探讨切片底层的结构。

切片底层引用了一个数组,由三个部分构成 —— 指针、长度和容量,指针指向数组起始下标,长度对应切片中元素的个数,容量则是切片起始位置到底层数组结尾的位置:

切片长度不能超过容量,比如上面的数组切片 q2,其指针指向底层数组 months 下标为 3 的位置,切片长度是3,切片容量是9(从下标 3 开始到下标 11 结束,可容纳 9 个元素),和数组一样,我们可以通过内置函数 len 获取切片的长度,此外还可以通过 cap 函数获取切片容量:

fmt.Println(len(q2))   // 3
fmt.Println(cap(q2))   // 9

基于切片

类似于切片可以基于一个数组创建,切片也可以基于另一个切片创建:

firsthalf := months[:6]
q1 := firsthalf[:3] // 基于 firsthalf 的前 3 个元素构建新切片

基于 firsthalf 创建切片时,选择的 firsthalf 元素范围可以超过所包含的元素个数,比如 q1 可以基于firsthalf 的前 9 个元素创建:

q1 := firsthalf[:9]

打印结果是:[January February March April May June July August September]

因为 firsthalf 的容量是 12,只要选择的范围不超过 firsthalf 的容量,那么这个创建操作就是合法的,所以虽然是基于切片创建切片,但本质上还是基于数组。

直接创建

并非一定要事先准备一个数组才能创建切片,Go 语言提供的内置函数 make() 可以用于灵活地创建切片。

例如要创建一个初始长度为 5 的整型切片,可以这么做:

mySlice1 := make([]int, 5)

要创建一个初始长度为 5、容量为 10 的整型切片,可以这么做(通过第三个参数设置容量):

mySlice2 := make([]int, 5, 10)

此外,还可以直接创建并初始化包含 5 个元素的数组切片(长度和容量均为5):

mySlice3 := []int{1, 2, 3, 4, 5}

和数组一样,所有未初始化的切片,会填充元素类型对应的零值。

事实上,使用直接创建的方式来创建切片时,Go 底层还是会有一个匿名数组被创建出来,然后调用基于数组创建切片的方式返回切片,只是上层不需要关心这个匿名数组的操作而已。所以,最终切片都是基于数组创建的,切片可以看做是操作数组的指针。

遍历切片

由于切片可以看作是是数组指针,因此,操作数组元素的所有方法都适用于切片,比如切片也可以按下标读写元素,用 len() 函数获取元素个数,并支持使用 range 关键字来快速遍历所有元素。

传统的元素遍历方法如下:

for i := 0; i < len(summer); i++ {
    fmt.Println("summer[", i, "] =", summer[i]) 
}

打印结果如下:

summer[ 0 ] = June
summer[ 1 ] = July
summer[ 2 ] = August

使用 range 关键字可以让遍历代码显得更简洁,range 表达式有两个返回值,第一个是索引,第二个是元素的值:

for i, v := range summer { 
    fmt.Println("summer[", i, "] =", v) 
}

两种方式打印结果完全一致。

动态增加元素

切片比数组更强大之处在于支持动态增加元素,甚至可以在容量不足的情况下自动扩容。在切片类型中,元素个数和实际可分配的存储空间是两个不同的值,元素的个数即切片的实际长度,而可分配的存储空间就是切片的容量。

一个切片的容量初始值根据创建方式的不同而不同:

  • 对于基于数组和切片创建的切片而言,默认容量是从切片起始索引到对应底层数组的结尾索引;
  • 对于通过内置 make 函数创建的切片而言,在没有指定容量参数的情况下,默认容量和切片长度一致。

所以,通常一个切片的长度值小于等于其容量值,我们可以通过 Go 语言内置的 cap() 函数和 len() 函数来获取某个切片的容量和实际长度:

var oldSlice = make([]int, 5, 10)

fmt.Println("len(oldSlice):", len(oldSlice))
fmt.Println("cap(oldSlice):", cap(oldSlice))

程序运行结果如下:

len(oldSlice): 5
cap(oldSlice): 10

此时,切片 oldSlice 的默认值是 [0 0 0 0 0],我们可以通过 append() 函数向切片追加新元素:

newSlice := append(oldSlice, 1, 2, 3)

将返回的新切片赋值给 newSlice,此时 newSlice 的长度是 8,容量是 10,切片值是:

[0 0 0 0 0 1 2 3]

函数 append() 的第二个参数是一个不定参数,我们可以按自己需求添加若干个元素(大于等于 1 个),甚至直接将一个切片追加到另一个切片的末尾:

appendSlice := []int{1, 2, 3, 4, 5}
newSlice := append(oldSlice, appendSlice...)  // 注意末尾的 ... 不能省略

自动扩容

如果追加的元素个数超出 oldSlice 的默认容量,则底层会自动进行扩容:

newSlice := append(oldSlice, 1, 2, 3, 4, 5, 6)
fmt.Println(newSlice)
fmt.Println(len(newSlice))
fmt.Println(cap(newSlice))

此时 newSlice 的长度变成了 11,容量变成了 20,需要注意的是 append() 函数并不会改变原来的切片,而是会生成一个容量更大的切片,然后把原有的元素和新元素一并拷贝到新切片中。

默认情况下,扩容后新切片的容量将会是原切片容量的 2 倍,如果还不足以容纳新元素,则按照同样的操作继续扩容,直到新容量不小于原长度与要追加的元素数量之和。但是,当原切片的长度大于或等于 1024 时,Go 语言将会以原容量的 1.25 倍作为新容量的基准。

因此,如果事先能预估切片的容量并在初始化时合理地设置容量值,可以大幅降低切片内部重新分配内存和搬送内存块的操作次数,从而提高程序性能。

内容复制

切片类型还支持 Go 语言的另一个内置函数 copy(),用于将元素从一个切片复制到另一个切片。如果两个切片不一样大,就会按其中较小的那个切片的元素个数进行复制。

下面的示例展示了 copy() 函数的行为:

slice1 := []int{1, 2, 3, 4, 5} 
slice2 := []int{5, 4, 3}

// 复制 slice1 到 slice 2
copy(slice2, slice1) // 只会复制 slice1 的前3个元素到 slice2 中
// slice2 结果: [1, 2, 3]
// 复制 slice2 到 slice 1
copy(slice1, slice2) // 只会复制 slice2 的 3 个元素到 slice1 的前 3 个位置
// slice1 结果:[5, 4, 3, 4, 5]

动态删除元素

切片除了支持动态增加元素之外,还可以动态删除元素,在切片中动态删除元素可以通过多种方式实现(其实是通过切片的切片实现的「伪删除」):

slice3 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
slice3 = slice3[:len(slice3) - 5]  // 删除 slice3 尾部 5 个元素
slice3 = slice3[5:]  // 删除 slice3 头部 5 个元素

此时切片 slice3 的所有元素被删除,长度是0,容量也变成 5,注意这里不是自动缩容,而是第二个切片容量计算逻辑决定的。

此外,还可以通过上述介绍的 append 函数和 copy 函数实现切片元素的「删除」:

slice3 := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    
slice4 := append(slice3[:0], slice3[3:]...)  // 删除开头三个元素
slice5 := append(slice3[:1], slice3[4:]...)  // 删除中间三个元素
slice6 := append(slice3[:0], slice3[:7]...)  // 删除最后三个元素
slice7 := slice3[:copy(slice3, slice3[3:])]  // 删除开头前三个元素

append 相对好理解一些,copy 之所以可以用于删除元素,是因为其返回值是拷贝成功的元素个数,我们可以根据这个值完成新切片的设置从而达到「删除」元素的效果。

和动态增加元素一样,原切片的值并没有变动,而是创建出一个新的内存空间来存放新切片并将其赋值给其它变量。

关于 Go 切片元素的动态插入、新增、删除操作,还可以查看 Go Slice Tricks Cheat Sheet 提供的图片示例有一个更直观的感受。

数据共享问题

我们知道,切片底层是基于数组实现的,对应的结构体对象如下所示:

type slice struct {
    array unsafe.Pointer //指向存放数据的数组指针
    len   int            //长度有多大
    cap   int            //容量有多大
}

在结构体中使用指针存在不同实例的数据共享问题,我们看个例子:

slice1 := []int{1, 2, 3, 4, 5}

slice2 := slice1[1:3]
slice2[1] = 6

fmt.Println("slice1:", slice1)
fmt.Println("slice2:", slice2)

打印结果如下:

slice1: [1 2 6 4 5]
slice2: [2 6]

可以看到,slice2 是基于 slice1 创建的,它们的数组指针指向了同一个数组,因此,修改 slice2 元素会同步到 slice1,因为修改的是同一份内存数据,这就是数据共享问题。

解决方案

要解决这个问题,可以怎么做:

slice1 := make([]int, 4)
slice2 := slice1[1:3]
slice1 = append(slice1, 0)
slice1[1] = 2
slice2[1] = 6

fmt.Println("slice1:", slice1)
fmt.Println("slice2:", slice2)

打印结果如下:

slice1: [0 2 0 0 0]
slice2: [0 6]

可以看到,虽然 slice2 是基于 slice1 创建的,但是修改 slice2 不会再同步到 slice1,因为 append 函数会重新分配新的内存,然后将结果赋值给 slice1,这样一来,slice2 会和老的 slice1 共享同一个底层数组内存,不再和新的 slice1 共享内存,也就不存在数据共享问题了。

对于不支持指针的语言,比如 PHP,类似问题就是引用对象共享,在涉及到引用对象属性的复合对象集合遍历时,很多初学者可能都遇到过这个问题,其实就是浅拷贝导致的不同对象引用了同一个对象属性,要解决这个问题,需要通过深拷贝将对象及嵌套引用的对象重新克隆一份出来,避免内存共享。

但是这里有个需要注意的地方,就是一定要重新分配内存空间,如果没有重新分配,依然存在数据共享问题:

slice1 := make([]int, 4, 5)
slice2 := slice1[1:3]
slice1 = append(slice1, 0)
slice1[1] = 2
slice2[1] = 6

fmt.Println("slice1:", slice1)
fmt.Println("slice2:", slice2)

打印结果如下:

slice1: [0 2 6 0 0]
slice2: [2 6]

可以看到这里就发生了数据共享问题,因为初始化的容量是 5,比长度大,执行append 的时候没有进行扩容,也就不存在重新分配内存操作。

3 Comments

发表回复