深入理解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 的值并构造的新对象。

alt

在  AddPtr 方法中,y是指针类型变量,变量内容为0xc000018080,也就是a的地址,代表y指针所指向的对象是 a 。所以我们在 AddPtr 中对 y 所指向对象的赋值,实际上就是对函数外部变量a的操作,我们尝试y.Age++,a.Age 的值从18变成19。

alt

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的修改是可以影响到函数外部的。

alt

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。

alt

当我们在函数内部对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]

alt

当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]

alt

2 最佳实践

函数参数和函数接收者什么时候该传值,什么时候该传指针?

下面是golang官方建议。

alt

  • 对于map、chan、func类型,实际上golang底层传递的就是指针,因此传参时不用额外传指针。对于slice类型,如果被调函数内部操作不会触发reslice,比如append操作,则不用传指针。否则需要传指针或者将函数内部slice返回。

  • 如果函数内部需要通过修改形参对实参生效,传指针。

  • 如果参数是包含sync.Mutex或其它类似同步字段的结构体,为了避免并发类型成员变量复制,传指针。

  • 对于大结构体或大数组,考虑到内存分配开销,传指针。

  • 如果接收者是结构体、数组或切片,并且它的任何元素是指向可能会被修改的对象的指针,那么倾向于使用指针接收者,因为这会使函数调用者更清楚其意图。

  • 如果接收者是一个小型数组或结构体等基础类型,内部成员变量没有特殊类型且没有指针,或者只是一个简单的基本类型,如intstring,那么值接收者是有意义的。值接收者可以减少gc扫描的对象数量(栈上而不是在堆上分配内存)。

  • 不要混合接收者类型。对于同一个结构体类型,要么全部方法都定义成指针接收者,要么全部定义成值类型接收者。

  • 不知道该用什么的场景,都用指针类型。

3 高频面试题

  1. golang是值传递还是引用传递,解释原因。

  2. 调用函数传入结构体时,应该传值还是指针?(Golang 都是值传递)

  3. slice底层实现,传参的时候传的是什么?

  4. 如果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面试核心考点 文章被收录于专栏

golang语言核心功能、原理、实践和面试

全部评论
看完这篇,下次就不用怕问golang值传递还是引用传递的问题了#golang#
点赞 回复 分享
发布于 09-01 16:47 北京

相关推荐

2 12 评论
分享
牛客网
牛客企业服务