我们知道,对slice的切分实际上是作用在slice的底层数组上的操作。对一个已存在的slice进行切分操作会创建一个新的slice,但都会指向相同的底层数组。因此,如果一个索引值对两个slice都是可见的,那么使用索引更新一个slice的时候(例如s1[1] = 10),同时该更新也会影响另外一个slice。
本文将介绍使用append时的一种常见的错误,该操作在某些场景下会导致副作用。
01 append是如何对slice产生副作用的
首先,我们有以下示例:初始化一个切片s1,然后通过切分s1的方式创建切片s2,再然后通过在s2上进行append操作创建切片s3:
代码语言:javascript复制s1 := []int{1, 2, 3}s2 := s1[1:2]s3: = append(s3, 10)
通过以上代码可知,s1包含3个元素。对s1进行切分操作来创建s2。然后对s2进行append操作创建s3。那么,最后这3个切片的状态是什么呢?
下图是s1和s2在内存中的状态示例图:
s1是长度为3,容量为3的切片结构,而s2是长度为1,容量为2的切片结构,s1和s2的都指向相同的底层数组。
当使用append给切片添加元素的时候 会检查切片是否已满:切片的长度等于切片容量时判定为元素已满。如果没有满,还有空间,那么append函数则将元素添加到原底层数据的空闲空间中,并返回一个新的结构体。
在该示例中,s2还没有满,还能接收一个元素。因此,下图是3个切片最终的状态,如图:
由图可看出,3个切片共享一个底层数据,数据的最后一个元素被更新为10。那么,如果我们打印这3个切片,则会有以下输出:
代码语言:javascript复制s1=[1 2 10], s2=[2], s3=[2 10]
可见,即使我们没有修改s1[2],也没修改s1[1],但s1的内容被修改了。因此,我们应该牢记该规则,以避免造成意外的错误。
我们再来看下另外一个影响:当将通过切分得到的新切片作为函数参数传递时的影响。
我们看下面的示例代码:
代码语言:javascript复制func main() {
s := []int{1, 2, 3}
f(s[:2])
// Use s
}
func f(s []int) {
// Update s
}
这种实现非常危险。实际上,函数f会对输入的切片产生副作用。例如,如果函数f调用append(s, 10),那么main函数中的s的内容就不再是[1 2 3],而是[1 2 10]。
02 如何解决append对slice产生的副作用
方案一:拷贝切片
我们可以通过对原切片进行拷贝,然后构建一个新的切片变量,如下代码所示:
代码语言:javascript复制func main() {
s := []int{1, 2, 3}
sCopy := make([]int, 2)
copy(sCopy, s) ①
f(sCopy)
result := append(sCopy, s[2]) ②
// Use result
}
func f(s []int) {
// Update s
}
① 将s的前两个元素拷贝到sCopy中
② 通过append函数将s[2]增加到sCopy中构建一个新的结果切片
因为我们在函数f中传递了一个拷贝,即使在函数中调用了append,也不会对该切片造成副作用。该方案的缺点就是需要对已存在的切片进行一次拷贝,如果切片很大,那拷贝时存储和性能就会成为问题。
方案二:限制切片容量
该方案是通过限制切片容量,在对切片进行操作时自动产生一个新的底层数据的方式来避免对原有切片副作用的产生。该方案就是所谓的满切片表达式:s[low:high:max]。这种满切片表达式和s[low:high]的区别在于s[low:high:max]的切片的容量是max-low,而s[low:high]的容量是s中底层数据的最大容量减去low。
代码语言:javascript复制func main() {
s := []int{1, 2, 3}
f(s[:2:2]) ①
// Use s
}
func f(s []int) {
// Update s
}
① 使用满切片表达式传递一个子切片
上面代码中传递给f函数的切片不是s[:2],而是s[:2:2]。因此,切片的容量是2 - 0 = 2,如下所示:
这种解决方案既共享了切片的底层数组,又通过限制容量避免了副作用。
我们必须时刻注意,从一个切片切分成子切片时,在这两个切片之间有可能会产生数据副作用。当直接修改一个元素或使用append函数的时候,这种副作用就会产生。如果我们想解决这种副作用,可以通过满切片表达式的方式来解决。这种方式避免了额外的拷贝,还算是比较高效的。