下划线字段在golang结构体中的应用

最近公司里的新人问了我一个问题:这段代码是啥意思。这个问题很普通也很常见,我还是个新人的时候也经常问,当然,现在我不是新人了但我也经常发出类似的提问。

代码是长这样的:

复制代码
type BussinessObject struct {
    _      [0]func()
    ID     uint64
    FieldA string
    FieldB *int64
    ...
}

新人问我_ [0]func()是什么。不得不说这是个好问题,因为这样的代码第一眼看上去谁都会觉得很奇怪,这种叫没有名字只有一个下划线占位符的我们暂且叫做"下划线字段",下划线字段会占用实际的空间但又不能被访问,使用这样一个字段有什么用呢?

今天我就来讲讲下划线字段在Golang中的实际应用,除了能回答上面新人的疑问,还能帮你了解一些开源项目中的golang惯用法。

使结构体不能被比较

默认情况下golang的结构体是可以进行相等和不等判断的,编译器会自动生成比较每个字段的值的代码。

这和其他语言是很不一样的,在c语言里想要比较两个结构体你需要自写比较函数或者借助memcmp等标准库接口,在c++/Java/python中则需要重载/重写指定的运算符或者方法,而在go里除了少数特殊情况之外这些工作都由编译器代劳了。

然而天下没有免费的午餐,让编译器代劳等价于失去对比较操作的控制权。

举个简单的例子,你有一个字段都是指针类型的结构体,这些结构体可以进行等值判断,判断的依据是指针指向的实际内容:

复制代码
type A struct {
    Name *string
    Age  int
}

这种结构体在JSON序列化和数据库操作中很常见,理想中的判断操作应该是先解引用Name,比较他们指向的字符串的值,然后再比较Age是否相同。

但编译器生成的是先比较Name存储的地址值而不是他们指向的字符串的具体内容,然后再比较Age。这样当你使用==来处理结构体的时候就会得到错误的结果:

复制代码
func (a *A) Equal(b *A) bool {
    if b == nil || a.Name == nil || b.Name == nil {
        return false
    }
    return *a.Name == *b.Name && a.Age == b.Age
}
 
//go:noinline
func getString(s string) *string {
    buff := strings.Builder{}
    buff.WriteString(s)
    result := buff.String()
    return &result
}
 
func main() {
    a := A{getString("test"), 100}
    b := A{getString("test"), 100}
    fmt.Println(a == b, (*A).Equal(&a, &b)) // false, true
}

函数getString模拟了序列化和反序列化时的场景:相同内容的字符串每次都是独立分配的,导致了他们的地址不同。从结果可以看到golang默认生成的比较是不正确。

更糟糕的是这个默认生成的行为无法禁止,会导致==的误用。

实际生产中还有另一种情况,编译器觉得结构体符合比较的规则,但逻辑上这种结构体的等值比较没有实际意义。显然放任编译器的默认行为没有任何好处。

这时候新人问的那行代码就发挥用处了,我们把那行代码加进结构体里:

复制代码
type A struct {
    _    [0]func()
    Name *string
    Age  int
}

现在程序会报错了:invalid operation: a == b (struct containing [0]func() cannot be compared)

这就是之前说的少数几种特殊情况:函数、切片、map是不能比较的,包含这些类型字段的结构体或者数组也不可以进行比较操作。

我们的下划线字段是一个元素为函数的数组。在Go中,数组可以进行等值比较,但函数不能,因此[0]func()类型的下划线字段将无法参与比较。接着由于go语法的规定,只要有一个字段不能进行比较,那么整个结构体也不能,所以==不再能应用在结构体A上。

解释到这里新人又有了疑问:如果只是禁止使用==,那么_ func()的效果不是一样的吗,为什么还要费事再套一层数组呢?

新人的洞察力真的很敏锐,如果只是禁止自动生成比较操作的代码,直接使用函数类型或者切片和map效果是一样的。但是我们忘了一件事:下划线字段虽然无法访问但仍然会占用实际的内存空间,也就是说如果我们用函数、切片,那么结构体就会多占用一个函数/切片的内存。

