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

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

代码是长这样的:

/* by 01022.hk - online tools website : 01022.hk/zh/json2go.html */
type BussinessObject struct {
    _      [0]func()
    ID     uint64
    FieldA string
    FieldB *int64
    ...
}

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

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

使结构体不能被比较

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

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

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

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

/* by 01022.hk - online tools website : 01022.hk/zh/json2go.html */
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的类型转换中详细介绍过。

简单的说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的下划线字段,我们一般放在结构体的开头处,以免产生不必要的开销。

总结

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

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

C语言-光伏MPPT算法:电导增量法扰动观察法+自动全局搜索Plecs最大功率跟踪算法仿真内容概要:本文档主要介绍了一种基于C语言实现的光伏最大功率点跟踪(MPPT)算法,结合电导增量法与扰动观察法,并引入自动全局搜索策略,利用Plecs仿真工具对算法进行建模与仿真验证。文档重点阐述了两种经典MPPT算法的原理、优缺点及其在不同光照和温度条件下的动态响应特性,同时提出一种改进的复合控制策略以提升系统在复杂环境下的跟踪精度与稳定性。通过仿真结果对比分析,验证了所提方法在快速性和准确性方面的优势,适用于光伏发电系统的高效能量转换控制。; 适合人群:具备一定C语言编程基础和电力电子知识背景,从事光伏系统开发、嵌入式控制或新能源技术研发的工程师及高校研究人员;工作年限1-3年的初级至中级研发人员尤为适合。; 使用场景及目标:①掌握电导增量法与扰动观察法在实际光伏系统中的实现机制与切换逻辑;②学习如何在Plecs中搭建MPPT控制系统仿真模型;③实现自动全局搜索以避免传统算法陷入局部峰值问题,提升复杂工况下的最大功率追踪效率;④为光伏逆变器或太阳能充电控制器的算法开发提供技术参考与实现范例。; 阅读建议:建议读者结合文中提供的C语言算法逻辑与Plecs仿真模型同步学习,重点关注算法判断条件、步长调节策略及仿真参数设置。在理解基本原理的基础上,可通过修改光照强度、温度变化曲线等外部扰动因素,进一步测试算法鲁棒性,并尝试将其移植到实际嵌入式平台进行实验验证。
【无人机协同】动态环境下多无人机系统的协同路径规划与防撞研究(Matlab代码实现)​ 内容概要:本文围绕动态环境下多无人机系统的协同路径规划与防撞问题展开研究,提出基于Matlab的仿真代码实现方案。研究重点在于在复杂、动态环境中实现多无人机之间的高效协同飞行与避障,涵盖路径规划算法的设计与优化,确保无人机集群在执行任务过程中能够实时规避静态障碍物与动态冲突,保障飞行安全性与任务效率。文中结合智能优化算法,构建合理的成本目标函数(如路径长度、飞行高度、威胁规避、转弯角度等),并通过Matlab平台进行算法验证与仿真分析,展示多机协同的可行性与有效性。; 适合人群:具备一定Matlab编程基础,从事无人机控制、路径规划、智能优化算法研究的科研人员及研究生。; 使用场景及目标:①应用于灾害救援、军事侦察、区域巡检等多无人机协同任务场景;②目标是掌握多无人机系统在动态环境下的路径规划与防撞机制,提升协同作业能力与自主决策水平;③通过Matlab仿真深入理解协同算法的实现逻辑与参数调优方法。; 阅读建议:建议结合文中提供的Matlab代码进行实践操作,重点关注目标函数设计、避障策略实现与多机协同逻辑,配合仿真结果分析算法性能,进一步可尝试引入新型智能算法进行优化改进。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值