朱晓峰

一只生之无趣死之乏味的丧家之犬

0%

空struct引发的一些探索

背景

偶然发现项目代码中有如下的写法:

1
2
3
4
5
6
7
8
type Student struct {
Name string
Age int
}

func NewStudent(s Student) *Student {
return &s
}

我们且不去探讨“这样写法有什么作用”、“为什么要这样写”,我们先就代码本身讨论。

众所周知,从严格意义上来说,golang函数调用中只有值传递的方式传递参数,所以这个函数会返回一个新的指针,并指向一个新的对象。

然后我就在思考,这种函数会被编译器特殊优化么?

探索

写一个简单demo测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

type A struct{} // 空struct

func NewStructA(a A) *A {
return &a
}

func main() {
a := A{}
newA := NewStructA(a)
fmt.Printf("a: %p, newA: %p\n", &a, newA)
}

运行结果很amazing啊

1
2
$ go run main.go
a: 0x5a6d88, newA: 0x5a6d88

两个指针居然完全相等,这不合常理啊,讲道理,值传递的话,return返回出来的应该是一个新对象的指针啊

不过仔细观察一下,这个例子中,A是一个空struct,函数里也不可能修改传进来的A的实例a,所以即使两个是相同的指针也没有任何影响。

那我们就去看看编译器是怎么做的吧。

-gcflags='-S' 列出汇编代码

FUNCDATAPCDATA是编译器产生的,用于保存一些给垃圾收集的信息,在本例中可以过滤掉

1
2
3
4
5
6
7
8
9
10
$ go build -gcflags='-S' main.go 2>&1 | grep -v "FUNCDATA\|PCDATA" | grep -A10 '"".NewStructA STEXT'

"".NewStructA STEXT nosplit size=13 args=0x8 locals=0x0
0x0000 00000 (D:\projects\test\go\struct\1\main.go:7) TEXT "".NewStructA(SB), NOSPLIT|ABIInternal, $0-8
0x0000 00000 (D:\projects\test\go\struct\1\main.go:8) LEAQ runtime.zerobase(SB), AX
0x0007 00007 (D:\projects\test\go\struct\1\main.go:8) MOVQ AX, "".~r1+8(SP)
0x000c 00012 (D:\projects\test\go\struct\1\main.go:8) RET
0x0000 48 8d 05 00 00 00 00 48 89 44 24 08 c3 H......H.D$..
rel 3+4 t=16 runtime.zerobase+0
...

$0-8中0代表的是分配的栈帧大小为0,8代表的是各个实参和返回值的总大小

caller

LEAQ runtime.zerobase(SB), AX

runtime.zerobase(SB)的地址传送到AX寄存器

MOVQ AX, "".~r1+8(SP)

AX寄存器中的数据移动到~r1+8(SP)即返回值的位置

runtime.zerobase是个什么东西?

翻看源码找到runtime/malloc.go826行

1
2
// base address for all 0-byte allocations
var zerobase uintptr

是一个用于所有对内存零占用的结构的地址

所以空struct的地址就是zerobase,所以函数NewStructA才会返回相同的地址。

而且实际上前文所说的$0-8中的8代表的是各个实参和返回值的总大小,这个8也可以理解了,实参其实并没有占用栈空间,是返回值(空struct的指针)占用了栈空间的8个字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
"unsafe"
)

type A struct{}

func main() {
a := A{}
fmt.Println(unsafe.Sizeof(a)) // print 0
fmt.Println(unsafe.Sizeof(&a)) // print 8
}

空struct特性

  1. 以前一直不理解chan struct{}的意图,其实可以理解为struct{}是golang中最小的数据结构

    1
    2
    3
    4
    5
    6
    done := make(chan struct{})
    go func() {
    // ...
    done <- struct{}{}
    }()
    <- done
  2. map[string]struct{}map[string]bool更节约空间

  3. var x [1000000000]struct{}
    fmt.Println(unsafe.Sizeof(x)) // print 0
    

参考资料

附录

助记符 指令种类 用途 示例
MOVQ 传送 数据传送 MOVQ 48, AX // 把 48 传送到 AX
LEAQ 传送 地址传送 LEAQ AX, BX // 把 AX 有效地址传送到 BX
PUSHQ 传送 栈压入 PUSHQ AX // 将 AX 内容送入栈顶位置
POPQ 传送 栈弹出 POPQ AX // 弹出栈顶数据后修改栈顶指针
ADDQ 运算 相加并赋值 ADDQ BX, AX // 等价于 AX+=BX
SUBQ 运算 相减并赋值 SUBQ BX, AX // 等价于 AX-=BX
CMPQ 运算 比较大小 CMPQ SI CX // 比较 SI 和 CX 的大小
CALL 转移 调用函数 CALL runtime.printnl(SB) // 发起调用
JMP 转移 无条件转移指令 JMP 0x0185 //无条件转至 0x0185 地址处
JLS 转移 条件转移指令 JLS 0x0185 //左边小于右边,则跳到 0x0185