西梧 runtime

Go Slice 在函数间传递的防范

  • 2023-05-17 16:16:16
  • qtsunami

Go 语言中的数组是值类型,其赋值和函数传参也都是值传递,会复制整个数组数据。

切片是引用类型,它是对数组一个连续片段的引用,是一个长度可变的数组。

所以很多同学,也会理所当然的认为,在函数之间传递切片也是通过引用进行传递的。而实际上函数间传递切片是以值的方式传递的。

我们可以看一个示例:

package main

import "fmt"

func main() {
	var s = []int{1, 2, 3, 4, 5}
	fmt.Printf("s 底层数组指针: %p\n", s)
	fmt.Printf("s 本身指针: %p\n", &s)
	printSlice(s)
}

func printSlice(st []int) {
	fmt.Printf("st 底层数组指针: %p\n", st)
	fmt.Printf("st 本身指针: %p\n", &st)
}

这里注意下,直接打印 s 或 st,指向的是切片指向的底层数组的地址,而加上 & 符号才是切片本身的地址,结果如下:

// Output:
// s 底层数组指针: 0xc00000e2d0
// s 本身指针: 0xc000008078
// st 底层数组指针: 0xc00000e2d0
// st 本身指针: 0xc0000080a8

其实可以发现,切片本身的指针在传递前和传递后是不一致的。接下我们试下使用指针传递切片:

package main

import "fmt"

func main() {
	var s = []int{1, 2, 3, 4, 5}
	fmt.Printf("s : %p\t&s %p\n", &s, s)
	printSlicePoint(&s)
}
func printSlicePoint(st *[]int) {
	fmt.Printf("st: %p\t&st:%p\n", st, *st)
}

其结果如下:

// s : 0xc000008078        &s 0xc00000e2d0
// st: 0xc000008078        &st:0xc00000e2d0

上述可以得到,无论是切片本身还是切片底层数组的指向都是与传递前是一致的。

所以,这里引申出一个问题,有没有必要使用指针来传递切片呢?

答案是没必要的。

切片本身是引用类型,是一个很小的对象,是对底层数组进行了抽象,并且提供了相关的操作方法。

并且,切片的数据结构有 3 个字段,分别是指向底层数组的指针、切片访问的元素的数量(长度)以及切片允许增长到的元素数量(容量)。

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

在 64 位架构的机器上,一个切片需要24个字节的内存:底层指针字段需要8字节,长度及容量各需要8字节。

当切片复制到任意函数时,对底层数组的大小不会有任何影响。复制时也只会复制切片本身,也不会涉及到底层数组。

所以,这也是切片效率高的原因,不需要传递指针和处理复杂的语法。

正是因为切片的特殊性,本身是引用类型,在函数之间传递则是值传递。在修改和增加切片的时候,也是我们一不小心就容易踩坑的地方。

我们再看段代码:

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4, 5}
    viewSlice(s)
    fmt.Println(s)
}

func viewSlice(s []int) {
	s[2] = 122
	fmt.Println(s)
}

结果是显而易见的,打印出来的都是 [1 2 122 4 5],我们换成下列代码:

func viewSlice(s []int) {
	s = append(s, 11)
    s[3] = 133
	fmt.Println(s)
}

这次就不是那么显而易见了:main 函数中打印的是 [1 2 3 4 5],而viewSlice 函数中打印的则是 [1 2 3 133 5 11]。我们可以看到对切片的操作并没有影响到原切片的值。

如果我们再把 main 函数中的切片声明变成: s := make([]int, 5, 10) ,那打印还会跟刚才一样吗?你可以试试。

到这里,我们可以得出一个结论:在函数间传递切片,任何对切片内容的修改都会对原始变量产生影响,但使用 append 来给切片增加新元素并不会对原始变量产生影响,即使切片的容量大于它的长度。

这是由切片实现的结构决定的。我们上面说了,切片由三个字段组成:一个表示长度的 int 字段,一个表示容量的 int 字段以及一个指向内存块的指针。

当一个切片被复制到一个不同的变量或传入到一个函数时,长度、容量和指针都被复制了。而改变切片中的值会改变指针所指向的内存,所以这些变化对副本和原切片都是可见的。

但是对长度和容量的改变不会反映在原切片上,因为它们只在副本中,改变容量意味着指针现在指向了一个新的、更大的内存块。

这个其实是跟切片扩容的机制原理有关系。

如果容量足够,切片副本的元素增加,那么副本的长度就会改变,新的值会被存储在副本和原切片共享的内存块中。

然而,原切片的长度依旧保持不变,这也意味着Go运行时会阻止原切片读取这些值,因为它们超出了原切片的长度。下面的图可能表达的会更清楚一些:

weread_image_378620612886496.jpeg

这里也证明了刚才上面的结论,传递给函数的切片可以被修改其内容,但切片不能被调整大小。所以,如果我们在传递切片时,涉及到切片的修改、新增都应该谨慎操作,防止入坑太深。

© 2023 By 西梧Runtime.    本站博客未经授权禁止转载   |   京ICP备15032626号-1