我们可以算一下,以官方的编译器为准,在64位操作系统上指针和int都是8字节大小,一个函数的大小大概是8字节,一个切片目前是24字节,原始结构体A大小是16字节,如果使用_ func(),则大小变成24字节,膨胀50%,如果我们使用_ []int,则大小变成40字节,膨胀了150%!另外添加了新的有实际大小的字段,还会影响整个结构体的内存对齐,导致浪费内存或者在有特殊要求的接口中出错。

这时候_ [0]func()便派上用场了,go规定大小为0的数组不占用内存空间,但字段依旧实际存在,编译器也会照常进行类型检查。所以我们既不用浪费内存空间和改变内存对齐,又可以禁止编译器生成结构体的比较操作。

至此新人的疑问解答完毕,下划线字段的第一个实际应用也介绍完了。

阻止结构体被拷贝

首先要声明,仅靠下划线字段是不能阻止结构体被拷贝的,我们只能做到让代码在几乎所有代码检查工具和IDE里爆出警告信息。

这也是下划线字段的常见应用,在标准库里就有,比如sync.Once

复制代码
// A Once must not be copied after first use.
//
// In the terminology of [the Go memory model],
// the return from f "synchronizes before"
// the return from any call of once.Do(f).
//
// [the Go memory model]: https://go.dev/ref/mem
type Once struct {
	_ noCopy
 
	// done indicates whether the action has been performed.
	// It is first in the struct because it is used in the hot path.
	// The hot path is inlined at every call site.
	// Placing done first allows more compact instructions on some architectures (amd64/386),
	// and fewer instructions (to calculate offset) on other architectures.
	done atomic.Bool
	m    Mutex
}

其中noCopy长这样:

复制代码
// noCopy may be added to structs which must not be copied
// after the first use.
//
// See https://golang.org/issues/8005#issuecomment-190753527
// for details.
//
// Note that it must not be embedded, due to the Lock and Unlock methods.
type noCopy struct{}
 
// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

noCopy实现了sync.Locker,所有实现了这个接口的类型理论上都不可以被复制,所有的代码检查工具包括自带的go vet都会在看到实现了sync.Locker的类型被拷贝时发出警告。

而且noCopy的底层类型是空结构体,不会占用内存,因此这种用法也不需要我们支付额外的运行时代价。

美中不足的是这只能产生一些警告,对这些结构体进行拷贝的代码还是能正常编译的。

强制指定初始化方式

在golang中用字面量初始化结构体有方式:

复制代码
type A struct {
    B int64
    C uint64
    D string
}
 
a := A{1, 2, "3"}
b := A{
    B: 1,
    C: 2,
    D: "3",
}

一个是在初始化时不指定字段的名称,我们叫匿名初始化,在这种方式下所有字段的值都需要给出,且顺序从左到右要和字段定义的顺序一致。

第二个是在初始化时明确给出字段的名字,我们叫它具名初始化。具名初始化时不需要给出所有字段的值,未给出的会用零值进行初始化;字段的顺序也可以和定义时的顺序不同(不过有的IDE会给出警告)。其中a := A{}算是一种特殊的具名初始化------没给出字段名,所有全部的字段都用零值初始化。

如果结构体里字段很多,而这些字段中的大多数又可以使用默认的零值,那么具名初始化是一种安全又方便的做法。

匿名初始化则不仅繁琐,而且因为依赖字段之间的相对顺序,很容易造成错误或者因为增删字段导致代码出错。因此一些项目里禁止了这种初始化。然而go并没有在编译器里提供这种禁止机制,所以我们又只能用下划线字段模拟了。

我们可以反向利用匿名初始化需要给出每一个字段的值的特点来阻止匿名初始化。看个例子:

复制代码
// package a
package a
 
type A struct {
    _ struct{}
    B int64
    C uint64
    D string
}
 
