下划线字段在 golang 结构体中的应用 - apocelipes
最近公司里的新人问了我一个问题:这段代码是啥意思。这个问题很普通也很常见,我还是个新人的时候也经常问,当然,现在我不是新人了但我也经常发出类似的提问。
代码是长这样的:
新人问我 _ [0]func() 是什么。不得不说这是个好问题,因为这样的代码第一眼看上去谁都会觉得很奇怪,这种叫没有名字只有一个下划线占位符的我们暂且叫做“下划线字段”,下划线字段会占用实际的空间但又不能被访问,使用这样一个字段有什么用呢?
今天我就来讲讲下划线字段在 Golang 中的实际应用,除了能回答上面新人的疑问,还能帮你了解一些开源项目中的 golang 惯用法。
使结构体不能被比较
默认情况下 golang 的结构体是可以进行相等和不等判断的,编译器会自动生成比较每个字段的值的代码。
这和其他语言是很不一样的,在 c 语言里想要比较两个结构体你需要自写比较函数或者借助 memcmp 等标准库接口,在 c++/Java/python 中则需要重载/重写指定的运算符或者方法,而在 go 里除了少数特殊情况之外这些工作都由编译器代劳了。
然而天下没有免费的午餐,让编译器代劳等价于失去对比较操作的控制权。
举个简单的例子,你有一个字段都是指针类型的结构体,这些结构体可以进行等值判断,判断的依据是指针指向的实际内容:
这种结构体在 JSON 序列化和数据库操作中很常见,理想中的判断操作应该是先解引用 Name,比较他们指向的字符串的值,然后再比较 Age 是否相同。
但编译器生成的是先比较 Name 存储的地址值而不是他们指向的字符串的具体内容,然后再比较 Age。这样当你使用==来处理结构体的时候就会得到错误的结果:
函数 getString 模拟了序列化和反序列化时的场景:相同内容的字符串每次都是独立分配的,导致了他们的地址不同。从结果可以看到 golang 默认生成的比较是不正确。
更糟糕的是这个默认生成的行为无法禁止,会导致==的误用。
实际生产中还有另一种情况,编译器觉得结构体符合比较的规则,但逻辑上这种结构体的等值比较没有实际意义。显然放任编译器的默认行为没有任何好处。
这时候新人问的那行代码就发挥用处了,我们把那行代码加进结构体里:
现在程序会报错了:invalid operation: a == b (struct containing [0]func() cannot be compared)。
这就是之前说的少数几种特殊情况:函数、切片、map 是不能比较的,包含这些类型字段的结构体或者数组也不可以进行比较操作。
我们的下划线字段是一个元素为函数的数组。在 Go 中,数组可以进行等值比较,但函数不能,因此 [0]func() 类型的下划线\n 字段将无法参与比较。接着由于 go 语法的规定,只要有一个字段不能进行比较,那么整个结构体也不能,所以==不再能应用在结构体 A 上。
解释到这里新人又有了疑问:如果只是禁止使用==,那么 _ func() 的效果不是一样的吗,为什么还要费事再套一层数组呢?
新人的洞察力真的很敏锐,如果只是禁止自动生成比较操作的代码,直接使用函数类型或者切片和 map 效果是一样的。但是我们忘了一件事:下划线字段虽然无法访问但仍然会占用实际的内存空间,也就是说如果我们用函数、切片,那么结构体就会多占用一个函数/切片的内存。
我们可以算一下,以官方的编译器为准,在 64 位操作系统上指针和 int 都是 8 字节大小,一个函数的大小大概是 8 字节,一个切片目前是 24 字节,原始结构体 A 大小是 16 字节,如果使用 _ func(),则大小变成 24 字节,膨胀 50%,如果我们使用 _ []int,则大小变成 40 字节,膨胀了 150%!另外添加了新的有实际大小的字段,还会影响整个结构体的内存对齐,导致浪费内存或者在有特殊要求的接口中出错。
这时候 _ [0]func() 便派上用场了,go 规定大小为 0 的数组不占用内存空间,但字段依旧实际存在,编译器也会照常进行类型检查。所以我们既不用浪费内存空间和改变内存对齐,又可以禁止编译器生成结构体的比较操作。
至此新人的疑问解答完毕,下划线字段的第一个实际应用也介绍完了。
阻止结构体被拷贝
首先要声明,仅靠下划线字段是不能阻止结构体被拷贝的,我们只能做到让代码在几乎所有代码检查工具和 IDE 里爆出警告信息。
这也是下划线字段的常见应用,在标准库里就有,比如 sync.Once:
其中 noCopy 长这样:
noCopy 实现了 sync.Locker,所有实现了这个接口的类型理论上都不可以被复制,所有的代码检查工具包括自带的 go vet 都会在看到实现了 sync.Locker 的类型被拷贝时发出警告。
而且 noCopy 的底层类型是空结构体,不会占用内存,因此这种用法也不需要我们支付额外的运行时代价。
美中不足的是这只能产生一些警告,对这些结构体进行拷贝的代码还是能正常编译的。
强制指定初始化方式
在 golang 中用字面量初始化结构体有方式:
一个是在初始化时不指定字段的名称,我们叫匿名初始化,在这种方式下所有字段的值都需要给出,且顺序从左到右要和字段定义的顺序一致。
第二个是在初始化时明确给出字段的名字,我们叫它具名初始化。具名初始化时不需要给出所有字段的值,未给出的会用零值进行初始化;字段的顺序也可以和定义时的顺序不同(不过有的 IDE 会给出警告)。其中 a := A{}算是一种特殊的具名初始化——没给出字段名,所有全部的字段都用零值初始化。
如果结构体里字段很多,而这些字段中的大多数又可以使用默认的零值,那么具名初始化是一种安全又方便的做法。
匿名初始化则不仅繁琐,而且因为依赖字段之间的相对顺序,很容易造成错误或者因为增删字段导致代码出错。因此一些项目里禁止了这种初始化。然而 go 并没有在编译器里提供这种禁止机制,所以我们又只能用下划线字段模拟了。
我们可以反向利用匿名初始化需要给出每一个字段的值的特点来阻止匿名初始化。看个例子:
编译代码会得到类似 implicit assignment to unexported field _ in struct literal of type a.A 的报错。
那如果我们偷看了源代码,发现 A 的第一个字段就是一个空结构体,然后把代码改成下面的会怎么样:
答案依然是编译报错:implicit assignment to unexported field _ in struct literal of type a.A。
还记得我们在开头就说过的吗,下划线字段不可访问,这个访问包含“初始化”,不可访问意味着没法给它初始值,这导致了匿名初始化无法进行。所以偷看答案也没有用,我们得老老实实对 A 使用具名初始化。
同样因为是用的空结构体,我们不用付出运行时代价。不过我推荐还是给出一个初始化函数如 NewA 比较好。
防止错误的类型转换
这个应用我在以前的博客 golang 的类型转换中详细介绍过。
简单的说 golang 只要两个类型的底层类型相同,那么就运行两个类型的值之间互相转换。这会给泛型类型带来问题:
最早的 atomic.Pointer 长这样,它可以原子操作各种类型的指针。原子操作只需要地址值并不需要具体的类型,因此用 unsafe.Pointer 是合理的也是最便利的。
但基于 golang 的类型转换规则,atomic.Pointer[byte] 可以和 atomic.Pointer[map[int]string] 互相转换,因为它们除了类型参数不同,底层类型是完全相同的。这当然很荒谬,因为 byte 好 map 别说内存布局完全不一样,它们的实际大小也不同,相互转换不仅没有意义还会造成安全问题。
我们需要让泛型类型的底层类型不同,那么就需要把类型参数加入字段里;而我们又不想这一补救措施产生运行时开销和影响使用。这时候就需要下划线字段救场了:
通过添加 _ [0]*T,我们在字段里使用了类型参数,现在 atomic.Pointer[byte] 会有一个 _ [0]*byte 字段,atomic.Pointer[map[int]string] 会有一个 _ [0]*map[int]string 字段,两者类型完全不同,所以泛型类型之间也不再可以互相转换了。
至于零长度数组,我们前面已经介绍过了,它和空结构体一样不会产生实际的运行开销。
这个应用其实不是很常见,但随着泛型代码越来越常用,我想大多数人早晚有一天会见到类似代码的。
缓存行对齐
我们之前提到,下划线字段不可访问,但仍然实际占用内存空间。所以之前的应用都给下划线字段一些大小为 0 的类型以避免产生开销。
但下面要介绍的这种应用反其道而行之,它需要占用空间的特性来实现缓存行对齐。
想象一下你有两个原子变量,线程 1 会操作变量 A,线程 2 操作变量 B:
现代的 x86 cpu 上一个缓存行有 64 字节(Apple 的一些芯片上甚至是 128 字节),所以一个 Obj 的对象多半会存储在同一个缓存行里。线程 1 和线程 2 看似安全得操作这个两个不同的原子变量,但在运行时看来两个线程会互相修改同一个缓存行里的内容,这是典型的 false sharing,会造成可观的性能损失。
我这里不想对伪共享做过多的解释,现在你只要知道想避免它,就得让 AB 存储在不同的缓存行里。最典型的就是在 AB 之间加上其他数据做填充,这些数据的大小要只是有一个缓存行也就是 64 字节那么大。
我们需要数据填充,但又不想填充的数据被访问到,那肯定只能选择下划线字段了。以 runtime 里的代码为例:
三个字段都用 _ cpu.CacheLinePad 分隔开了。而 cpu.CacheLinePad 的大小是正好一个缓存行,在 arm 上它的定义是:
CacheLinePad 也使用下划线字段,并且用一个 byte 数组占足了长度。
我们可以利用类似的方法来保证字段之间按缓存行对齐。
注意下划线字段的位置
最后一点不是应用场景,而是注意事项。
可以看到,如果我们不想下划线字段占用内存的时候,这个字段通常都是结构体的第一个字段。
这当然有可读性更好的因素在,但还有一个更重要的影响:
是的,字段一样,对齐规则一样,但 B 会多出 8 字节。
这是因为 golang 对结构体的内存布局有规定,结构体里的字段可以有重叠,但这个重叠不能超过这个结构体本身的内存范围。
举个例子:
我们有一个数组存了两个类型 B 的元素,字段 D 的大小理论上为 0,所以如果我们用&array[0].D 取 D 的地址,那么理论上有两种情况:
- D 和 C 共享地址,因为前面说过结构体内部字段之间发生重叠是允许的,但在这里这个方案不行,因为字段之间还有 offset 的规定,字段的 offset 必须大于等于前面所有字段和内存对齐留下的空洞的大小之和(换句话说,也就是当前字段的地址到结构体内存开始地址的距离),如果 C 和 D 共享地址,那么 D 的 offset 就错了,正确的应该是 16(D 前面有 8 字节的 A 和 8 字节的 C)而共享地址后会变成 8。offset 对反射和编译器生成代码有很重要的影响,所以容不得错误。
- 数组的内存是连续的,所以 D 和 array[1] 共享地址,这是不引入填充时的第二个选择,然而这会导致 array[0] 的字段可以访问到 array[1] 的内存,往严重说这是一种内存破坏,只不过恰好我们的字段大小为 0 没法进行有效读写罢了。而且你考虑过 array[1] 的字段 D 的地址上应该放啥了吗,按照目前的想法是没法处理的。 所以 go 选择了一种折中的办法,如果末尾的字段大小为 0,则会在结构体尾部加入一个内存对齐大小的填充,在我们的结构体里这个大小是 8。这样 offset 的计算不会出错,同时也不会访问到不该访问的地址,而 D 的地址就是填充内容起始处的地址。
如果大小为 0 的字段出现在结构体的开头,上面两个问题就都不存在了,编译器自然也不会再插入不必要的填充物。
所以对于大小为 0 的下划线字段,我们一般放在结构体的开头处,以免产生不必要的开销。
总结
上面列举的只是一些最常见的下划线字段的应用,你完全可以因地制宜创造出新的用法。
但别忘了代码可读性是第一位的,不要为了炫技而滥用下划线字段。同时也要小心不要踩到注意事项里说的坑。