【Go 进阶】Go 语言到底是值传递,还是引用传递?

9,407 阅读29分钟

本文是《GO 进阶》系列第一篇 ~

Go 语言里有指针的概念,它比 C++ 的指针要简单的多,同时你需要记住一个概念:Go 语言是 值传递。我们今天探讨的是在编码的时候到底该使用指针呢还是值类型?在作为参数和返回值的时候该如何去使用?两种传递方式有什么区别?

要搞懂这些问题,需要对 “Go语言是值传递” 这句话有深刻的理解。

1、Go 语言是值传递

先说结论,Go里面没有引用传递,Go语言是值传递。很多技术博客说Go语言有引用传递,都是没真的理解Go语言。而Go语言中的一些让你觉得它是引用传递的原因,是因为Go语言有值类型和引用类型,但是它们都是值传递。

  • 值传递:指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。

  • 引用传递:指在调用函数时将实际参数的地址直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。

1.1 值类型和引用类型

可能对初学者来说,可能会搞混值类型和值传递,引用类型和引用传递。为了避免这种低级错误,先来了解一下 Go 语言中的值类型和引用类型:

  • 值类型:变量直接存储值,内存通常在栈上分配,栈在函数调用完会被释放。比如:int、float、bool、string、array、sturct 等。

  • 引用类型:变量存储的是一个地址,这个地址存储最终的值。内存通常在堆上分配,通过GC回收。比如:slice,map,channel,interface,func 等。

    • 严格来说,Go 语言没有引用类型,但是我们可以把 map、chan、func、interface、slice 称为引用类型,这样便于理解。
    • 指针类型也可以理解为是一种引用类型

这里提到了堆和栈,简单介绍下内存分配中的堆和栈:

  • 栈(操作系统):由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
  • 堆(操作系统): 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。

image.png

这幅图中展示了常用的值类型和引用类型(注意:引用类型和传引用是两个概念)。在左边是我们常用的一些值类型,函数调用时需要使用指针修改底层数据;而右边是 “引用类型”,我们可以理解为它们的底层都是指针类型,所以右边的类型在使用的时候会有些不同。

所以在 Go 语言中:

  • 引用类型作为参数时,称为浅拷贝,形参改变,实参数跟随变化。因为传递的是地址,形参和实参都指向同一块地址
  • 值类型作为参数时,称为深拷贝,形参改变,实参不变,因为传递的是值的副本,形参会新开辟一块空间,与实参指向不同
  • 如果希望值类型数据在修改形参时实参跟随变化,可以把参数设置为指针类型

1.2 类型的零值

  1. 在 Go 语言中,定义变量可以通过声明或者通过 make、new函数,区别是 make 和 new 函数属于显示声明并初始化。
  2. 如果我们声明的变量没有显示的声明初始化,那么该变量的默认值就是对于类型的零值。
类型零值
数值类型(int、float等)0
boolfalse
string""(空字符串)
struct内部字段的零值
slicenil
mapnil
指针nil
funcnil
channil
interfacenil

1.3 值传递

一定要记住,在 Go 语言中,函数的参数传递只有值传递,而且传递的实参都是原始数据的一份拷贝。如果拷贝的内容是值类型的,那么在函数中就无法修改原始数据;如果拷贝的内容是指针(或者可以理解为引用类型 map、chan 等),那么就可以在函数中修改原始数据。

记住!Go 语言值传递! 可以看官网解释:When are function parameters passed 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.

大致意思:像 C 家族中的其他所有语言一样,Go 语言中的所有传递都是传值。也就是说,函数接收到的永远都是参数的一个副本,就好像有一条将值赋值给参数的赋值语句一样。例如,传递一个 int 值给一个函数,函数收到的是这个 int 值的副本,传递指针值,获得的是指针值的副本,而不是指针指向的数据。

参考:Should I define methods on values or pointers?,来了解这种方式对方法接收者的影响。

