结构体的值传递

前天在修改服务器端 Go 代码的时候因为一个很基本的问题纠结了好一会儿,今天还和同事认真地探讨了一番,结果是因为太长时间都在玩儿 JavaScript,连基本的值传递与引用传递都搞混了…

业务逻辑中有一个结构体Item,它有一个Options的字段,其具体结构依赖于ItemType字段,所以Item的定义如下:

1
2
3
4
5
type Item struct {
Id string
Type string
Options interface{}
}

某个特定的Options结构可能是:

1
2
3
4
type QuestionOptions struct {
Id string
Label string
}

为了可以对item.Options进行具体的操作,需要先强制类型转化

1
2
options, _ := item.Options.(QuestionOptions)
options.Label = "Hello"

简单到不能再简单的操作,一眼看过去妥妥的,但是结果完全不是想要的,实际情况是item.OptionsLabel属性并没有发生任何变化,那行对Label的赋值语句完全没有起作用。

第一时间想到的是难道强制类型转化的同时还会进行数据拷贝?立马再补上一句赋值:

1
2
3
options, _ := item.Options.(QuestionOptions)
options.Label = "Hello"
item.Options = options

好了,搞了定… 但是完全不能接受呀,思来想去,强制类型转化的同时还进行数据拷贝完全没道理呀,不仅浪费内存还违背常理,难道这是 Go 的“feature”?

今天一大早和同事认真讨论了一番,从变量可能的实际存储结构到内存布局,最后还拿到Go编译的汇编代码仔细瞅了瞅才恍然大悟,根本就是把值传递的事儿给抛之脑后了…

在 c/c++ 及 Go 这些语言中结构体之间的赋值都是值传递,而且普通情况下结构体都是在栈上创建的,也就是说每一次结构体变量之间的赋值都会在栈上进行数据拷贝:

1
2
3
opt1 := QuestionOptions{Id: "1", Label: "1"}
opt2 := opt1
opt2.Id = "2"

结果是opt1.Id != opt2.Id,更进一步&opt2 != &opt1

而 JavaScript 中并没有结构体,相近的只有对象字面量(Object literals){},由于对象始终都是创建在堆上,变量存储的只是堆地址,所以在变量赋值时拷贝的仅仅是地址

1
2
3
const opt1 = { id: '1', label: '1' };
const opt2 = opt1;
opt2.id = '2';

所以opt1.id === opt2.id并且opt1 === opt2,但是如果 JavaScript 支持取地址操作的话&opt1 !== &opt2

到此都还很清晰,这些基本知识也还没有还给老师,不过加上一个强制类型转化就昏头了…

首先强制类型转换item.Options.(QuestionOptions)返回了一个结构体变量,但并没有进行数据拷贝,其数据还是指向同一个内存地址。然后options, _ :=进行了一次结构体变量之间的赋值,这才是真正触发拷贝的地方!

后记

后来又仔细看了下 Go 汇编,想找到具体的赋值语句,测试代码很简单:

1
2
3
var p interface{} = Option{Label: "1"}
opt := p.(Option)
opt.Label = "2"

对应的 Go 汇编代码是

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
0x0000 00000 (convert_type.go:11)	TEXT	"".main(SB), $104-0
0x0000 00000 (convert_type.go:11) MOVQ (TLS), CX
0x0009 00009 (convert_type.go:11) CMPQ SP, 16(CX)
0x000d 00013 (convert_type.go:11) JLS 265
0x0013 00019 (convert_type.go:11) SUBQ $104, SP
0x0017 00023 (convert_type.go:11) MOVQ BP, 96(SP)
0x001c 00028 (convert_type.go:11) LEAQ 96(SP), BP
0x0021 00033 (convert_type.go:12) MOVQ $0, ""..autotmp_2+88(SP)
0x002a 00042 (convert_type.go:12) LEAQ go.string."1"(SB), AX
0x0031 00049 (convert_type.go:12) MOVQ AX, ""..autotmp_2+80(SP)
0x0036 00054 (convert_type.go:12) MOVQ $1, ""..autotmp_2+88(SP)
0x003f 00063 (convert_type.go:12) LEAQ type."".Option(SB), AX
0x0046 00070 (convert_type.go:12) MOVQ AX, (SP)
0x004a 00074 (convert_type.go:12) LEAQ ""..autotmp_2+80(SP), CX
0x004f 00079 (convert_type.go:12) MOVQ CX, 8(SP)
0x0054 00084 (convert_type.go:12) CALL runtime.convT2E(SB)
0x0059 00089 (convert_type.go:12) MOVQ 24(SP), AX
0x005e 00094 (convert_type.go:12) MOVQ 16(SP), CX
0x0063 00099 (convert_type.go:13) LEAQ type."".Option(SB), DX
0x006a 00106 (convert_type.go:13) CMPQ CX, DX
0x006d 00109 (convert_type.go:13) JNE 237
0x006f 00111 (convert_type.go:13) MOVQ 8(AX), AX
0x0073 00115 (convert_type.go:13) MOVQ AX, "".opt+56(SP)
0x0078 00120 (convert_type.go:14) LEAQ go.string."2"(SB), AX
0x007f 00127 (convert_type.go:14) MOVQ AX, "".opt+48(SP)
0x0084 00132 (convert_type.go:14) MOVQ $1, "".opt+56(SP)

其中第二行(实际文件中的第13行)强制类型转换对应的汇编代码是

1
2
3
4
5
0x0063 00099 (convert_type.go:13)	LEAQ	type."".Option(SB), DX
0x006a 00106 (convert_type.go:13) CMPQ CX, DX
0x006d 00109 (convert_type.go:13) JNE 237
0x006f 00111 (convert_type.go:13) MOVQ 8(AX), AX
0x0073 00115 (convert_type.go:13) MOVQ AX, "".opt+56(SP)

这里的AX存放的是第一行初始化的结构体的地址,根据上下文可以看出8(AX)并不指指向Label这个字段(AX才是),所以这一行实际并没有拷贝Label字段的值… 本来都满心感觉搞定了的问题又找不到北了…

不过故事总是有结果的,实际情况是在这段代码中,期待的结构体拷贝代码是没有必要的,因为在完成结构体强制类型转换后立马对其进行了Label的重新赋值,所以编译器优化了这个过程,在类型转换那行只创建了一个新的结构体,省略了重复而且不必要的结构体内容拷贝。可以通过关掉编译优化的选项,从原始的汇编代码中找到对应的拷贝操作,或者去掉那句Label的赋值语句,然后就能看到:

1
2
3
4
5
6
7
0x0063 00099 (convert_type.go:13)	LEAQ	type."".Option(SB), DX
0x006a 00106 (convert_type.go:13) CMPQ CX, DX
0x006d 00109 (convert_type.go:13) JNE 224
0x006f 00111 (convert_type.go:13) MOVQ (AX), CX
0x0072 00114 (convert_type.go:13) MOVQ 8(AX), AX
0x0076 00118 (convert_type.go:13) MOVQ CX, "".opt+48(SP)
0x007b 00123 (convert_type.go:13) MOVQ AX, "".opt+56(SP)

Label字段的值通过CX复制到新结构体"".opt+48(SP)起始位置!

再后记…

通过上面的汇编代码还能看出interface{}这个能指向万物的“空”指针在内部其实是存贮着实际数据的具体类型的,而强制类型转换实际只是简单的比较期望的类型是否与具体类型相符。