从最近遇到一个bug说起,示意代码如下:
func test(a interface{}) {
s, _ := a.([]int)
s = append(s, 1)
fmt.Printf("%v,%p\n", s, &s)
return
}
func main() {
s := make([]int, 0)
test(s)
fmt.Printf("%v,%p\n", s, &s)
}
期望的是函数里面对切片的操作会返回出来,结果却没有。
运行结果如下:
[1],0xc000004090
[],0xc000004078
上述过程涉及两个问题,一是函数传接口或者切片类型的参数到底是传值还是传引用,一是切片转接口发生了什么。
官方文档说明如下,出处 https://go.dev/doc/faq#pass_by_value。
When are function parameters passed by value?
As in all languages in the C family, everything in Go is passed by value. That is, a function always gets a copy of the thing being passed, as if there were an assignment statement assigning the value to the parameter. For instance, passing an int value to a function makes a copy of the int, and passing a pointer value makes a copy of the pointer, but not the data it points to. (See a later section for a discussion of how this affects method receivers.)
Map and slice values behave like pointers: they are descriptors that contain pointers to the underlying map or slice data. Copying a map or slice value doesn't copy the data it points to. Copying an interface value makes a copy of the thing stored in the interface value. If the interface value holds a struct, copying the interface value makes a copy of the struct. If the interface value holds a pointer, copying the interface value makes a copy of the pointer, but again not the data it points to.
大致意思是go语言中,所有函数参数的传递都是用的值复制的方式传递参数,包括切片,接口,map等。那为什么map表现起来像在传引用呢,是因为map中的指针复制后,其指向的地址是相同的,所以对相同地址的操作看起来像是在传递引用。
切片从实现上看定义如下:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
一个切片是由指针,长度,和容量组成。Data
是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度与容量的标识。
当切片作为参数传递到函数中的时候, 切片的这三个字段都会被复制成为一个新的切片;新切片与旧切片指向相同的地址,但是长度和容量其实已经被复制了;这样的行为如果不了解就会导致一些莫名其妙的问题。比如函数内对切片进行修改元素操作,如果直接修改旧的元素,因为是修改的Data指向的地址,所以函数外也能看到修改后的元素;但是如果是添加或者删除元素,函数内部对Len或者Cap的修改并不会反应到函数外部。所以一个良好的建议是,切片参数用取地址的方式来进行传递。
如下代码可以更好的理解:
func test(a interface{}) {
s, _ := a.([]int)
s[0] = 10
s = append(s, 2)
fmt.Printf("%v,%p\n", s, &s)
return
}
func main() {
s := make([]int, 1)
s[0] = 1
test(s)
fmt.Printf("%v,%p\n", s, &s)
}
输出结果:
[10 2],0xc000096078
[10],0xc000096060
可以看到0号位置的元素修改后可以反应到函数外部,而添加的元素则丢失了,原因就是切片的长度复制后在函数内的修改不会反应到函数外部,而指针指向的内容修改是外部可见的。可以进一步将切片内部打印出来:
type slice struct {
array unsafe.Pointer
len int
cap int
}
func printSlice(a []int) {
b := (*slice)(unsafe.Pointer(&a))
fmt.Printf("%v,%+v\n", a, b)
}
func test(a interface{}) {
s, _ := a.([]int)
s = append(s, 2)
s[0] = 10
fmt.Printf("%v,%p\n", s, &s)
printSlice(s)
return
}
func main() {
s := make([]int, 1)
s[0] = 1
printSlice(s)
test(s)
fmt.Printf("%v,%p\n", s, &s)
printSlice(s)
}
执行结果:
[1],&{array:0xc0000a8060 len:1 cap:4}
[10 2],0xc0000960c0
[10 2],&{array:0xc0000a8060 len:2 cap:4}
[10],0xc000096060
[10],&{array:0xc0000a8060 len:1 cap:4}
可以看到切片的指针是一样的,但是len在函数返回后并没有将test函数内的修改返回。这里切片初始化的时候指定了容量是4,如果不指定容量,test函数中的append会导致切片重新分配内存,指针地址就会改变,而对新分配的地址的修改也会丢失。
当需要将切片的修改返回到函数外部的时候,正确的做法是取切片地址传参数。
接口内部实现上分为两种:
1,使用 runtime.iface 结构体表示包含方法的接口
2,使用 runtime.eface 结构体表示不包含任何方法的 interface{}
类型
上述切片转接口就是转成第二种eface接口,定义如下:
type eface struct { // 16 字节
_type *_type
data unsafe.Pointer
}
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str nameOff
ptrToThis typeOff
}
直观的推测,当切片转为接口之后,_type字段表示切片类型,data指向原有切片的地址。代码验证:
type eface struct { // 16 字节
_type unsafe.Pointer
data unsafe.Pointer
}
type slice struct {
array unsafe.Pointer
len int
cap int
}
func printSlice(a *[]int) {
b := (*slice)(unsafe.Pointer(a))
fmt.Printf("%p, %v,%+v\n", a, a, b)
}
func test(a interface{}) {
fmt.Printf("slice:%p, %v\n", &a, a)
b := (*eface)(unsafe.Pointer(&a))
fmt.Printf("[eface]%p, %v,%+v\n", &a, a, b)
c := a.([]int)
printSlice(&c)
c[0] = 10
c = append(c, 20)
d := (*slice)(unsafe.Pointer(b.data))
fmt.Printf("[eface.data]%v,%+v\n", d, d)
return
}
func main() {
s := make([]int, 1)
s[0] = 1
printSlice(&s)
test(s)
printSlice(&s)
}
输出:
0xc000004078, &[1],&{array:0xc00000a098 len:1 cap:1}
slice:0xc00004e230, [1]
[eface]0xc00004e230, [1],&{_type:0x3d47a0 data:0xc0000040c0}
0xc0000040d8, &[1],&{array:0xc00000a098 len:1 cap:1}
[eface.data]&{0xc00000a098 1 1},&{array:0xc00000a098 len:1 cap:1}
0xc000004078, &[10],&{array:0xc00000a098 len:1 cap:1}
仔细观察输出,可以发现接口的data字段指向一个复制后的切片,切片内部的array域都是指向同一个地址,因此切片以接口形式传入函数内部其实也发生了一次值赋值,函数内部对与切片的长度或者容量的修改,也不会返回到函数外部。