golang 易错点-slice copy

背景

切片的复制是一个容易出错的操作,先看几个的例子:

go 复制代码
func main() {
	listing1()
	listing2()
	listing3()
}

func listing1() {
	s := []int{1, 2, 3}

	f(s[:2])
	fmt.Println(s) // output: [1,2,10]
}

func listing2() {
	s := []int{1, 2, 3}
	sCopy := make([]int, 2)
	copy(sCopy, s)

	f(sCopy)
	fmt.Println(sCopy) // output: [1,2]
	result := append(sCopy, s[2])
	fmt.Println(result) // output: [1,2,3]
}

func listing3() {
	s := []int{1, 2, 3}
	f(s[:2:2])
	fmt.Println(s) // output: [1,2,3]
}

func f(s []int) []int {
	return append(s, 10)
}
  • listing1 原切片 s 的 len=3,然后创建了一个 len=2 的临时切片传入 f,当 f 对它 append 新数据后,原切片 s 中的元素被覆盖了

  • listing2 将原切片复制到一个 len=2 的新切片 sCopy,将 sCopy 传入 f,这时 f 中的操作没有影响外面的原切片 sCopy

  • listing3 创建一个 len=2, cap=2 的临时切片传入 f,f 中的操作同样没用影响外面的原切片 s

为什么 listing1 中的原始数据会被覆盖,listing2,listing3 则不会?

知识点

知识点1:切片的数据结构

go 复制代码
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

slice 的底层其实是借助数组实现的,数据都保存在数组中。 array 就是指向这个数组的指针,len 代表切片的长度,cap 代表切片的容量。

知识点2:append 操作发现切片容量不足,会创建一个新的底层数组,同时把原数组的内容复制到新数组,再更新切片的 array 指针指向新数组,len,cap 都更新为新数组的长度和容量。

知识点3:go 函数采用值复制(pass-by-value),函数执行前会把参数复制一份,函数体中操作的是复制后的数据。对于指针来说,复制并不会复制底层数据,所以复制后新旧指针指向的是同一份数据。

回看开头的例子

listing1

go 复制代码
func listing1() {
	s := []int{1, 2, 3}

	f(s[:2])
	fmt.Println(s) // output: [1,2,10]
}

func f(s []int) []int {
	return append(s, 10)
}

f 执行 append 操作前,两个切片的数据结构如图,:

lua 复制代码
       s (main)                           s (in f)
+-------+                            +-------+
|  ptr  |--------> +---+---+---+ <---|  ptr  |
|  len  | 3        | 1 | 2 | 3 |     |  len  | 2
|  cap  | 3        +---+---+---+     |  cap  | 3
+-------+          ▲                 +-------+
                   |
                   +-- Shared starting point

执行 append 操作,因为 f 中切片的 len=2,cap=3,go 判断底层数组可以容纳新元素,于是在原数组添加。这导致原数组的最后一个元素就被覆盖了:

lua 复制代码
       s (main)                           s (in f)
+-------+                            +-------+
|  ptr  |--------> +---+---+---+ <---|  ptr  |
|  len  | 3        | 1 | 2 | 10|     |  len  | 3
|  cap  | 3        +---+---+---+     |  cap  | 3
+-------+          ▲                 +-------+
                   |
                   +-- Shared starting point

listing2

go 复制代码
func listing2() {
	s := []int{1, 2, 3}
	sCopy := make([]int, 2)
	copy(sCopy, s)

	f(sCopy)
	fmt.Println(sCopy) 
	result := append(sCopy, s[2])
	fmt.Println(result)
}

func f(s []int) []int {
	return append(s, 10)
}

首先 s 和 sCopy 是两个独立的切片,它们的底层数组不同,当执行 copy(sCopy, s) 后,数据结构如图:

lua 复制代码
     s (main)                             sCopy (main)
+-------+                            +-------+
|  ptr  |--------> +---+---+---+      |  ptr  |--------> +---+---+
|  len  | 3        | 1 | 2 | 3 |      |  len  | 2        | 1 | 2 |  <-- 独立数组2
|  cap  | 3        +---+---+---+      |  cap  | 2        +---+---+
+-------+          ^                  +-------+          ^
                   |                                     |
                   +-- 独立数组1                          +-- 独立数组2