Map 和 Slice 的值表现和指针一样:它们是对内部映射或者切片数据的指针的描述符。复制 Map 和 Slice 的值,不会复制它们指向的数据。复制接口的值,会产生一个接口值存储的数据的副本。如果接口值存储的是一个结构体,复制接口值将产生一个结构体的副本。如果接口值存储的是指针,复制接口值会产生一个指针的副本,而不是指针指向的数据的副本。

1.4 一个典型的例子

理论讲完了,下面来看一个典型的值传递的例子:

package main

import (
	"fmt"
)

type student struct {
	name string
	age  int
}

func main() {
	i := 1
	str := "hello"
	stu := student{name: "iankevin", age: 18}

	test_demo(i, str, stu)
	fmt.Println(i, str, stu.age) // 1 hello 18
}

func test_demo(i int, str string, stu student) {
	i = 10
	str = "world"
	stu.age = 22
}

可以发现,虽然在函数里面对三个类型的变量都做了修改,但是并不会影响函数外的变量的值。那如果我们希望函数内的变量修改能影响到函数外的变量的值,怎么办呢?

答案是:传指针。

因为传指针的值传递,复制的是指针本身,意味着形参和实参地址是一样的。所以我们在函数内部的修改,就能影响到函数外的变量的值。

package main

import (
	"fmt"
)

type student struct {
	name string
	age  int
}

func main() {
	i := 1
	str := "hello"
	stu := &student{name: "iankevin", age: 18}
        
        // 注意这里的 i 和 str 要取地址入参
	test_demo(&i, &str, stu)
	fmt.Println(i, str, stu.age) // 10 world 22
}

func test_demo(i *int, str *string, stu *student) {
        // 注意这里的 i 和 str 传入的是指针,所以要先取值(解引用)再赋值
	*i = 10
	*str = "world"
	stu.age = 22
}

注意,这可不是引用传递,只是因为我们传入的是指针,指针本身是一份拷贝,但是对这个指针解引用之后,也就是指针所指向的具体地址是一样的,所以函数内部对形参的修改,是会影响实参的。

Go 中是值传递,一个方法 / 函数总是获取这个传递的拷贝,只是有一个分配声明给这个参数分配这个数值。拷贝一个指针的值就做了这个指针的拷贝,而不是指针指向的数据(重点理解)。

2、函数参数中的 type

2.1 基本数据类型

来看一个例子:

package main

import "fmt"

func main() {
	a, b := 0, 0

	fmt.Printf("====== 初始化 ====== \n")
	fmt.Printf("Memory Location a: %p, b: %p\n", &a, &b)
	fmt.Printf("value a: %d, b: %d\n", a, b) // 0 0

	Add(a)

	AddPtr(&b)

	fmt.Printf("\n ======  final ====== \n")
	fmt.Printf("Memory Location a: %p, b: %p\n", &a, &b)
	fmt.Printf("value a: %d, b: %d\n", a, b) // 0 1
}

// 通过值传递
func Add(x int) {
	fmt.Printf("\n======   'Add' 函数 ====== \n")
	fmt.Printf("Before Add, Memory Location: %p, Value: %d\n", &x, x)
	x++
	fmt.Printf("After Add, Memory Location: %p, Value: %d\n", &x, x)
}

// 通过指针传递
func AddPtr(x *int) {
	fmt.Printf("\n ======  'AddPtr' 函数 ====== \n")
	fmt.Printf("Before AddPtr, Memory Location: %p, Value: %d\n", x, *x)
	*x++ // We add * in front of the variable because it is a pointer, * will call value of a pointer
	fmt.Printf("After AddPtr, Memory Location: %p, Value: %d\n", x, *x)
}

输出:

====== 初始化 ====== 
Memory Location a: 0xc00010a000, b: 0xc00010a008
value a: 0, b: 0

======   'Add' 函数 ====== 
Before Add, Memory Location: 0xc00010a010, Value: 0
After Add,  Memory Location: 0xc00010a010, Value: 1

 ======  'AddPtr' 函数 ====== 
Before AddPtr, Memory Location: 0xc00010a008, Value: 0
After AddPtr,  Memory Location: 0xc00010a008, Value: 1

 ======  final ====== 
Memory Location a: 0xc00010a000, b: 0xc00010a008
value a: 0, b: 1

