深度解密Go语言之值传递
前言
在 Golang 中,无论是函数参数还是返回值,皆采用值传递的方式,即会将实参复制一份作为函数形参。
而对于成员函数中的函数接收者,比如下面代码中的x
和y
。
type User struct {
Age int
}
// 值类型
func (x User) Add() {
x.Age = x.Age + 1
fmt.Printf("x address : %p, value: %v\n", &x, x)
}
// 指针类型
func (y *User) AddPtr() {
y.Age = y.Age + 1
fmt.Printf("y address:%p ,y value: %p, the value pointed to by the pointer: %v\n", &y, y, *y)
}
由于成员函数等价于第一个参数是函数接收者的普通函数,因此函数接收者与普通参数一样,也是采取值传递的方式。
// 值类型
func Add(x User) {
x.Age = x.Age + 1
fmt.Printf("x address : %p, value: %v\n", &x, x)
}
// 指针类型
func AddPtr(y *User) {
y.Age = y.Age + 1
fmt.Printf("y address:%p ,y value: %p, the value pointed to by the pointer: %v\n", &y, y, *y)
}
既然是值传递,那么在函数内部访问形参对象,理论上不应改变函数外部实参对象的值才对。然而,当我们将 map
、slice
、chan
等类型作为参数时,在函数内对形参进行写操作,函数外部实参却受到了影响,这究竟是怎么回事呢?
本文将介绍不同类型的参数在传递时,函数内部对形参的写操作是否可以影响到外部对象以及其原因。同时,本文还收集了参数传递的最佳实践和高频面试题,期望对您的工作和面试有所帮助。
1. 参数类型
基本类型
基本数据类型如 int
、int8
、int16
、int32
、int64
、uint
、uint8
、uint16
、uint32
、uint64
、uintptr
、float32
、float64
、string
、bool
、byte
、rune
、Array
、Structs
作为函数参数时,在函数内部对参数进行写操作,无法影响外部对象,除非参数类型为指针类型。例如下面的例子:
package main
import "fmt"
type User struct {
Age int
}
func main() {
a := User{Age: 18}
fmt.Printf("before add a address : %p, value: %v\n", &a, a)
Add(a)
fmt.Printf("after add a address : %p, value: %v\n", &a, a)
AddPtr(&a)
fmt.Printf("after addptr a address : %p, value: %v\n", &a, a)
}
// 通过值传递
func Add(x User) {
x.Age = x.Age + 1
fmt.Printf("x address : %p, value: %v\n", &x, x)
}
// 通过指针传递
func AddPtr(y *User) {
y.Age = y.Age + 1
fmt.Printf("y address:%p ,y value: %p, the value pointed to by the pointer: %v\n", &y, y, *y)
}
// 输出
// before add a address : 0xc000018080, value: {18}
// x address : 0xc000018088, value: {19}
// after add a address : 0xc000018080, value: {18}
// y address:0xc000012030 ,y value: 0xc000018080, the value pointed to by the pointer: {19}
// after addptr a address : 0xc000018080, value: {19}
在 Add 方法中,我们改变值 x.Age++
,a.Age
仍然是 18
。原因是x
的内存地址与 main()
中的 a
不一样,x
是由Go 复制a
的值并构造的新对象。
graph TD
a["`Age{18}
a(地址0xc000122008`"]
b["`Age{19}
b(地址0xc000122010`"]
a -->|copy| b
在 AddPtr
方法中,y
是指针类型变量,变量内容为0xc000018080
,也就是a
的地址,代表y
指针所指向的对象是 a
。所以我们在 AddPtr
中对 y
所指向对象的赋值,实际上就是对函数外部变量a的操作,我们尝试y.Age++
,a.Age
的值从18
变成19
。
graph TD
a["`Age{18->19}
a(地址0xc000122008`"]
b["`Age{18->19}
b(地址0xc000122008`"]
a -->|地址copy| b:::someclass
classDef someclass fill:#f96
b -->|y.Age++| a
特殊类型
对于map、chan和slice类型,在函数内部修改它的内容,可以影响到函数外部对象。
- map类型
对于map类型参数,没有明显的指针但是却可以在函数内部改变函数外部map的值。比如我们在下面update函数内修改形参u[1]="u2"
,在main函数中可以观察到,users[1]
的值从u1
变成了u2
。
package main
import "fmt"
func main() {
users := make(map[int]string)
users[1] = "u1"
fmt.Printf("before update: user:%v\n", users[1]) // before update: user:u1
update(users)
fmt.Printf("after update: user:%v\n", users[1]) // after update: user:u2
}
func update(u map[int]string) {
u[1] = "u2"
}
既然值传递是一份拷贝,函数内的修改并不影响外面实参的值,那为什么map在函数内部的修改可以影响外部呢?
实际上,当我们创建map对象时,golang底层给我们返回了一个hmap
的指针,因此可以理解成map==*hmap
,update(u map)
这样的函数,其实就等价于update(u *hmap)
,相当于传递了一个指针进来。
func makemap(t *maptype, hint int, h *hmap) *hmap {
}
而对于指针类型的参数来说,只是复制了指针本身,指针所指向的地址还是之前的地址。所以对函数内部map的修改是可以影响到函数外部的。
graph TD
a["users(隐式指针)"]
b["hmap{}"]
c["u(隐式指针)"]
a --> b
a -->|指针copy| c
c --> b
- chan类型
chan类型和map类型一样,当我们创建chan对象时,golang底层给我们返回了一个hchan的指针,因此函数内部可以通过指针访问影响到函数外部对象。
func makechan(t *chantype, size int) *hchan {
}
- slice类型
和map、chan类型不一样,当我们创建slice类型对象时,golang给我们返回的不是一个指针,而是下面这个slice结构的对象。(想深入了解slice原理的,可以看往期文章Golang是如何实现动态数组功能的?可以往前面翻一翻)
type slice struct {
array unsafe.Pointer // 数组指针
len int // slice长度,len函数
cap int // slice容量
}
当把slice
当作函数参数时,会copy一份slice
结构作为形参,但是形参和实参底层的array
指针指向的是同一个数组。因此当我们在函数内部给slice
元素赋值时,会影响到函数外部对象。我们来看一个例子:
package main
import "fmt"
func main() {
a := make([]int, 0)
a = append(a, 1, 2)
fmt.Printf("outer1: %p, %p\n", &a, &a[0]) // outer1: 0xc0000a0018, 0xc0000a6020
update(a)
fmt.Println(a) // [3 2]
}
func update(b []int) {
fmt.Printf("inner1: %p, %p\n", &b, &b[0]) // inner1: 0xc0000a0030, 0xc0000a6020
b[0] = 3
fmt.Printf("inner2: %p, %p\n", &b, &b[0]) // inner2: 0xc0000a0030, 0xc0000a6020
}
//输出:
// outer1: 0xc0000a0018, 0xc0000a6020
// inner1: 0xc0000a0030, 0xc0000a6020
// inner2: 0xc0000a0030, 0xc0000a6020
// [3 2]
在上面的例子中,a和b的地址分别是0xc0000a0018
和0xc0000a0030
,b
是由实参a
拷贝而来。a[0]
和b[0]
的地址相同,都是0xc0000a6020
,说明a
和b
内部的指针成员变量array
指向的是同一个数组。可以观察到在update
函数内部给b[0]
赋值为3
,在main
函数中,a[0]
也变成了3
。
当我们在函数内部对slice类型的参数,执行append操作,对函数外部对象又有什么影响呢?答案是没有影响。我们分两种情况看一下。
当make创建的slice
容量不够时,在函数内部append
操作,会发生扩容。函数外部对象所能看到的数据不变,函数内部形参底层数组地址发生迁移。比如下面的例子,在函数内部对象b
append
前后,函数外部切片a
的地址、底层数组地址、len和cap都不变;函数内部对象b的地址不变,但是底层数组地址从0xc0000a6020
变成了0xc0000b4020
,len
和cap
也发生了变化。
package main
import "fmt"
func main() {
a := make([]int, 0)
a = append(a, 1, 2)
fmt.Printf("before append a: %p, %p, len:%d, capacity:%d\n", &a, &a[0], len(a), cap(a))
appendSlice(a)
fmt.Printf("after append a: %p, %p, len:%d, capacity:%d\n", &a, &a[0], len(a), cap(a))
fmt.Println(a)
}
func appendSlice(b []int) {
fmt.Printf("before append b: %p, %p, len:%d, capacity:%d\n", &b, &b[0], len(b), cap(b))
b = append(b, 3)
fmt.Printf("after append b: %p, %p, len:%d, capacity:%d\n", &b, &b[0], len(b), cap(b))
}
// 输出
// before append a: 0xc0000a0018, 0xc0000a6020, len:2, capacity:2
// before append b: 0xc0000a0030, 0xc0000a6020, len:2, capacity:2
// after append b: 0xc0000a0030, 0xc0000b4020, len:3, capacity:4
// after append a: 0xc0000a0018, 0xc0000a6020, len:2, capacity:2
// [1 2]
当make
创建的slice
容量足够时,在函数内部执行append
操作,不会发生扩容。函数外部对象所能看到的数据不变,函数内部形参底层数组地址也不会发生迁移,和外部对象仍然共享一个数组。比如下面的例子,在函数内部对象b
append
前后,函数外部切片a
的地址、底层数组地址、len
和cap
都不变。函数内部对象b的地址不变,底层数组地址也不变。实际上a底层数组的第三个元素的地址上已经有数据了。只不过因为len
为2
,所以我们无法看到第三个元素。
package main
import "fmt"
func main() {
a := make([]int, 0, 3)
a = append(a, 1, 2)
fmt.Printf("before append a: %p, %p, len:%d, capacity:%d\n", &a, &a[0], len(a), cap(a))
appendSlice(a)
fmt.Printf("after append a: %p, %p, len:%d, capacity:%d\n", &a, &a[0], len(a), cap(a))
fmt.Println(a)
}
func appendSlice(b []int) {
fmt.Printf("before append b: %p, %p, len:%d, capacity:%d\n", &b, &b[0], len(b), cap(b))
b = append(b, 3)
fmt.Printf("after append b: %p, %p, len:%d, capacity:%d\n", &b, &b[0], len(b), cap(b))
}
// 输出
// before append a: 0xc0000a4018, 0xc0000ae018, len:2, capacity:3
// before append b: 0xc0000a4030, 0xc0000ae018, len:2, capacity:3
// after append b: 0xc0000a4030, 0xc0000ae018, len:3, capacity:3
// after append a: 0xc0000a4018, 0xc0000ae018, len:2, capacity:3
// [1 2]
2. 最佳实践
函数参数和函数接收者什么时候该传值,什么时候该传指针?
下面是golang官方建议。
-
对于
map
、chan
、func
类型,实际上golang
底层传递的就是指针,因此传参时不用额外传指针。对于slice
类型,如果被调函数内部操作不会触发reslice
,比如append
操作,则不用传指针。否则需要传指针或者将函数内部slice
返回。 -
如果函数内部需要通过修改形参对实参生效,传指针。
-
如果参数是包含
sync.Mutex
或其它类似同步字段的结构体,为了避免并发类型成员变量复制,传指针。 -
对于大结构体或大数组,考虑到内存分配开销,传指针。
-
如果接收者是结构体、数组或切片,并且它的任何元素是指向可能会被修改的对象的指针,那么倾向于使用指针接收者,因为这会使函数调用者更清楚其意图。
-
如果接收者是一个小型数组或结构体等基础类型,内部成员变量没有特殊类型且没有指针,或者只是一个简单的基本类型,如
int
或string
,那么值接收者是有意义的。值接收者可以减少gc扫描的对象数量(栈上而不是在堆上分配内存)。 -
不要混合接收者类型。对于同一个结构体类型,要么全部方法都定义成指针接收者,要么全部定义成值类型接收者。
-
不知道该用什么的场景,都用指针类型。
3. 高频面试题
-
golang是值传递还是引用传递,解释原因。
-
调用函数传入结构体时,应该传值还是指针?(Golang 都是值传递)
-
slice底层实现,传参的时候传的是什么?
-
如果
map
的value
是基础类型,map
取一个key
,然后修改对应value
的属性,原map
数据的value
会不会变化?
不会变化,从map
取出value
的操作,在Go底层对应一个函数,该函数的返回值类型是value
类型。因此,如果value
是基本类型,函数返回值是原value
的拷贝,如果需要修改原value
,map
的value
类型可以是指针类型。
比如下面的例子,usersStruct
的value
类型是结构体类型,直接usersStruct["a"].Age = 19
编译不通过,且userA
和usersStruct["a"]
的值也不相等。usersPtr
的value
类型是指针类型,userB
和usersPtr["b"]
指向的是同一个对象,Age
值相等。
package main
import "fmt"
type User struct {
Age int
}
func main() {
usersStruct := make(map[string]User, 0)
usersStruct["a"] = User{Age: 18}
// usersStruct["a"].Age = 19 编译不通过,cannot assign to struct field usersStruct["a"].Age
userA := usersStruct["a"]
userA.Age = 19
fmt.Printf("userA age:%v,usersStruct[a] age:%v\n", userA.Age, usersStruct["a"].Age)
usersPtr := make(map[string]*User, 0)
usersPtr["b"] = &User{Age: 18}
userB := usersPtr["b"]
userB.Age = 19
fmt.Printf("userB age:%v,usersPtr[b] age:%v\n", userB.Age, usersPtr["b"].Age)
}
// 输出
// userA age:19,usersStruct[a] age:18
// userB age:19,usersPtr[b] age:19