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 操作时创建一个新底层数组

相关推荐
天天摸鱼的java工程师1 小时前
RestTemplate 如何优化连接池?—— 八年 Java 开发的踩坑与优化指南
java·后端
一乐小哥1 小时前
一口气同步10年豆瓣记录———豆瓣书影音同步 Notion分享 🚀
后端·python
LSTM971 小时前
如何使用C#实现Excel和CSV互转:基于Spire.XLS for .NET的专业指南
后端
三十_1 小时前
【NestJS】构建可复用的数据存储模块 - 动态模块
前端·后端·nestjs
武子康1 小时前
大数据-91 Spark广播变量:高效共享只读数据的最佳实践 RDD+Scala编程
大数据·后端·spark
努力的小郑1 小时前
MySQL索引(二):覆盖索引、最左前缀原则与索引下推详解
后端·mysql
阿拉伦1 小时前
智能交通拥堵治理柔性设计实践复盘小结
后端
用户4099322502121 小时前
如何在 FastAPI 中优雅地模拟多模块集成测试?
后端·ai编程·trae
一枝花算不算浪漫1 小时前
线上频繁FullGC?慌得一比!竟是Log4j2的这个“特性”坑了我
jvm·后端
Cache技术分享1 小时前
182. Java 包 - 创建和使用 Java 包
前端·后端