在 Add 方法中,内存位置的值与 main() 中的 a 不一样,因为 Go 复制了 a 的值并初始化了一个新的内存位置,所以如果我们改变值 x++,a 仍然是 0。a 的最终输出是0,因为它是 Pass by value。

在 AddPtr 方法中,值的内存位置与 b 相同,所以我们知道它共享同一个 "容器",我们在 AddPtr 中对 x 所做的一切都会影响 b 的值,我们尝试在 *x++ 中增加 x 的值。b 的最终输出是1,这是因为通过引用传递而改变的。

其他基本数据类型如 int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, float32, float64, string, bool, byte, rune, Array, Structs。Array 和 Struct 与基本数据类型具有相同的属性。

2.2 引用数据类型

2.2.1 map 类型

对于 Map 类型来说,一来我们可以通过函数修改它的内容,二来它没有明显的指针。

func main() {
    users := make(map[int]string)
    users[1] = "user1"

    fmt.Printf("before modify: user:%v\n", users[1])  // before modify: user:user1
    modify(users)
    fmt.Printf("after modify: user:%v\n", users[1])  // after modify: user:user2
}

func modify(u map[int]string) {
    u[1] = "user2"
}

我们都知道,值传递是一份拷贝,里面的修改并不影响外面实参的值,那为什么map在函数内部的修改可以影响外部呢?

通过查看源码我们可以看到,实际上make底层调用的是makemap函数,主要做的工作就是初始化hmap结构体的各种字段:

func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
    //...
}

通过查看src/runtime/hashmap.go源代码发现,make函数返回的是一个hmap类型的指针*hmap。也就是说map===*hmap。 现在看func modify(p map)这样的函数,其实就等于func modify(p *hmap),相当于传递了一个指针进来。

而对于指针类型的参数来说,只是复制了指针本身,指针所指向的地址还是之前的地址。所以对map的修改是可以影响到函数外部的。

2.2.2 chan 类型

chan类型本质上和map类型是一样的,这里不做过多的介绍,参考下源代码:

func makechan(t *chantype, size int64) *hchan {
    //...
}

chan也是一个引用类型,和map相差无几,make返回的是一个*hchan。

2.2.3 slice 类型

而 map 和 chan 使用 make 函数返回的实际上是 *hmap和*hchan 指针类型,也就是指针传递。slice 虽然也是引用类型,但是它又有点不一样。

简单来说就是,slice 本身是个结构体,但它内部第一个元素是一个指针类型,指向底层的具体数组,slice 在传递时,形参是拷贝的实参这个 slice,但他们底层指向的数组是一样的,拷贝 slice 时,其内部指针的值也被拷贝了,也就是说指针的内容一样,都是指向同一个数组。

我们先看一个简单的例子,对slice的某一元素进行赋值:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

下面举个例子:

func main() {
    arr := make([]int, 0)
    arr = append(arr, 1, 2, 3)
    fmt.Printf("outer1: %p, %p\n", &arr, &arr[0])
    modify(arr)
    fmt.Println(arr)  // 10, 2, 3
}

func modify(arr []int) {
    fmt.Printf("inner1: %p, %p\n", &arr, &arr[0])
    arr[0] = 10
    fmt.Printf("inner2: %p, %p\n", &arr, &arr[0])
}

//输出:
//outer1: 0x14000112018, 0x14000134000
//inner1: 0x14000112030, 0x14000134000
//inner2: 0x14000112030, 0x14000134000
//[10 2 3]

因为slice是引用类型,指向的是同一个数组。可以看到,在函数内外,arr 本身的地址 &arr 变了,但是两个指针指向的底层数据,也就是&arr[0]数组首元素的地址是不变的。所以在函数内部的修改可以影响到函数外部,这个很容易理解。

再来看另外一个稍微复杂的例子,函数内部使用append。这个会稍微不一样。

func main() {
    arr := make([]int, 0)
    //arr := make([]int, 0, 5)
    arr = append(arr, 1, 2, 3)
    fmt.Printf("outer1: %p, %p, len:%d, capacity:%d\n", &arr, &arr[0], len(arr), cap(arr))
    //modify(arr)
    appendSlice(arr)
    fmt.Printf("outer2: %p, %p, len:%d, capacity:%d\n", &arr, &arr[0], len(arr), cap(arr))
    fmt.Println(arr)
}

