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运行时会阻止原切片读取这些值,因为它们超出了原切片的长度。下面的图可能表达的会更清楚一些:
这里也证明了刚才上面的结论,传递给函数的切片可以被修改其内容,但切片不能被调整大小。所以,如果我们在传递切片时,涉及到切片的修改、新增都应该谨慎操作,防止入坑太深。