切片append没扩容_切片传递的隐藏危机,你真的知道吗?

提出疑问

在Go的源码库或者其他开源项目中,会发现有些函数在需要用到切片入参时,它采用是指向切片类型的指针,而非切片类型。这里未免会产生疑问:切片底层不就是指针指向底层数组数据吗,为何不直接传递切片,两者有什么区别

例如,在源码log包中,Logger对象上绑定了formatHeader方法,它的入参对象buf,其类型是*[]byte,而非[]byte。

func (l *Logger) formatHeader(buf *[]byte, t time.Time, file string, line int) {}

有以下例子

func modifySlice(innerSlice []string) {    innerSlice[0] = "b"    innerSlice[1] = "b"    fmt.Println(innerSlice)}func main() {    outerSlice := []string{"a", "a"}    modifySlice(outerSlice)    fmt.Print(outerSlice)}// 输出如下[b b][b b]

我们将modifySlice函数的入参类型改为指向切片的指针

func modifySlice(innerSlice *[]string) {    (*innerSlice)[0] = "b"    (*innerSlice)[1] = "b"    fmt.Println(*innerSlice)}func main() {    outerSlice := []string{"a", "a"}    modifySlice(&outerSlice)    fmt.Print(outerSlice)}// 输出如下[b b][b b]

很好,在上面的例子中,两种函数传参类型得到的结果都一样,似乎没发现有什么区别。通过指针传递它看起来毫无用处,而且无论如何切片都是通过引用传递的,在两种情况下切片内容都得到了修改。

这印证了我们一贯的认知:函数内对切片的修改,将会影响到函数外的切片。但,真的是如此吗?

考证与解释

在《你真的懂string与[]byte的转换了吗》一文中,我们讲过切片的底层结构如下所示。

type slice struct {    array unsafe.Pointer    len   int    cap   int}

array是底层数组的指针,len表示长度,cap表示容量。

我们对上文中的例子,做以下细微的改动。

func modifySlice(innerSlice []string) {    innerSlice = append(innerSlice, "a")    innerSlice[0] = "b"    innerSlice[1] = "b"    fmt.Println(innerSlice)}func main() {    outerSlice := []string{"a", "a"}    modifySlice(outerSlice)    fmt.Print(outerSlice)}// 输出如下[b b a][a a]

神奇的事情发生了,函数内对切片的修改竟然没能对外部切片造成影响?

为了清晰地明白发生了什么,将打印添加更多细节。

func modifySlice(innerSlice []string) {    fmt.Printf("%p %v   %p", &innerSlice, innerSlice, &innerSlice[0])    innerSlice = append(innerSlice, "a")    innerSlice[0] = "b"    innerSlice[1] = "b"    fmt.Printf("%p %v %p", &innerSlice, innerSlice, &innerSlice[0])}func main() {    outerSlice := []string{"a", "a"}    fmt.Printf("%p %v   %p", &outerSlice, outerSlice, &outerSlice[0])    modifySlice(outerSlice)    fmt.Printf("%p %v   %p", &outerSlice, outerSlice, &outerSlice[0])}// 输出如下0xc00000c060 [a a]   0xc00000c0800xc00000c0c0 [a a]   0xc00000c0800xc00000c0c0 [b b a] 0xc0000220800xc00000c060 [a a]   0xc00000c080

在Go函数中,函数的参数传递均是值传递。那么,将切片通过参数传递给函数,其实质是复制了slice结构体对象,两个slice结构体的字段值均相等。正常情况下,由于函数内slice结构体的array和函数外slice结构体的array指向的是同一底层数组,所以当对底层数组中的数据做修改时,两者均会受到影响。

但是存在这样的问题:如果指向底层数组的指针被覆盖或者修改(copy、重分配、append触发扩容),此时函数内部对数据的修改将不再影响到外部的切片,代表长度的len和容量cap也均不会被修改。

为了让读者更清晰的认识到这一点,将上述过程可视化如下。

7ea5be9680bf044635e0761932893d43.png
2d1464ae3b52dc8119a6ed3db7e6b8d4.png
6e5e65654f8a13f9a5ae54a271ebec1f.png
6e4785a03749681039caabc086ab953f.png

可以看到,当切片的长度和容量相等时,发生append,就会触发切片的扩容。扩容时,会新建一个底层数组,将原有数组中的数据拷贝至新数组,追加的数据也会被置于新数组中。切片的array指针指向新底层数组。所以,函数内切片与函数外切片的关联已经彻底斩断,它的改变对函数外切片已经没有任何影响了。

注意,切片扩容并不总是等倍扩容。为了避免读者产生误解,这里对切片扩容原则简单说明一下(源码位于src/runtime/slice.go 中的 growslice 函数):

切片扩容时,当需要的容量超过原切片容量的两倍时,会直接使用需要的容量作为新容量。否则,当原切片长度小于1024时,新切片的容量会直接翻倍。而当原切片的容量大于等于1024时,会反复地增加25%,直到新容量超过所需要的容量。

到此,我们终于知道为什么有些函数在用到切片入参时,它需要采用指向切片类型的指针,而非切片类型。

func modifySlice(innerSlice *[]string) {    *innerSlice = append(*innerSlice, "a")    (*innerSlice)[0] = "b"    (*innerSlice)[1] = "b"    fmt.Println(*innerSlice)}func main() {    outerSlice := []string{"a", "a"}    modifySlice(&outerSlice)    fmt.Print(outerSlice)}// 输出如下[b b a][b b a]

请记住,如果你只想修改切片中元素的值,而不会更改切片的容量与指向,则可以按值传递切片,否则你应该考虑按指针传递。

例题巩固

为了判断读者是否已经真正理解上述问题,我将上面的例子做了两个变体,读者朋友们可以自测。

测试一

func modifySlice(innerSlice []string) {    innerSlice[0] = "b"  innerSlice = append(innerSlice, "a")    innerSlice[1] = "b"    fmt.Println(innerSlice)}func main() {    outerSlice := []string{"a", "a"}    modifySlice(outerSlice)    fmt.Println(outerSlice)}

测试二

func modifySlice(innerSlice []string) {    innerSlice = append(innerSlice, "a")    innerSlice[0] = "b"    innerSlice[1] = "b"    fmt.Println(innerSlice)}func main() {    outerSlice:= make([]string, 0, 3)    outerSlice = append(outerSlice, "a", "a")    modifySlice(outerSlice)    fmt.Println(outerSlice)}

测试一答案

[b b a][b a]

测试二答案

[b b a][b b]

你做对了吗?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
提供的源码资源涵盖了安卓应用、小程序、Python应用和Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机会。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行性和易用性,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我会定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值