func appendSlice(arr []int) {
    fmt.Printf("inner1: %p, %p, len:%d, capacity:%d\n", &arr, &arr[0], len(arr), cap(arr))
    //modify(arr)
    arr = append(arr, 1)
    fmt.Printf("inner2: %p, %p, len:%d, capacity:%d\n", &arr, &arr[0], len(arr), cap(arr))
    //modify(arr) //&arr[0]的地址是否相等,取决于初始化slice的时候的capacity是否足够
}

这个问题就相对复杂的多了。

分两种情况:

1)make slice 的时候没有分配足够的capacity

arr := make([]int, 0) 像这种写法,那么输出就是:

outer1: 0x14000114018, 0x1400012e000, len:3, capacity:3
inner1: 0x14000114030, 0x1400012e000, len:3, capacity:3
inner2: 0x14000114030, 0x1400012c060, len:4, capacity:6
outer2: 0x14000114018, 0x1400012e000, len:3, capacity:3
[1 2 3]

image.png

  1. outer1: 外部传入一个slice,引用类型,值传递。
  2. inner1: 由于是值传递,所以arr的地址&arr变了,但是两个arr指向的底层数组首元素&arr[0],也就是array unsafe.Pointer。
  3. inner2: 在内部调用append后,由于cap容量不够,所以扩容,cap=cap*2,重新在新的地址空间分配底层数组,所以数组首元素的地址改变了。
  4. 回到函数外部,外部的 slice 指向的底层数组为原数组,内部的修改不影响原数组。

2)make slice 的时候分配足够的capacity

arr := make([]int, 0, 5)

像这种写法,那么输出就是:

outer1: 0x1400000c030, 0x1400001c050, len:3, capacity:5
inner1: 0x1400000c048, 0x1400001c050, len:3, capacity:5
inner2: 0x1400000c048, 0x1400001c050, len:4, capacity:5
outer2: 0x1400000c030, 0x1400001c050, len:3, capacity:5
[1 2 3]

虽然函数内部append的结果同样不影响外部的输出,但是原理却不一样。

image.png

不同点:

  1. 在内部调用append的时候,由于cap 容量足够,所以不需要扩容,在原地址空间增加一个元素,底层数组的首元素地址相同。
  2. 回到函数外部,打印出来还是[1 2 3],是因为外层的len是3,所以只能打印3个元素,实际上第四个元素的地址上已经有数据了。只不过因为len为3,所以我们无法看到第四个元素。

那正确的 append 应该是怎么样的呢:

appendSlice(&arr)

func appendSlice(arr *[]int) {
    *arr = append(*arr, 1)
}

传指针进去,这样拷贝的就是这个指针,指针指向的对象,也就是slice本身,是不变的,我们使用*arr可以对slice进行操作。

2.3 小结

  • Go里面没有引用传递,Go语言是值传递。
  • 如果需要函数内部的修改能影响到函数外部,那么就传指针。
  • map/channel 本身就是指针,是引用类型,所以直接传 map和channel 本身就可以。
  • slice 的赋值操作其实是针对 slice 结构体内部的指针进行操作,也是指针,可以直接传 slice 本身。
  • slice 的 append 操作 同时需要修改结构体的 len/cap,类似于 struct,如果需要传递到函数外部,需要传指针。(或者使用函数返回值)

3、函数接收者中的 type

3.1 值接收者和指针接收者

比如我们有两个结构体:

type Man struct {}

type Woman struct {}

我们分别使用指针接收者和值接收者给它们添加一个Say()方法:

// Say()方法的全名为(*Man).Say(),即只有指针类型*Man才有Say()方法
func (*Man) Say() {
    fmt.Println("man say")
}

// Say()方法的全名为(Woman).Say(),即只有值类型Woman才有Say()方法
func (Woman) Say() {
    fmt.Println("woman say")
}

