切片传参的问题

以下内容来自:毛毛鱼 - golang 切片传参


今天写题时遇到一个需要用到大顶堆的题,以往我是会手写一个堆的,但今天想熟悉一下 go 的标准包就用了标准库提供的 heap 包,实现接口后发现运行不对,排查后发现是实现函数传参时 Push 和 Pop 方法传了切片而不是切片的指针,因此要留个记录,防止再掉坑~

说切片前需要先说一下数组

首先需要明确的是:数组类型定义了长度和元素类型,即长度和元素属于数组这个数据类型的一部分,因此当将两个不同长度的数组进行赋值时会报错,因为这两个不同长度的数组根本不是同一种类型

在 C 语言中,数组变量是指向第一个元素的指针,但在 golang 中,数组变量属于值类型,因此将一个数组变量进行赋值或者传参时,实际上会复制整个数组

切片:

切片本质上是对一个数组片段的索引,包括这个数组的指针,切片的长度和容量

1
2
3
4
5
struct {
	ptr   *[] int
	len   int
	cap   int
}

在 go 中,函数是通过值传递来传参的,也就是说在函数中传入一个切片,实际传入的是这个切片的 copy,但是因为切片中本身记录了底层数组的指针,因此在函数中对传来的切片进行操作,还是可以操作到同一个底层数组,底层数组的某个值发生了变化,当然也可以影响到外面的切片

但是问题就在于这个底层数组,我们都知道切片是可以扩容的,如果扩容后的容量没超出底层数组的长度那么底层数组不会变,但如果扩容后容量大于了原来底层数组的长度,就会开辟一个新的底层数组,更新切片的底层数组指针,函数内外切片的底层数组不一样了,自然函数内的改变就反映不到函数外了

那么该如何避免呢,只需要在进行可能对切片容量产生影响的操作时传切片的指针即可,这样即便发生了底层数组的变化,更新的也是同一个切片的底层数组指针,借用官方说法:定义切片参数时

use pointer receivers if they modify the slice’s length, not just its contents.


切片传参示例

切片本质是 SliceHeader,且 Go 语言只有值传递,所以在切片作为函数参数时,传递的是 SliceHeader 的副本,SliceHeader 包括了三个字段:Data、Len 和 Cap,Data 是一个指向底层数组的起始位置的指针(关键)

正常情况下,我们认为我们在函数内部对切片进行修改时,也会造成函数外面切片值的变化。

但我们需要注意的是,这个同步修改也是有限制的,那就是函数内部不能对原切片进行 append 操作。

要看在函数中,我们修改的是 Data 指向的底层数组,还是 Data 指针的指向。

如果是前者的话,就会同步修改,如果是后者的话,就不会同步修改。

如果我们在函数内部对切片进行了 append,就会在新的地址开辟一段空间来存放函数内部的切片,这时,函数内部的切片和外部的切片就不是同一片地址了。

可以通过下面三个函数来进行说明:

为了前面的修改不影响后面的变量,我们定义了三个相同的变量 a、b、c,以及三个函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func main () {
	a := make ([] int, 2)
	a [0] = 1
	changeSlice1 (a)
	fmt.Println (a)

	b := make ([] int, 2)
	b [0] = 1
	changeSlice2 (b)
	fmt.Println (b)

	c := make ([] int, 2)
	c [0] = 1
	changeSlice3 (&c)
	fmt.Println (c)
}
func changeSlice1 (a [] int) {a [1] = 20}
func changeSlice2 (a [] int) {a = append (a, 30)}
func changeSlice3 (a *[] int) {*a = append (*a, 30)}
  • 函数 changeSlice1 () 传递的是切片类型,当我们直接使用下标对内部切片 a 进行修改时,main 函数中的切片 a 也会被修改,因为它们指向的是同一个底层数组。

    这里传递的是 SliceHeader 的副本,main 函数中切片 a 的 SliceHeader 的 Data 指针和 changeSlice1 () 函数中切片 a 的 SliceHeader 的 Data 指针,指向的都是同一个底层数组。

  • 函数 changeSlice2 () 传递的也是切片,但是在函数中,使用 append 方法对内部切片 a 进行扩容,这时候就会在内存中新开辟一段内存(新的底层数组)来存放新的切片,内部变量 a 指向了新底层数组的地址,而 main 函数中的切片 b 指向的还是原来的底层数组的地址,它们就不一样了。

    这里传递的也是 SliceHeader 的副本,但在 changeSlice2 () 函数中,修改了 切片 a 的 SliceHeader 的 Data 指针的指向。

  • 函数 changeSlice2 () 传递的也是切片的引用(指针),即使在函数中开辟了新的底层数组,main 函数中的 c 也会指向新的底层数组,而不是原来的底层数组,这样函数内外就可以保证同步变化了。

    这里传递的也是 SliceHeader 的地址,所以,在 changeSlice3 () 函数中,修改了 切片 a 的 SliceHeader 的 Data 指针的指向,main 函数中也会同步变化,因为它们都是对同一个 SliceHeader 对象进行的修改。

