解析 Go 切片:为何按值传递时会发生改变?
一、前言
在Go语言中,切片是一个非常常用的数据结构,很多开发者在编写代码时都会频繁使用它。尽管切片很方便,但有一个问题常常让人感到困惑:当我们把切片作为参数传递给函数时,为什么有时候切片的内容会发生变化?这让很多人一头雾水,甚至在调试时浪费了不少时间。
这篇文章简单明了地探讨这个问题,揭示切片按值传递时发生变化的原因。我们通过一些简单的示例,帮助大家理解这一现象是如何发生的,以及如何在实际开发中避免相关的坑。希望这篇文章能让你对Go切片有更清晰的认识,少走一些弯路!
二、思考
在开始之前我们先来看几则单测,思考一下切片调用reverse之后会发生什么样的变化?为什么会有这样的变化?
func TestReverse(t *testing.T) { var s []int for i := 1; i <= 3; i++ { s = append(s, i) } reverse(s) } func reverse(s []int) { for i, j := 0, len(s)-1; i < j; i++ { j = len(s) - (i + 1) s[i], s[j] = s[j], s[i] } } func TestReverse2(t *testing.T) { var s []int for i := 1; i <= 3; i++ { s = append(s, i) } reverse2(s) } func reverse2(s []int) { s = append(s, 4) for i, j := 0, len(s)-1; i < j; i++ { j = len(s) - (i + 1) s[i], s[j] = s[j], s[i] } } func TestReverse3(t *testing.T) { var s []int for i := 1; i <= 3; i++ { s = append(s, i) } reverse3(s) } func reverse3(s []int) { s = append(s, 4, 5) for i, j := 0, len(s)-1; i < j; i++ { j = len(s) - (i + 1) s[i], s[j] = s[j], s[i] } }
带着上面的疑问,接下来我们回顾一下切片的基础知识点。
三、切片的结构
type slice struct { array unsafe.Pointer len int cap int }
Go切片的底层结构由以下三个部分组成:
-
指针(unsafe.Pointer): 指向底层数组的第一个元素。如果切片的长度为 0,那么指针可以是nil。这个指针允许切片访问底层数组中的元素。
-
长度(len): 切片中实际包含的元素个数。通过len(slice)可以获取切片的长度。 长度决定了切片在进行迭代或访问元素时的范围。
-
容量(cap): 切片底层数组的大小,表示切片可以增长的最大长度。可以通过
-
cap(slice)获取容量。 当切片的长度达到容量时,使用append函数添加更多元素时,Go会新分配一个更大的数组并复制现有元素。
一个切片的示意图如下:
四、切片的创建
直接创建切片
func TestCreate(t *testing.T) { slice := []int{1, 2, 3} fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n", len(slice), cap(slice), &slice, slice) }
上述示例代码运行输出如下:
len=3, cap=3, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000b2048
此时创建出来的切片对应图示如下:
直接创建切片时,会为切片的底层数组开辟内存空间并使用指定的元素对数组完成初始化,且创建出来的切片的len等于cap等于初始化元素的个数。
从整个数组切得到切片
func TestCreate(t *testing.T) { originArray := [3]int{1, 2, 3} slice := originArray[:] fmt.Printf("originArrayPointer=%p\n", &originArray) fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n", len(slice), cap(slice), &slice, slice) }
上述示例代码运行打印如下:
originArrayPointer=0xc000010198 len=3, cap=3, slicePointer=0xc0000080f0, sliceArrayPointer=0xc000010198
此时创建出来的切片对应图示如下:
从整个数组切,实际就是切片直接使用了这个数组作为底层的数组。
从前到后切数组得到切片
func TestCreate(t *testing.T) { originArray := [6]int{1, 2, 3, 4, 5, 6} slice := originArray[:3] fmt.Printf("originArrayPointer=%p\n", &originArray) fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n", len(slice), cap(slice), &slice, slice) }
上述在切数组时,没有指定数组的开始索引,表示从索引0开始切(inclusive),指定了数组的结束索引,表示切到结束索引的位置(exclusive),运行代码输出如下:
originArrayPointer=0xc0000144c0 len=3, cap=6, slicePointer=0xc0000080f0, sliceArrayPointer=0xc0000144c0
此时创建出来的切片对应图示如下:
从前到后切数组得到的切片,len等于切的范围的长度,对应示例中索引0(inclusive )到索引2(exclusive )的长度3,而cap 等于切的开始位置(inclusive )到数组末尾(inclusive)的长度6。
从数组中间切到最后得到切片
func TestCreate(t *testing.T) { originArray := [6]int{1, 2, 3, 4, 5, 6} slice := originArray[3:] fmt.Printf("originArrayPointer=%p\n", &originArray) fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n", len(slice), cap(slice), &slice, slice) }
上述在切数组时,指定了数组的开始索引,表示从索引3(inclusive)开始切,没有指定数组的结束索引,表示切到数组的末尾(inclusive),运行代码输出如下:
originArrayPointer=0xc0000bc060 len=3, cap=3, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc078
此时创建出来的切片对应图示如下:
从数组中间切到最后得到的切片,len 等于cap 等于切的范围的长度,对应示例中索引3(inclusive )到数组末尾(inclusive)的长度3。并且由上述图示可以看出,切片使用的底层数组其实还是被切的数组,只不过使用的是被切数组的一部分。
从数组切一段得到切片
func TestCreate(t *testing.T) { originArray := [6]int{1, 2, 3, 4, 5, 6} slice := originArray[2:5] fmt.Printf("originArrayPointer=%p\n", &originArray) fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n", len(slice), cap(slice), &slice, slice) }
上述在切数组时,指定了数组的开始索引,表示从索引2(inclusive )开始切,也指定了数组的结束索引,表示切到数组的索引5的位置(exclusive),运行代码输出如下:
originArrayPointer=0xc0000bc060 len=3, cap=4, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc070
此时创建出来的切片对应图示如下:
从数组切一段得到的切片,len等于切的范围的长度,对应示例中索引2(inclusive )到索引5(exclusive )的长度3,cap等于切的开始位置(inclusive )到数组末尾(inclusive)的长度4。切片使用的底层数组还是被切数组的一部分。
从切片切得到切片
func TestCreate(t *testing.T) { originArray := [6]int{1, 2, 3, 4, 5, 6} originSlice := originArray[:] derivedSlice := originSlice[2:4] fmt.Printf("originArrayPointer=%p\n", &originArray) fmt.Printf("originSlice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n", len(originSlice), cap(originSlice), &originSlice, originSlice) fmt.Printf("derivedSlice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p\n", len(derivedSlice), cap(derivedSlice), &derivedSlice, derivedSlice) }
上述示例代码中,originSlice 是切数组originArray 得到的切片,derivedSlice 是切切片originSlice得到的切片,运行代码输出如下:
func TestCreate(t *testing.T) { slice := make([]int, 3, 5) fmt.Printf("slice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, slice=%v\n", len(slice), cap(slice), &slice, slice, slice) }
此时创建出来的切片对应图示如下:
从切片切得到切片后,两个切片会使用同一个底层数组,区别就是可能使用的是底层数组的不同区域,因此如果其中一个切片更改了数据,而这个数据恰好另一个切片可用访问,那么另一个切片访问该数据时就会发现数据发生了更改。但是请注意,虽然两个切片使用同一个底层数组,但是切片的len 和cap 都是独立的,也就是假如其中一个切片通过类似于append() 函数导致len 或者cap 发生了更改,此时另一个切片的len 或者cap是不会受影响的。
使用make函数得到切片
make() 函数专门用于为slice,map 和chan 这三种引用类型分配内存并完成初始化,make() 函数返回的就是引用类型对应的底层结构体本身,使用 make() 函数创建slice的示例代码如下所示:
func TestCreate(t *testing.T) { slice := make([]int, 3, 5) fmt.Printf("slice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, slice=%v\n", len(slice), cap(slice), &slice, slice, slice) }
上述示例代码中,会使用make() 函数创建一个int 类型的切片,并指定len 为3(第二个参数指定),cap 为5(第三个参数指定),其中可以不指定cap ,此时cap 会取值为len。运行代码输出如下:
slice: len=3, cap=5, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc060, slice=[0 0 0]
此时访问索引3或索引4的元素,会引发panic:
func TestCreate(t *testing.T) { slice := make([]int, 3, 5) fmt.Printf("slice: len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, slice=%v\n", len(slice), cap(slice), &slice, slice, slice) fmt.Printf("%p\n", &slice[3]) fmt.Printf("%p\n", &slice[4]) } panic: runtime error: index out of range [3] with length 3
五、切片的扩容
在Go语言中,当使用append()函数向切片添加元素时,如果切片的当前长度达到了它的容量,Go会自动触发扩容。扩容是指创建一个新的更大的底层数组,并将原有元素复制到新数组中。以下是关于切片触发扩容的详细说明。
触发扩容的条件
- 当调用append()函数,如果当前长度小于容量,可以直接在底层数组中添加新元素;当切片的长度(len)达到或超过它的容量(cap)时,就会触发扩容。
扩容操作
- Go 会分配一个新的底层数组。
- 原有的元素会被复制到新的数组中。
- 切片的指针会更新为指向新的底层数组,长度和容量也会相应更新。
最新的扩容规则在1.18版本中就已经发生改变了,具体可以参考一下这个 commit:runtime: make slice growth formula a bit smoother。
在之前的版本中:对于<1024个元素,增加2倍,对于>=1024个元素,则增加1.25倍。而现在,使用更平滑的增长因子公式。在256个元素后开始降低增长因子,但要缓慢。
它还给了个表格,写明了不同容量下的增长因子:
从这个表格中,我们可以看到,新版本的切片库容,并不是在容量小于1024的时候严格按照2倍扩容,大于1024的时候也不是严格地按照1.25倍来扩容;在slice.go源码中也验证了这一点。
// nextslicecap computes the next appropriate slice length. func nextslicecap(newLen, oldCap int) int { newcap := oldCap doublecap := newcap + newcap if newLen > doublecap { return newLen } const threshold = 256 if oldCap < threshold { return doublecap } for { // Transition from growing 2x for small slices // to growing 1.25x for large slices. This formula // gives a smooth-ish transition between the two. newcap += (newcap + 3*threshold) >> 2 // We need to check `newcap >= newLen` and whether `newcap` overflowed. // newLen is guaranteed to be larger than zero, hence // when newcap overflows then `uint(newcap) > uint(newLen)`. // This allows to check for both with the same comparison. if uint(newcap) >= uint(newLen) { break } } // Set newcap to the requested cap when // the newcap calculation overflowed. if newcap <= 0 { return newLen } return newcap }
上面说到:一旦触发扩容,会创建新容量大小的数组,然后将老数组的数据拷贝到新数组上,再然后将附加元素添加到新数组中,最后切片的array指向新数组。也就是说,切片扩容会导致切片使用的底层数组地址发生变更,我们通过代码来了解这一过程:
func TestSliceGrow(t *testing.T) { // 原始数组 originArray := [6]int{1, 2, 3, 4, 5, 6} // 原始切片 originSlice := originArray[0:5] // 打印原始切片和原始数组的信息 fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, originArrayPointer=%p\n", len(originSlice), cap(originSlice), &originSlice, originSlice, &originArray) // 第一次append不会触发扩容 firstAppendSlice := append(originSlice, 7) // 打印第一次Append后的切片和原始数组的信息 fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, originArrayPointer=%p\n", len(firstAppendSlice), cap(firstAppendSlice), &firstAppendSlice, firstAppendSlice, &originArray) // 第二次append会触发扩容 secondAppendSlice := append(firstAppendSlice, 8) // 打印第二次Append后的切片和原始数组的信息 fmt.Printf("len=%d, cap=%d, slicePointer=%p, sliceArrayPointer=%p, originArrayPointer=%p\n", len(secondAppendSlice), cap(secondAppendSlice), &secondAppendSlice, secondAppendSlice, &originArray) }
运行上面代码输出如下:
len=5, cap=6, slicePointer=0xc0000980d8, sliceArrayPointer=0xc0000bc060, originArrayPointer=0xc0000bc060 len=6, cap=6, slicePointer=0xc000098108, sliceArrayPointer=0xc0000bc060, originArrayPointer=0xc0000bc060 len=7, cap=12, slicePointer=0xc000098138, sliceArrayPointer=0xc0000862a0, originArrayPointer=0xc0000bc060
在示例代码中,切数组originArray得到的切片如下所示:
第一次append元素后,切片如下所示:
第二次append元素时,会触发扩容,扩容后的切片如下所示:
可见,扩容后切片使用了另外一个数组作为了底层数组。对扩容之后的切片任何操作将不再影响原切片;反之:扩容之前,对新切片的新增和修改影响的是底层数组,同时也会影响引用了该数组的任何切片。
现在,让我们回顾一下文章开头提到的三个单元测试,运行它们后得到的结果是否符合你的预期?结合我们对切片创建、初始化和扩容的基础知识,你是否能理解为何切片在传递时是值传递,但原始切片中的元素却可能会发生变化?
六、总结
这篇文章通过简单明了的示例,深入分析了Go语言中切片作为参数传递时值变化的问题。揭示了切片的运行机制,帮助开发者理解为什么在函数内部对切片的修改会影响到原始切片的内容。这样的分析旨在消除开发中遇到的困惑,为实际开发提供更清晰的指导。
最重要的是,希望这篇文章能够传达一个信息:当你对某个现象的原因尚不完全理解时,花时间去深入探究是非常值得的。这种探究不仅能提升你的编程能力,更能培养解决问题的能力。
*文/徒徒
本文属得物技术原创,更多精彩文章请看:得物技术
未经得物技术许可严禁转载,否则依法追究法律责任!

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
ISO C++ 主席:C++26 是 C++11 以来最具影响力的版本
在 Microsoft Visual C++ 编译器团队工作了 22 年后,ISO C++ 委员会主席 Herb Sutter 宣布离开微软,加入 Citadel Securities 担任技术研究员,负责技术战略和培训工作。 与此同时,他的 C++ 标准和社区角色保持不变,将继续担任 ISO 委员会主席、标准 C++ 基金会主席,并继续投入大量时间在 C++ 的标准化和发展上。 Sutter 认为,2024 年是 C++ 的关键一年;因为 2024 年 ISO 委员会首次开始在 C++ 标准草案中采用(或即将采用)重大的安全性和反射改进,这是一个重大转折点。 “我在 CppCon 的演讲中曾说过,C++ 的下一个十年将由反射和安全性改进主导,而 C++26 确实有望成为自 C++11 开启新纪元以来最具影响力的版本;这对 C++ 来说是一个激动人心的时刻,我计划继续花大量时间为 C++26 及以后的版本做贡献。” Sutter 称,上一个里程碑式的版本 C++ 11 引入了现代 C++,而“C++26 正在为再次实现这一目标奠定基础”:我们下一个“现代 C++”的重要时代将以默认安全...
- 下一篇
消息称阿里通义大模型前核心员工加入字节跳动,被诉违反竞业协议
据《科创板日报》报道,有消息称阿里通义大模型前员工周畅违反竞业协议,阿里方面已起诉递交劳动争议仲裁申请书。据接近通义的业内人士对《科创板日报》记者表示:情况属实。 公开资料显示,周畅 2017 年博士毕业于北京大学计算机软件与理论专业,随后加入阿里巴巴,花名“钟煌”,是阿里通义千问大模型的技术负责人,曾和团队推出一系列语言模型、多模态模型。 在阿里巴巴工作期间,周畅带领团队设计并实现了超大规模的多模态预训练模型 M6,在参数数量和低碳训练模式上取得了突破。 M6 模型是 2021 年 3 月阿里巴巴与清华大学联合发布的业界最大中文多模态预训练 AI 模型,参数规模高达 1000 亿,是多模态预训练领域史上最大的模型。 今年 7 月曾有知情人士表示,周畅是通义实验室算法团队的核心技术骨干之一,属于正常离职。通义大模型的研发和开源工作还在进行中,目前通义实验室负责人为阿里云 CTO 周靖人。 询问AI
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- Red5直播服务器,属于Java语言的直播服务器
- CentOS7,8上快速安装Gitea,搭建Git服务器
- CentOS6,7,8上安装Nginx,支持https2.0的开启
- CentOS8,CentOS7,CentOS6编译安装Redis5.0.7
- SpringBoot2更换Tomcat为Jetty,小型站点的福音
- Jdk安装(Linux,MacOS,Windows),包含三大操作系统的最全安装
- SpringBoot2整合MyBatis,连接MySql数据库做增删改查操作
- SpringBoot2全家桶,快速入门学习开发网站教程
- CentOS8安装MyCat,轻松搞定数据库的读写分离、垂直分库、水平分库
- CentOS8编译安装MySQL8.0.19