// package main
func main() {
    obj := a.A{1, 2, "3"} // 编译报错
    fmt.Println(obj)
}

编译代码会得到类似implicit assignment to unexported field _ in struct literal of type a.A的报错。

那如果我们偷看了源代码,发现A的第一个字段就是一个空结构体,然后把代码改成下面的会怎么样:

复制代码
func main() {
-   obj := a.A{1, 2, "3"} // 编译报错
+   obj := a.A{struct{}{}, 1, 2, "3"} // ?
    fmt.Println(obj)
}

答案依然是编译报错:implicit assignment to unexported field _ in struct literal of type a.A

还记得我们在开头就说过的吗,下划线字段不可访问,这个访问包含"初始化",不可访问意味着没法给它初始值,这导致了匿名初始化无法进行。所以偷看答案也没有用,我们得老老实实对A使用具名初始化。

同样因为是用的空结构体,我们不用付出运行时代价。不过我推荐还是给出一个初始化函数如NewA比较好。

防止错误的类型转换

简单的说golang只要两个类型的底层类型相同,那么就运行两个类型的值之间互相转换。这会给泛型类型带来问题:

复制代码
// A Pointer is an atomic pointer of type *T. The zero value is a nil *T.
type Pointer[T any] struct {
    _ noCopy
    v unsafe.Pointer
}

最早的atomic.Pointer长这样,它可以原子操作各种类型的指针。原子操作只需要地址值并不需要具体的类型,因此用unsafe.Pointer是合理的也是最便利的。

但基于golang的类型转换规则,atomic.Pointer[byte]可以和atomic.Pointer[map[int]string]互相转换,因为它们除了类型参数不同,底层类型是完全相同的。这当然很荒谬,因为byte好map别说内存布局完全不一样,它们的实际大小也不同,相互转换不仅没有意义还会造成安全问题。

我们需要让泛型类型的底层类型不同,那么就需要把类型参数加入字段里;而我们又不想这一补救措施产生运行时开销和影响使用。这时候就需要下划线字段救场了:

复制代码
// A Pointer is an atomic pointer of type *T. The zero value is a nil *T.
type Pointer[T any] struct {
+   // Mention *T in a field to disallow conversion between Pointer types.
+   // See go.dev/issue/56603 for more details.
+   // Use *T, not T, to avoid spurious recursive type definition errors.
+   _ [0]*T
 
    _ noCopy
    v unsafe.Pointer
}

通过添加_ [0]*T,我们在字段里使用了类型参数,现在atomic.Pointer[byte]会有一个_ [0]*byte字段,atomic.Pointer[map[int]string]会有一个_ [0]*map[int]string字段,两者类型完全不同,所以泛型类型之间也不再可以互相转换了。

至于零长度数组,我们前面已经介绍过了,它和空结构体一样不会产生实际的运行开销。

这个应用其实不是很常见,但随着泛型代码越来越常用,我想大多数人早晚有一天会见到类似代码的。

缓存行对齐

我们之前提到,下划线字段不可访问,但仍然实际占用内存空间。所以之前的应用都给下划线字段一些大小为0的类型以避免产生开销。

但下面要介绍的这种应用反其道而行之,它需要占用空间的特性来实现缓存行对齐。

想象一下你有两个原子变量,线程1会操作变量A,线程2操作变量B:

复制代码
type Obj struct {
    A atomic.Int64
    B atomic.Int64
}

现代的x86 cpu上一个缓存行有64字节(Apple的一些芯片上甚至是128字节),所以一个Obj的对象多半会存储在同一个缓存行里。线程1和线程2看似安全得操作这个两个不同的原子变量,但在运行时看来两个线程会互相修改同一个缓存行里的内容,这是典型的false sharing,会造成可观的性能损失。

我这里不想对伪共享做过多的解释,现在你只要知道想避免它,就得让AB存储在不同的缓存行里。最典型的就是在AB之间加上其他数据做填充,这些数据的大小要只是有一个缓存行也就是64字节那么大。