这里虽然它们都是 Say() 方法,但实际上方法名是不一样的,如果你使用指针接收者,方法的全名为(*Man).Say(), 如果是值类型,则全名为 Woman.Say()。严格的来说,对于前者,只能使用 (*Man) 类型来调用 Say() 方法,后者则是只能使用 Woman 类型来调用,因为值类型 Man 并没有 Say()方法,同理指针类型 *Woman 也没有 Say() 方法。

但实际编码时,使用 Man.Say() 也能通过编译,为什么呢?因为编译器做了一层隐式转换,比如:

man := Man{} // man是个值类型
man.Say() // man say   ok, 编译器将man隐式转换成了 &Man

这是因为 go 的编译器为了我们做了一次隐式转换,即将 man.Say() 转换成了 (&man).Say(),也就是对 man 做了取地址操作。同理,如果实参是值类型而形参(方法接收者)是指针类型:

ptrWoman := &Woman{}
ptrWoman.Say() // woman say   ok, 编译器将ptrWoman隐式转换成了*Woman

编译器也会为了通过编译而尽量把指针类型 ptrWoman 向 Woman 类型上"套",这个"套"法就是对 ptrWoman 做隐式转换,转换成 (*ptrWoman).Say(),这样就跟方法名匹配上了。

那么既然编译器这么勤劳,为什么我们还需要关心这个问题呢?原因是对于接口类型,编译器"偷懒"了,并不会主动为我们做类型转换,比如我们定义一个 CanTalk 接口,里面就有这个 Say() 方法:

// 定义一个说话接口
type CanTalk interface {
	Say()
}

这样一来,Woman 和 Man 类型应该都实现了这个接口,对吧?其实不然,因为 Man 的 Say() 方法是指针接收者,所以严格来说是指针类型 *Man 实现了这个接口,而值类型 Man 并没有。同理,因为 Woman 的 Say() 方法是值类型,所以严格来说是 Woman 实现了这个接口,而 *Woman 则没有。所以,如果你把值类型 Man 的变量赋值给接口 CanTalk 是会报错的:

man := Man{} 

var canTalk CanTalk
canTalk = man // error, Man类型没有Say()方法

// error:cannot use man (type Man) as type CanTalk in assignment: 
// Man does not implement CanTalk (Say method has pointer receiver)

而反过来,如果将 指针类型的 *man 或者 值类型/指针类型的 woman 的变量赋值给CanTalk则没有问题:

man := &Man{} 

var canTalk CanTalk
canTalk = man

canTalk.Say() // man say

在Go语言中,接口变量实际上是一个由两部分组成的数据结构:一个指向实际值的指针(或数据),和一个指向类型信息的指针。而该类型信息包括方法集合信息、包路径等内容。

在将man := Man{}赋值给canTalk变量时,由于Man类型只实现了(*Man) Say()方法,而没有实现(Man) Say()方法,因此不能被赋值给CanTalk类型的变量,因为它无法满足CanTalk接口的要求。因此,这段代码会在编译期间报错:cannot use man (type Man) as type CanTalk in assignment: Man does not implement CanTalk (missing Say method)。

尽管*Man类型已经实现了Say方法,但是根据Go语言规范,只有带指针接收器的方法才可以被使用指针类型的变量调用。在这个例子中,虽然 man 是 Man 的一个值类型实例,它并不具备Man类型的Say方法可以被指针类型 CanTalk 变量调用的能力。因此,将man直接赋值给实现了Say方法的 CanTalk 类型变量会导致编译错误。解决这个问题的方法是,将man的地址赋值给 CanTalk 类型变量,即使用取地址符"&"进行显式转换:

var canTalk CanTalk
canTalk = &man

canTalk.Say()

这样就可以正确地通过 CanTalk 类型变量调用Man类型的Say方法了。

再来看看 Woman:

woman := Woman{} 
// or 
woman := &Woman{} 

var canTalk CanTalk
canTalk = woman

canTalk.Say() // woman say

woman := Woman{} 和 woman := &Woman{} 都是创建一个 Woman 类型的变量,但它们的类型和使用方式有所不同。

