Appearance
切片使用入门与数据共享问题处理
在前一篇教程里我们已经介绍过数组的一个特点:数组的长度在定义之后无法修改,数组长度是数组类型本身的一部分,是数组的一个内置常量,因此我们无法在数组上做动态的元素增删操作。
显然这种数据结构无法完全满足开发者的日常开发需求,尤其是从动态语言转过来的开发人员(如 PHP),为此,Go 语言提供了切片(slice)来弥补数组的不足,切片一个最强大的功能就是支持对元素做动态增删操作,在介绍动态增删元素之前,我们先来了解下切片的定义和创建。
切片的定义
在 Go 语言中,切片是一个新的数据类型,与数组最大的不同在于,切片的类型字面量中只有元素的类型,没有长度:
Go
var slice []string = []string{"a", "b", "c"}
因此它是一个可变长度的、同一类型元素集合,切片的长度可以随着元素数量的增长而增长(但不会随着元素数量的减少而减少),不过切片从底层管理上来看依然使用数组来管理元素,可以看作是对数组做了一层简单的封装。基于数组,切片添加了一系列管理功能,可以随时动态扩充存储空间,下面我们就来看看数组切片的创建和使用。
创建切片
创建切片的方法主要有三种 —— 基于数组、切片和直接创建,下面我们来简要介绍一下这几种方法。
基于数组
切片可以基于一个已存在的数组创建。从这个层面来说,数组可以看作是切片的底层数组,而切片则可以看作是数组某个连续片段的引用。切片可以只使用数组的一部分元素或者整个数组来创建,甚至可以创建一个比所基于的数组还要大的切片:
Go
// 先定义一个数组
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
的所有元素创建切片(全年)
Go
all := months[:]
基于 months
的前 6 个元素创建切片(上半年)
GO
firsthalf := months[:6]
基于从第 6 个元素开始的后续元素创建切片(下半年)
Go
secondhalf := months[6:]
另外,通过这个示例,还可以进一步探讨切片底层的结构。
切片底层引用了一个数组,由三个部分构成 —— 指针、长度和容量,指针指向数组起始下标,长度对应切片中元素的个数,容量则是切片起始位置到底层数组结尾的位置:
切片长度不能超过容量,比如上面的数组切片 q2
,其指针指向底层数组 months
下标为 3
的位置,切片长度是3,切片容量是9(从下标 3 开始到下标 11 结束,可容纳 9 个元素),和数组一样,我们可以通过内置函数 len
获取切片的长度,此外还可以通过 cap
函数获取切片容量:
Go
fmt.Println(len(q2)) // 3
fmt.Println(cap(q2)) // 9
类似于切片可以基于一个数组创建,切片也可以基于另一个切片创建:
Go
firsthalf := months[:6]
q1 := firsthalf[:3] // 基于 firsthalf 的前 3 个元素构建新切片
基于 firsthalf
创建切片时,选择的 firsthalf
元素范围可以超过所包含的元素个数,比如 q1
可以基于firsthalf
的前 9 个元素创建:
Go
q1 := firsthalf[:9]
打印结果是:[January February March April May June July August September]
。
因为 firsthalf
的容量是 12,只要选择的范围不超过 firsthalf
的容量,那么这个创建操作就是合法的,所以虽然是基于切片创建切片,但本质上还是基于数组。
直接创建
并非一定要事先准备一个数组才能创建切片,Go 语言提供的内置函数 make()
可以用于灵活地创建切片。
例如要创建一个初始长度为 5 的整型切片,可以这么做:
Go
mySlice1 := make([]int, 5)
要创建一个初始长度为 5、容量为 10 的整型切片,可以这么做(通过第三个参数设置容量):
Go
mySlice2 := make([]int, 5, 10)
此外,还可以直接创建并初始化包含 5 个元素的数组切片(长度和容量均为5):
Go
mySlice3 := []int{1, 2, 3, 4, 5}
和数组一样,所有未初始化的切片,会填充元素类型对应的零值。
事实上,使用直接创建的方式来创建切片时,Go 底层还是会有一个匿名数组被创建出来,然后调用基于数组创建切片的方式返回切片,只是上层不需要关心这个匿名数组的操作而已。所以,最终切片都是基于数组创建的,切片可以看做是操作数组的指针。
遍历切片
由于切片可以看作是是数组指针,因此,操作数组元素的所有方法都适用于切片,比如切片也可以按下标读写元素,用 len()
函数获取元素个数,并支持使用 range
关键字来快速遍历所有元素。
传统的元素遍历方法如下:
Go
for i := 0; i < len(summer); i++ {
fmt.Println("summer[", i, "] =", summer[i])
}
打印结果如下:
summer[ 0 ] = June
summer[ 1 ] = July
summer[ 2 ] = August
使用 range
关键字可以让遍历代码显得更简洁,range
表达式有两个返回值,第一个是索引,第二个是元素的值:
Go
for i, v := range summer {
fmt.Println("summer[", i, "] =", v)
}
两种方式打印结果完全一致。
切片比数组更强大之处在于支持动态增加元素,甚至可以在容量不足的情况下自动扩容。在切片类型中,元素个数和实际可分配的存储空间是两个不同的值,元素的个数即切片的实际长度,而可分配的存储空间就是切片的容量。
一个切片的容量初始值根据创建方式的不同而不同:
- 对于基于数组和切片创建的切片而言,默认容量是从切片起始索引到对应底层数组的结尾索引;
- 对于通过内置
make
函数创建的切片而言,在没有指定容量参数的情况下,默认容量和切片长度一致。
所以,通常一个切片的长度值小于等于其容量值,我们可以通过 Go 语言内置的 cap()
函数和 len()
函数来获取某个切片的容量和实际长度:
Go
var oldSlice = make([]int, 5, 10)
fmt.Println("len(oldSlice):", len(oldSlice))
fmt.Println("cap(oldSlice):", cap(oldSlice))
程序运行结果如下:
Go
len(oldSlice): 5
cap(oldSlice): 10
此时,切片 oldSlice
的默认值是 [0 0 0 0 0]
,我们可以通过 append()
函数向切片追加新元素:
Go
newSlice := append(oldSlice, 1, 2, 3)
将返回的新切片赋值给 newSlice
,此时 newSlice
的长度是 8,容量是 10,切片值是:
[0 0 0 0 0 1 2 3]
函数 append()
的第二个参数是一个不定参数,我们可以按自己需求添加若干个元素(大于等于 1 个),甚至直接将一个切片追加到另一个切片的末尾:
Go
appendSlice := []int{1, 2, 3, 4, 5}
newSlice := append(oldSlice, appendSlice...) // 注意末尾的 ... 不能省略
自动扩容
如果追加的元素个数超出 oldSlice
的默认容量,则底层会自动进行扩容:
Go
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()
函数的行为:
Go
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]
动态删除元素
切片除了支持动态增加元素之外,还可以动态删除元素,在切片中动态删除元素可以通过多种方式实现(其实是通过切片的切片实现的「伪删除」):
Go
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
函数实现切片元素的「删除」:
Go
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 提供的图片示例有一个更直观的感受。
数据共享问题
我们知道,切片底层是基于数组实现的,对应的结构体对象如下所示:
Go
type slice struct {
array unsafe.Pointer //指向存放数据的数组指针
len int //长度有多大
cap int //容量有多大
}
在结构体中使用指针存在不同实例的数据共享问题,我们看个例子:
Go
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
,因为修改的是同一份内存数据,这就是数据共享问题。
解决方案
要解决这个问题,可以怎么做:
Go
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)
可以看到,虽然 slice2
是基于 slice1
创建的,但是修改 slice2
不会再同步到 slice1
,因为 append
函数会重新分配新的内存,然后将结果赋值给 slice1
,这样一来,slice2
会和老的 slice1
共享同一个底层数组内存,不再和新的 slice1
共享内存,也就不存在数据共享问题了。
对于不支持指针的语言,比如 PHP,类似问题就是引用对象共享,在涉及到引用对象属性的复合对象集合遍历时,很多初学者可能都遇到过这个问题,其实就是浅拷贝导致的不同对象引用了同一个对象属性,要解决这个问题,需要通过深拷贝将对象及嵌套引用的对象重新克隆一份出来,避免内存共享。
但是这里有个需要注意的地方,就是一定要重新分配内存空间,如果没有重新分配,依然存在数据共享问题:
Go
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
的时候没有进行扩容,也就不存在重新分配内存操作。