f(sCopy) 会创建一个副本:

lua 复制代码
     sCopy (main)                       sCopy (in f)
+-------+                            +-------+
|  ptr  |--------> +---+---+ <-------|  ptr  |
|  len  | 2        | 1 | 2 |         |  len  | 2
|  cap  | 2        +---+---+         |  cap  | 2
+-------+          ^                 +-------+
                   |
                   +-- 共享的底层数组

执行 append 操作,因为 f 中切片的 len=2, cap=2,不够添加。于是会创建一个新的底层数组,并把数据复制过去:

lua 复制代码
                              (append 返回的新切片)
                            +-------+
    (sCopy in f)            |  ptr  |---------> +---+---+---+---+
    +-------+               |  len  | 3         | 1 | 2 | 10| 0 | <-- append后创建的新数组
    |  ptr  |---> +---+---+ |  cap  | 4         +---+---+---+---+
    |  len  | 2   | 1 | 2 | +-------+
    |  cap  | 2   +---+---+
    +-------+     ^
                  |
             原始的底层数组

因为 f 中的操作是基于新数组,所以 main 中的原数组不受影响。main 中 fmt.Println(sCopy) 输出的仍是 [1,2]。result := append(sCopy, s[2]) 在原数组基础上创建新数组,添加新元素,输出[1,2,3]。

listing3

go 复制代码
func listing3() {
	s := []int{1, 2, 3}
	f(s[:2:2])
	fmt.Println(s)
}

func f(s []int) []int {
	return append(s, 10)
}

s[:2:2] 的意思是基于原切片,创建一个 len=2,cap=2 的新切片:

lua 复制代码
        s (in main)                 s (in f)
    +-------+                   +-------+
    |  ptr  |---+-------------+-|  ptr  |
    |  len  | 3 |             | |  len  | 2
    |  cap  | 3 |             | |  cap  | 2
    +-------+   |             | +-------+
                |             |
                v             |
            +---+---+---+ <---+
            | 1 | 2 | 3 |  <-- 共享的底层数组
            +---+---+---+

append 操作,因为此时 f 中的切片 cap=2,不够容纳新元素,go 会分配一个新数组:

lua 复制代码
      s (in f)                               (append 返回的新切片)
+-------+                                +-------+
|  ptr  |--------> +---+---+---+          |  ptr  |--------> +---+---+---+---+
|  len  | 2        | 1 | 2 | 3 |          |  len  | 3        | 1 | 2 | 10| 0 |  <-- 新分配的底层数组
|  cap  | 2        +---+---+---+          |  cap  | 4        +---+---+---+---+
+-------+                                +-------+
                   ^
                   |
               共享的底层数组

因为 f 中的操作是基于新数组,所以 main 中的原数组不受影响。fmt.Println(s) 输出仍是[1,2,3]。

总结

  1. 假如新旧切片都指向同一个底层数组,且新切片的 len < cap。append 操作会修改原切片的底层数组

  2. 要想避免影响,可以通过 copy 复制一个新数组,或者用 full slice expression 限制 cap,两者都会在 append 操作时创建一个新底层数组

相关推荐
AAA修煤气灶刘哥4 分钟前
别再懵了!Spring、Spring Boot、Spring MVC 的区别,一篇讲透
后端·面试
柏油28 分钟前
MySQL 字符集 utf8 与 utf8mb4
数据库·后端·mysql
程序猿阿越36 分钟前
Kafka源码(三)发送消息-客户端
java·后端·源码阅读
javadaydayup38 分钟前
Apollo 凭什么能 “干掉” 本地配置?
spring boot·后端·spring
似水流年流不尽思念39 分钟前
Spring MVC 中的 DTO 对象的字段被 transient 修饰,可以被序列化吗?
后端·面试
武子康40 分钟前
大数据-70 Kafka 日志清理:删除、压缩及混合模式最佳实践
大数据·后端·kafka
故此26642 分钟前
synchronized原理
后端
似水流年流不尽思念43 分钟前
为啥 HashMap 中的 table 也被 transient 修饰?其目的是什么?
后端·面试
AAA修煤气灶刘哥44 分钟前
搞定 Redis 不难:从安装到实战的保姆级教程
java·redis·后端