深入理解Golang 值传递,读这一篇就够了(深度好文)
在 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 参数类型
1.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
的值并构造的新对象。
在 AddPtr
方法中,y是指针类型变量,变量内容为0xc000018080,也就是a的地址,代表y指针所指向的对象是 a 。所以我们在 AddPtr
中对 y 所指向对象的赋值,实际上就是对函数外部变量a的操作,我们尝试y.Age++
,a.Age 的值从18变成19。
1.2 特殊类型
对于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的修改是可以影响到函数外部的。
chan类型
chan类型和map类型一样,当我们创建chan对象时,golang底层给我们返回了一个hchan的指针,因此函数内部可以通过指针访问影响到函数外部对象。
func makechan(t *chantype, size int) *hchan {
}
slice类型
和map、chan类型不一样,当我们创建slice类型对象时,golang给我们返回的不是一个指针,而是下面这个slice结构的对象。(想深入了解slice原理的,可以看往期文章Golang是如何实现动态数组功能的?Slice切片原理解析)
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
#golang##golang工程师##golang面经##golang后端##golang实习#golang语言核心功能、原理、实践和面试