woman := Woman{} 是直接创建了一个 Woman 类型的值,并将其赋给 woman 变量。这时 woman 变量的类型就是 Woman,而不是指向 Woman 类型的指针。因此,对 woman 变量进行方法调用时,需要使用值类型,即:woman.Say()。同时,如果将 woman 赋值给实现了该类型的接口变量(如 var canTalk CanTalk = woman),Go 会在编译阶段自动为 woman 值生成一个指向该值的指针并赋给接口变量。

这里需要注意的是,由于值类型无法通过地址访问到,因此只有实现了 func (t T) MethodName() {...} 方法声明的值类型才可以被赋值给对应接口类型的变量。例如:func (Woman) Say() {...}。

woman := &Woman{} 则是创建了一个指向 Woman 类型的指针,并将该指针赋给 woman 变量。这时 woman 变量的类型就是 *Woman,而不是 Woman。因此,对 woman 变量进行方法调用时,需要使用指针类型,即:woman.Say() 或者 (*woman).Say()。同时,将 woman 赋值给实现了该类型的接口变量时(如 var canTalk CanTalk = woman),Go 会直接将 woman 指针赋给接口变量,无需做任何转换。

因此,使用 woman := &Woman{} 创建指向 Woman 类型的指针更为常见,可以方便地对对象进行修改和传递。如果需要直接操作值本身的数据,可以使用值类型。

需要注意的是:编译器确实会进行隐式转换,但这并不意味着你应该依赖于它。在一些情况下,隐式转换可能会导致编译错误或者程序行为不正确的问题。因此,尽量避免过度依赖编译器进行隐式转换,而是应该显式地转换数据类型以确保代码的正确性和可读性。

这里就得出一个经验,一旦你的函数的接收者的类型是指针类型并且实现一个接口的时候,在将一个实例赋值给接口类型的变量的时候,一定要是指针类型,否则编译器会报错。而如果你的函数接受者是值类型并且实现一个接口的时候,实例不管是值类型还是指针类型都会被编译器隐式生成一个指针指向接口变量,就不会报错了。

3.2 对参数值的影响

如果你之前写过 Java 代码的话经常会看到这样的代码:

public class Bar {
    String name;
    public void setName(String name){
        this.name = name;
    }
}

可以看到这里有 this 关键字,在 Go 中是没有的,这里的 this 可以调用当前对象的成员变量和实例方法,当使用 this 修改了成员变量就相当于在 Go 中使用了指针,看看下面的 Go 代码:

func (user *User) setNameByPoint(name string) {
	user.Name = name
}

func (user User) setName(name string) {
	user.Name = name
}

Go 中没有类这种语法,想要为结构体定义属于自己的方法就使用如上的两种方式,这两个方法在 Go 中称为 Receiver Type(接收者类型),可以使用结构体变量调用,我们今天只讨论结构体这种情况,来看看这两个方法有什么不同:

package main

import (
	"fmt"
)

type User struct {
	Name string
}

func (user *User) setNameByPoint(name string) {
	user.Name = name
}

func (user User) setName(name string) {
	user.Name = name
}

func main() {
	user := User{Name: "iankevin"}
	user.setName("hello")
	fmt.Println("user.Name:", user.Name) // user.Name: iankevin

	user.setNameByPoint("world")
	fmt.Println("user.Name:", user.Name) // user.Name: world
}

根据输出发现一个结构体,如果不使用指针类型的时候值是不会被修改的。这点也很容易理解,在 setName方法中 user 变量被作为值传递,所以如果这时候输出 user 的内存地址会发现和外面调用的是不一样的,来看看:

package main

import (
	"fmt"
)

type User struct {
	Name string
}

func (user *User) setNameByPoint(name string) {
	fmt.Printf("setNameByPoint: %v address: %p \n", user, &user) // 4
	user.Name = name
}

func (user User) setName(name string) {
	fmt.Printf("setName: %v address: %p \n", user, &user) // 2
	user.Name = name
}

