Go 结构体方法值传递与引用传递区别

Go 结构体方法中,有一个很重要的点就是值传递和引用传递,我们通过一个例子来看下什么是值传递、什么是引用传递,二者有什么区别。

我们声明 person 结构体,里边有一个 name 字段,用两种方式实现 SetName 方法,分别是值传递和引用传递。

1
2
3
4
5
6
7
8
9
10
11
type person struct {
name string
}

func (p person) SetName1(name string) {
p.name = name
}

func (p *person) SetName2(name string) {
p.name = name
}

如上,SetName1 就是值传递,SetName2 为引用传递。

main 方法中,我们分别调用两个 SetName 方法对 name 进行赋值,并打印每次赋值后的 name 值。

1
2
3
4
5
6
7
8
9
func main() {
p := &person{}

p.SetName1("张三")
fmt.Println(p.name)

p.SetName2("李四")
fmt.Println(p.name)
}

执行这个程序得到如下结果:

1
2

李四

可以看到 张三 并没有打印出来,而 李四 打印了出来。

在调用 SetName1 时,实际上是复制了一个新的 person,方法内操作的也是那个新 person 把复制 personname 改为了张三,而我们在 main 方法中打印的确是原始 personname 字段。因为我们在初始化 person 时并没有指定 name 的值,所以第一次打印出来的是个空串。

对程序稍作调整,先调用 SetName2 再调用 SetName1

1
2
3
4
5
6
7
8
9
func main() {
p := &person{}

p.SetName2("李四")
fmt.Println(p.name)

p.SetName1("张三")
fmt.Println(p.name)
}

这时输出的结果为:

1
2
李四
李四

第一次调用后,p 结构体指针中 name 的值已经被改为了 李四,接下来我们调用 SetName1 时,因为是复制了一个新的 person,并没有影响之前的 person,所以打印结果还是 李四

**我们日常开发中,编写结构体方法时大部分情况都是用引用传递。

值传递的问题是,如果我们结构体的成员数量非常多时,每次调用方法都会进行一次拷贝,会有额外的内存开销。

验证一下值传递有没有分配新的内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type person struct{
name string
}

func (p person) SetName1(name string) {
fmt.Printf("SetName1: %p\n", &p)
p.name = name
}

func (p *person) SetName2(name string) {
fmt.Printf("SetName2: %p\n", p)
p.name = name
}

func main() {
p := &person{}
fmt.Printf("Origin: %p\n", p)

p.SetName1("张三")
p.SetName2("李四")
}

运行结果:

1
2
3
Origin: 0xc000010200
SetName1: 0xc000010210
SetName2: 0xc000010200

可以看到,原始的 person 地址为 0xc000010200,在通过值传递时位置发生了改变,变为了0xc000010210,这也就意味着系统为这个新的 person 分配了新的内存地址,而用引用传递的方式地址是不会变的。

引用传递容易犯的错误

我们假设要实现一个发送邮件的功能,定义一个 email 结构体,里边有两个成员 fromto,实现两个方法用来更新这两个成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
type email struct {
from string
to string
}

func (e *email) SetFrom(from string) {
e.from = from
}

func (e *email) SetTo(to string) {
e.to=to
}

再实现一个发送邮件的方法,这里简单将 fromto 打印出来即可:

1
2
3
func (e *email) Send() {
fmt.Printf("from: %s, to: %s\n", e.from, e.to)
}

main 方法中,我们写一个循环,实现发送 10 次邮件,0 发送给 1,1 发送个 2,一次以此类推:

1
2
3
4
5
6
7
8
9
func main() {
e: = &email{}

for i:=0; i<10; i++ {
e.SetFrom(fmt.Sprintf("%d", i))
e.SetTo(fmt.Sprintf("%d", i+1))
e.Send()
}
}

输出如下:

1
2
3
4
5
6
7
8
9
10
from: 0, to: 1
from: 1, to: 2
from: 2, to: 3
from: 3, to: 4
from: 4, to: 5
from: 5, to: 6
from: 6, to: 7
from: 7, to: 8
from: 8, to: 9
from: 9, to: 10

这时候,如果我们改成并发发送这些邮件,同时发给10个人,很容易就会把上边的代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
e := &email{}

for i:=0; i<10; i++ {
go func(i int) {
e.SetFrom(fmt.Sprintf("%d", i))
e.SetTo(fmt.Sprintf("%d", i+1))
e.Send()
}(i)
}

time.Sleep(1 * time.Second)
}

再次运行,结果如下:

1
2
3
4
5
6
7
8
9
10
from: 2, to: 3
from: 1, to: 2
from: 3, to: 4
from: 4, to: 5
from: 5, to: 6
from: 6, to: 7
from: 0, to: 1
from: 7, to: 8
from: 9, to: 1
from: 8, to: 9

没有按照递增的顺序发送是在我们意料之中的,但是我们可以看到其中有一行输出为:from: 9, to: 1,这个并不是我们想要的结果。

出现这个问题的原因是在每个 go routine 中都是对原始 email 进行的修改,再并发操作的过程中,fromto 有可能被其他的 go routine 改掉,这是个非常严重的 bug。

两种修改方法

每一次都初始化一个新的 email 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
for i:=0; i<10; i++ {
e := &email{}
go func(i int) {
e.SetFrom(fmt.Sprintf("%d", i))
e.SetTo(fmt.Sprintf("%d", i+1))
e.Send()
}(i)
}

time.Sleep(1 * time.Second)
}

改用值传递,并返回修改后的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import(
"fmt"
"time"
)

type email struct {
from string
to string
}

func (e email) SetFrom(from string) email {
e.from = from
return e
}

func (e email) SetTo(to string) email {
e.to = to
return e
}

func (e email) Send() {
fmt.Printf("from: %s, to: %s\n", e.from, e.to)
}

func main() {
e := &email{}

for i:=0; i<10; i++ {
go func(i int) {
e.SetFrom(fmt.Sprintf("%d", i)).
SetTo(fmt.Sprintf("%d", i+1)).
Send()
}(i)
}

time.Sleep(1 * time.Second)
}

读者可以自己想一想,为什么这种写法可以解决并发赋值出现的问题。