我们需要数据填充,但又不想填充的数据被访问到,那肯定只能选择下划线字段了。以runtime里的代码为例:

复制代码
type traceMap struct {
    root atomic.UnsafePointer // *traceMapNode (can't use generics because it's notinheap)
    _    cpu.CacheLinePad
    seq  atomic.Uint64
    _    cpu.CacheLinePad
    mem  traceRegionAlloc
}

三个字段都用_ cpu.CacheLinePad分隔开了。而cpu.CacheLinePad的大小是正好一个缓存行,在arm上它的定义是:

复制代码
type CacheLinePad struct{ _ [CacheLinePadSize]byte }
 
// mac arm64
const CacheLinePadSize = 128

CacheLinePad也使用下划线字段,并且用一个byte数组占足了长度。

我们可以利用类似的方法来保证字段之间按缓存行对齐。

注意下划线字段的位置

最后一点不是应用场景,而是注意事项。

可以看到,如果我们不想下划线字段占用内存的时候,这个字段通常都是结构体的第一个字段。

这当然有可读性更好的因素在,但还有一个更重要的影响:

复制代码
type A struct {
    _    [0]func()
    Name *string
    Age  int
}
 
type B struct {
    Name *string
    Age  int
    _    [0]func()
}
 
func main() {
    fmt.Println(unsafe.Sizeof(A{})) // 16字节
    fmt.Println(unsafe.Sizeof(B{})) // 24字节
}

是的,字段一样,对齐规则一样,但B会多出8字节。

这是因为golang对结构体的内存布局有规定,结构体里的字段可以有重叠,但这个重叠不能超过这个结构体本身的内存范围。

举个例子:

复制代码
type B struct {
    A *string
    C int
    D struct{}
}
 
array := [2]B{}

我们有一个数组存了两个类型B的元素,字段D的大小理论上为0,所以如果我们用&array[0].D取D的地址,那么理论上有两种情况:

  1. D和C共享地址,因为前面说过结构体内部字段之间发生重叠是允许的,但在这里这个方案不行,因为字段之间还有offset的规定,字段的offset必须大于等于前面所有字段和内存对齐留下的空洞的大小之和(换句话说,也就是当前字段的地址到结构体内存开始地址的距离),如果C和D共享地址,那么D的offset就错了,正确的应该是16(D前面有8字节的A和8字节的C)而共享地址后会变成8。offset对反射和编译器生成代码有很重要的影响,所以容不得错误。

  2. 数组的内存是连续的,所以D和array[1]共享地址,这是不引入填充时的第二个选择,然而这会导致array[0]的字段可以访问到array[1]的内存,往严重说这是一种内存破坏,只不过恰好我们的字段大小为0没法进行有效读写罢了。而且你考虑过array[1]的字段D的地址上应该放啥了吗,按照目前的想法是没法处理的。

所以go选择了一种折中的办法,如果末尾的字段大小为0,则会在结构体尾部加入一个内存对齐大小的填充,在我们的结构体里这个大小是8。这样offset的计算不会出错,同时也不会访问到不该访问的地址,而D的地址就是填充内容起始处的地址。

如果大小为0的字段出现在结构体的开头,上面两个问题就都不存在了,编译器自然也不会再插入不必要的填充物。

所以对于大小为0的下划线字段,我们一般放在结构体的开头处,以免产生不必要的开销。

总结

上面列举的只是一些最常见的下划线字段的应用,你完全可以因地制宜创造出新的用法。

但别忘了代码可读性是第一位的,不要为了炫技而滥用下划线字段。同时也要小心不要踩到注意事项里说的坑。

文章转载自: ++apocelipes++

原文链接: 下划线字段在golang结构体中的应用 - apocelipes - 博客园

体验地址: JNPF快速开发平台

相关推荐
梦想很大很大15 小时前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰20 小时前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘1 天前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤1 天前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt112 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto5 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室6 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题6 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉7 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想