func main() {
	user := User{Name: "iankevin"}
	fmt.Printf("src user: %v address: %p \n", user, &user) // 1

	user.setName("hello")
	fmt.Printf("by value user: %v address: %p \n", user, &user) // 3

	user.setNameByPoint("world")
	fmt.Printf("by point user: %v address: %p \n", user, &user) // 5
}

/**
1. src user: {iankevin}        address: 0xc000014270
2. setName: {iankevin}         address: 0xc000014290
3. by value user: {iankevin}   address: 0xc000014270
4. setNameByPoint: &{iankevin} address: 0xc000012030
5. by point user: {world}      address: 0xc000014270
**/

而 setNameByPoint 方法和前面的指针类型传递是一样的,方法内部内存地址是一份指针的拷贝,修改数据会影响到外部指针变量的数据。

一般而言,工程化的项目中会出现非常多结构体定义方法的代码,这些方法的调用也会很频繁,使用结构体将其封装起来,和 Java 中类封装是一样的,大多数情况下建议都使用指针传递,避免值拷贝的情况。

4、内存变化分析

我们使用值类型和指针类型在内存的视角上会有什么不同之处吗?这将使得我们对这两个概念理解更加深入。

4.1 函数/方法返回值的情况

package main

import (
	"fmt"
)

var a = "hello world"

func returnValue() string {
	return a
}

func returnPoint() *string {
	return &a
}

func main() {
	a1 := returnValue()
	a2 := returnPoint()

	fmt.Printf("a: %v address: %p \n", a, &a)     
	fmt.Printf("a1 : %v address: %p \n", a1, &a1)  
	fmt.Printf("a2 : %v address: %p \n", *a2, &a2) 
}

//  a : hello world address: 0xc000014270 
// a1 : hello world  address: 0xc000014280 
// a2 : hello world  address: 0xc000012028 

从这个输出中可以看到数据都是一样的,这里使用 %p 输出一个指针的值(内存地址)都不同。第一个毋庸置疑是初始的内存地址,a1 是调用返回值类型的结果,a2 是返回指针类型的结果。照这样看的话好像返回指针还是值类型没有区别,地址都是新的。

来分析一下,首先 a1 的内存地址发生变化是因为方法参数被拷贝后产生了一份新的值给 a1,此时 a1 分配了新地址。对于 a2 也拷贝了一份新值,只不过这个值是 指针类型,所以在取数据的时候用了 * 进行解引用。

既然都分配了地址,到底使用值类型还是指针类型作为返回值,推荐这么做:

  • 当返回类型不涉及状态变更并且是较简单的数据结构,一律返回值类型
  • 当返回类型可能遇到状态变更或者你关心它的生命周期则使用指针类型
  • 当返回的结构比较大的时候使用指针类型

4.2 函数/方法参数情况

来看下面的例子:

package main

import (
	"fmt"
	"strings"
)

type User struct {
	Name string
}

func nameToUpper(user User) string {
	user.Name = strings.ToUpper(user.Name)
	fmt.Printf("nameToUpper user: %v address: %p \n", user, &user) // 2
	return user.Name
}

func main() {
	user := User{Name: "iankevin"}
	fmt.Printf("user: %v address: %p \n", user, &user) // 1

	nameToUpper(user)
	fmt.Printf("user: %v address: %p \n", user, &user) // 3
}

// user: {iankevin} address: 0xc000014270
// nameToUpper user: {IANKEVIN} address: 0xc000014290
// user: {iankevin} address: 0xc000014270

nameToUpper 接收值类型的参数,观察输出你会发现在外部的 foo 变量内存地址是没有发生变化的。

在方法内部接收这个 值类型变量 的时候,内存地址和外面不同,这意味着 Go 会将这个值类型参数作为一个拷贝传递过去,在方法内部的改变都不会影响到外面的变量。

如果将函数/方法的参数修改为指针类型呢?

package main

import (
	"fmt"
	"strings"
)

type User struct {
	Name string
}

func modifyName(user *User) {
	fmt.Printf("modifyName user: %v address: %p \n", user, &user) // 2
	user.Name = "hello " + user.Name
}

func main() {
	user := &User{Name: "iankevin"}
	fmt.Printf("user: %v address: %p \n", user, &user) // 1

	modifyName(user)
	fmt.Printf("user: %v address: %p \n", user, &user) // 3
}