上面程序的输出结果:

1
2
3
[1 20]
[1 0]   
[1 0 30]

注意

需要注意的是:

  • 如果在 main 函数中使用 append 操作,如果没有发生扩容,则会在原来的底层数组中去添加元素,不会开辟新的空间。

    底层数组地址的变化

    这种行为背后的原因是 Go 语言的内存管理和性能优化。其原则是:当原切片的底层数组还有足够的容量可供使用时,为了节省内存和提高性能,Go 语言会尽可能地利用原切片的底层数组,而不是每次都创建一个新的数组。

    1. 如果没有发生扩容,Go 语言会直接在原来的底层数组后面添加新的元素。
    2. 如果发生了扩容,也会在尽量在原来底层数组的后面去开辟新增的内存空间,如果原切片底层数组地址后面的内存空间已经被占用,Go 语言会重新分配一个更大的连续内存空间来存储扩容后的切片。

    这种处理方式也使得切片在动态增长时可以保持高效的内存分配和管理,同时也避免了频繁的内存分配和释放,提高了程序的性能和效率。

    示例:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    func main() {
    	a := make([]int, 2)
    	fmt.Println(a)              // [0 0]
    	fmt.Println(len(a), cap(a)) // 2 2
    	fmt.Printf("%p\n", &a)      // 0xc000008048
    
    	a = append(a, 20, 30, 40, 50, 60)
    	fmt.Println(a)              // [0 0 20 30 40 50 60]
    	fmt.Println(len(a), cap(a)) // 7 8
    	fmt.Printf("%p\n", &a)      // 0xc000008048
    }

    这个示例中,切片发生了扩容,但底层数组的首地址还是没有发生变换,验证了上面的说法。

  • 使用切片类型的参数传递到另外一个函数 A 中,在函数 A 中使用 append 操作,即使没有发生扩容,也会将函数内的切片变量指向新的底层数组,和 main 函数中的地址不相同。

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func main() {
	a := make([]int, 2, 10)
	fmt.Println(a)         // [0 0]
	fmt.Printf("%p\n", &a) // 0xc000008048

	a = append(a, 20)
	fmt.Println(a)         // [0 0 20]
	fmt.Printf("%p\n", &a) // 0xc000008048

	changeSlice(a)
	fmt.Println(a)         // [0 0 20]
	fmt.Printf("%p\n", &a) // 0xc000008048
}

func changeSlice(a []int) {
	a = append(a, 99)
	fmt.Printf("%p\n", &a) // 0xc000008090
} 

在这个示例中,main 函数中的切片 a 长度而 2,容量为 10。

  • main 函数中对切片 a 使用了 append 添加元素,由于没有进行扩容,所以是在原来底层数组的位置添加元素的,切片地址不变。
  • changeSlice () 函数的参数类型为切片,在函数中对内部切片 a 进行了 append 操作,原来 main 函数中的切片 a 的容量是足够添加新元素的,但 append 操作依然会开辟新的空间来存那新的切片内容,与原来的容量大小无关。
0%