golang slice传参陷阱




文章目录

  • golang slice传参陷阱
  • 起因
  • slice的传参
  • slice的扩容
  • 回到开始




起因



package main

func SliceRise(s []int)  {
	s = append(s, 0)
	for i := range s {
		s[i]++
	}
}

func main()  {
	s1 := []int{1, 2}
	s2 := s1
	s2 = append(s2, 3)
	SliceRise(s1)
	SliceRise(s2)
	fmt.Println(s1, s2)
}

//A: [2,3][2,3,4]
//B: [1,2][1,2,3]
//C: [1,2][2,3,4]
//D: [2,3,1][2,3,4,1]



起因是寝室里的大佬在我干大事的时候突然叫我看一道题,就是上面这段程序。于是我愤怒的马上进行分析。这道题目来源于《Go专家编程》p14。我思考了很久,想不到一个解释的通的答案。
答案是选C。



后面在研究这道题的时候,翘出了一个忽略的知识点。那就是关于slice在传参append时的一些陷阱。



slice的传参



在初学golang的时候,我一直以为slice是引用传递而不是值传递,其实不然。



我们先来看一下官方对于这个问题的解释:



In a function call, the function value and arguments are evaluated in
the usual order. After they are evaluated, the parameters of the call
are passed by value to the function and the called function begins
execution. The return parameters of the function are passed by value
back to the caller when the function returns.

译文:

在函数调用中,函数值和参数按通常的顺序计算。求值之后,调用的参数通过值传递给函数,被调用的函数开始执行。当函数返回时,函数的返回参数按值返回给调用者。




也就是说golang中其实是没有所谓的引用传递的,只有值传递。那为什么我们在函数中对slice进行修改时,有时候会影响到函数外部的slice呢?



这就要从slice的内存模型说起了,slice的内存模型其实非常简单,就是一个结构体,里面包含了三个字段。第一个字段是一个指向底层数组的指针,第二个是slice的长度,第三个是底层数组的大小。具体的可以看这里:

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



在传递参数的时候,其实是传递了一一个slice结构体,这个时候当然是值传递。我们来验证一下:



package main

import "fmt"

func SliceRise(s []int)  {
	fmt.Printf("%p\n", &s)
	s = append(s, 0)
	for i := range s {
		s[i]++
	}
}

func main()  {
	s1 := []int{1, 2}
	s2 := s1
	s2 = append(s2, 3)
	fmt.Printf("%p\n", &s1)
	SliceRise(s1)
	//SliceRise(s2)
	//fmt.Println(s1, s2)
}

//输出
//0xc000004078
//0xc000004090



通过计算可以知道slice结构体的大小为24byte,两个地址之差刚好是24byte。
地址不同,所以两个结构体不是同一个结构体。

然而结构体中的指针字段却包含了底层数组的地址,这就使得函数中的slice和函数外的slice都指向了同一个底层数组,这也就是有些时候,改变函数内部的slice也能影响到函数外部的slice的原因。



slice的扩容



有关扩容的详细规则可以看这篇博客:。

slice在append的时候,如果底层数组的大小(cap)不够了,就会发生扩容。发生扩容的时候,slice结构体的指针会指向一个新的底层数组,然后把原来数组中的元素拷贝到新数组中,最后添加上append的新元素,就完成了扩容。
所以在这个时候,函数内部slice的改变是不会影响到函数外部slice的。因为此时,两个结构体中的指针指向的底层数组已经不相同了。



回到开始



然后我们回到最开始的这段代码:



package main

func SliceRise(s []int)  {
	s = append(s, 0)
	for i := range s {
		s[i]++
	}
}

func main()  {
	s1 := []int{1, 2}
	s2 := s1
	s2 = append(s2, 3)
	SliceRise(s1)
	SliceRise(s2)
	fmt.Println(s1, s2)
}

//A: [2,3][2,3,4]
//B: [1,2][1,2,3]
//C: [1,2][2,3,4]
//D: [2,3,1][2,3,4,1]



选C也就不难解释了:

  • 首先s1在初始化的时候,分配了一个底层数组,len=2cap=2
  • 将s1赋值给s2,两者就指向了同一个底层数组;
  • s2发生扩容,因为cap不够了,这个时候s2指向一个新的底层数组,并且len=3cap=4
  • 然后调用两次SliceRise函数;
  • s1作为参数进入函数时,发生了扩容,因为cap不够了,所以新分配了一个底层数组,这个时候,main函数中的s1与SliceRise中的s1已经分道扬镳了。所以main函数中的s1不会有任何改变;
  • s2作为参数进入函数时,同样发生了扩容,但是cap还够,所以不会分配新的底层数组,接下来的所有改变都会影响到s2所指向的底层数组。但是main函数结构体中len字段的值却不会发生变化,所以即使底层数组的第4位append了0,切片s2也访问不到。所以最后在输出s2的时候,只能输出s2的前len位,也就是前3位。
  • 因此最终在main函数中,s1输出[1,2],而s2输出[2,3,4]。