// user: &{iankevin} address: 0xc000012028 
// modifyName user: &{iankevin} address: 0xc000012038 
// user: &{hello iankevin} address: 0xc000012028 

可以看到,数据被修改了,因为传递的是指针;内存地址没有发生变化,作为入参的 foo 在方法内部的地址也是一份新的拷贝,这一点和前面返回值是相同的(0xc00000c028 和 0xc00000c038 指向同一份数据)。

4.3 其他类型数据

在前面我们有一张图中分了值类型和引用类型,除了那些常用的基本类型,还有像 map 和 slice 这种引用类型,它们在使用上有点像指针(但不用任何操作符如 &、*),来看个例子:

package main

import (
	"fmt"
)

func updateMap(mmp map[string]int) {
	mmp["iankevin"] = 2333
}

func main() {
	mmp := make(map[string]int)
	mmp["iankevin"] = 1024
	fmt.Printf("src mmp: %v address: %p \n", mmp, &mmp) // 1

	updateMap(mmp)
	fmt.Printf("new mmp: %v address: %p \n", mmp, &mmp) // 2
}

/**
src mmp: map[iankevin:1024] address: 0xc000012028 
new mmp: map[iankevin:2333] address: 0xc000012028 
**/

如果你尝试 slice 的话是同样的效果,可以看到给方法传递的并非是一个指针类型,但是 map 的值确实被修改了,这是为什么呢?

其实拷贝一个 map 或者 slice 的时候并没有拷贝这个类型(引用类型)里面指向的数据,而是拷贝了引用类型(可简单理解为指针),如何验证这一说法呢?我们在 updateMap 中添加一行输出代码,再次运行代码:

package main

import (
	"fmt"
)

func updateMap(mmp map[string]int) {
	fmt.Printf("param mmp: %v address: %p \n", mmp, &mmp) // 2
	mmp["iankevin"] = 2333
}

func main() {
	mmp := make(map[string]int)
	mmp["iankevin"] = 1024
	fmt.Printf("src mmp: %v address: %p \n", mmp, &mmp) // 1

	updateMap(mmp)
	fmt.Printf("new mmp: %v address: %p \n", mmp, &mmp) // 3
}

/**
src mmp: map[iankevin:1024] address: 0xc000012028 
param mmp: map[iankevin:1024] address: 0xc000012038 
new mmp: map[iankevin:2333] address: 0xc000012028 
**/

你会发现 input mmp 这行的地址发生了变化,正因为拷贝的是这个特殊的 “引用类型”,会产生一个新的地址,而这个地址 0xc000012038 和 0xc000012028 指向的是同一份数据,所以修改后外部的变量也会得到新的数据。

5、总结

前面我们通过一些代码示例来演示了在 Go 中值类型和指针类型的一些具体表现,最后我们要回答这么几个问题,希望你能够在使用 Go 编程的过程中更加清晰的掌握这些技巧。

Receiver Type 为什么推荐使用指针?

  • 推荐在实例方法上使用指针(前提是这个类型不是一个自定义的 map、slice 等引用类型)
  • 当结构体较大的时候使用指针会更高效
  • 如果要修改结构内部的数据或状态必须使用指针
  • 当结构类型包含 sync.Mutex 或者同步这种字段时,必须使用指针以避免成员拷贝
  • 如果你不知道该不该使用指针,使用指针!

“结构较大” 到底多大才算大可能需要自己或团队衡量,如超过 5 个字段或者根据结构体内占用来计算。

函数/方法参数该使用什么类型?

  • map、slice 等类型不需要使用指针(自带 buf)
  • 指针可以避免内存拷贝,结构大的时候不要使用值类型
  • 值类型和指针类型在方法内部都会产生一份拷贝,指向不同
  • 小数据类型如 bool、int 等没必要使用指针传递
  • 初始化一个新类型时(像 NewEngine() *Engine)使用指针
  • 变量的生命周期越长则使用指针,否则使用值类型

参考

end ~

欢迎关注之前的几篇文章: