一文詳解下劃線字段在golang結(jié)構(gòu)體中的應(yīng)用
最近公司里的新人問了我一個(gè)問題:這段代碼是啥意思。這個(gè)問題很普通也很常見,我還是個(gè)新人的時(shí)候也經(jīng)常問,當(dāng)然,現(xiàn)在我不是新人了但我也經(jīng)常發(fā)出類似的提問。
代碼是長這樣的:
type BussinessObject struct {
_ [0]func()
ID uint64
FieldA string
FieldB *int64
...
}
新人問我_ [0]func()是什么。不得不說這是個(gè)好問題,因?yàn)檫@樣的代碼第一眼看上去誰都會(huì)覺得很奇怪,這種叫沒有名字只有一個(gè)下劃線占位符的我們暫且叫做“下劃線字段”,下劃線字段會(huì)占用實(shí)際的空間但又不能被訪問,使用這樣一個(gè)字段有什么用呢?
今天我就來講講下劃線字段在Golang中的實(shí)際應(yīng)用,除了能回答上面新人的疑問,還能幫你了解一些開源項(xiàng)目中的golang慣用法。
使結(jié)構(gòu)體不能被比較
默認(rèn)情況下golang的結(jié)構(gòu)體是可以進(jìn)行相等和不等判斷的,編譯器會(huì)自動(dòng)生成比較每個(gè)字段的值的代碼。
這和其他語言是很不一樣的,在c語言里想要比較兩個(gè)結(jié)構(gòu)體你需要自寫比較函數(shù)或者借助memcmp等標(biāo)準(zhǔn)庫接口,在c++/Java/python中則需要重載/重寫指定的運(yùn)算符或者方法,而在go里除了少數(shù)特殊情況之外這些工作都由編譯器代勞了。
然而天下沒有免費(fèi)的午餐,讓編譯器代勞等價(jià)于失去對比較操作的控制權(quán)。
舉個(gè)簡單的例子,你有一個(gè)字段都是指針類型的結(jié)構(gòu)體,這些結(jié)構(gòu)體可以進(jìn)行等值判斷,判斷的依據(jù)是指針指向的實(shí)際內(nèi)容:
type A struct {
Name *string
Age int
}
這種結(jié)構(gòu)體在JSON序列化和數(shù)據(jù)庫操作中很常見,理想中的判斷操作應(yīng)該是先解引用Name,比較他們指向的字符串的值,然后再比較Age是否相同。
但編譯器生成的是先比較Name存儲(chǔ)的地址值而不是他們指向的字符串的具體內(nèi)容,然后再比較Age。這樣當(dāng)你使用==來處理結(jié)構(gòu)體的時(shí)候就會(huì)得到錯(cuò)誤的結(jié)果:
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
}
函數(shù)getString模擬了序列化和反序列化時(shí)的場景:相同內(nèi)容的字符串每次都是獨(dú)立分配的,導(dǎo)致了他們的地址不同。從結(jié)果可以看到golang默認(rèn)生成的比較是不正確。
更糟糕的是這個(gè)默認(rèn)生成的行為無法禁止,會(huì)導(dǎo)致==的誤用。
實(shí)際生產(chǎn)中還有另一種情況,編譯器覺得結(jié)構(gòu)體符合比較的規(guī)則,但邏輯上這種結(jié)構(gòu)體的等值比較沒有實(shí)際意義。顯然放任編譯器的默認(rèn)行為沒有任何好處。
這時(shí)候新人問的那行代碼就發(fā)揮用處了,我們把那行代碼加進(jìn)結(jié)構(gòu)體里:
type A struct {
_ [0]func()
Name *string
Age int
}
現(xiàn)在程序會(huì)報(bào)錯(cuò)了:invalid operation: a == b (struct containing [0]func() cannot be compared)。
這就是之前說的少數(shù)幾種特殊情況:函數(shù)、切片、map是不能比較的,包含這些類型字段的結(jié)構(gòu)體或者數(shù)組也不可以進(jìn)行比較操作。
我們的下劃線字段是一個(gè)元素為函數(shù)的數(shù)組。在Go中,數(shù)組可以進(jìn)行等值比較,但函數(shù)不能,因此[0]func()類型的下劃線
字段將無法參與比較。接著由于go語法的規(guī)定,只要有一個(gè)字段不能進(jìn)行比較,那么整個(gè)結(jié)構(gòu)體也不能,所以==不再能應(yīng)用在結(jié)構(gòu)體A上。
解釋到這里新人又有了疑問:如果只是禁止使用==,那么_ func()的效果不是一樣的嗎,為什么還要費(fèi)事再套一層數(shù)組呢?
新人的洞察力真的很敏銳,如果只是禁止自動(dòng)生成比較操作的代碼,直接使用函數(shù)類型或者切片和map效果是一樣的。但是我們忘了一件事:下劃線字段雖然無法訪問但仍然會(huì)占用實(shí)際的內(nèi)存空間,也就是說如果我們用函數(shù)、切片,那么結(jié)構(gòu)體就會(huì)多占用一個(gè)函數(shù)/切片的內(nèi)存。
我們可以算一下,以官方的編譯器為準(zhǔn),在64位操作系統(tǒng)上指針和int都是8字節(jié)大小,一個(gè)函數(shù)的大小大概是8字節(jié),一個(gè)切片目前是24字節(jié),原始結(jié)構(gòu)體A大小是16字節(jié),如果使用_ func(),則大小變成24字節(jié),膨脹50%,如果我們使用_ []int,則大小變成40字節(jié),膨脹了150%!另外添加了新的有實(shí)際大小的字段,還會(huì)影響整個(gè)結(jié)構(gòu)體的內(nèi)存對齊,導(dǎo)致浪費(fèi)內(nèi)存或者在有特殊要求的接口中出錯(cuò)。
這時(shí)候_ [0]func()便派上用場了,go規(guī)定大小為0的數(shù)組不占用內(nèi)存空間,但字段依舊實(shí)際存在,編譯器也會(huì)照常進(jìn)行類型檢查。所以我們既不用浪費(fèi)內(nèi)存空間和改變內(nèi)存對齊,又可以禁止編譯器生成結(jié)構(gòu)體的比較操作。
至此新人的疑問解答完畢,下劃線字段的第一個(gè)實(shí)際應(yīng)用也介紹完了。
阻止結(jié)構(gòu)體被拷貝
首先要聲明,僅靠下劃線字段是不能阻止結(jié)構(gòu)體被拷貝的,我們只能做到讓代碼在幾乎所有代碼檢查工具和IDE里爆出警告信息。
這也是下劃線字段的常見應(yīng)用,在標(biāo)準(zhǔn)庫里就有,比如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實(shí)現(xiàn)了sync.Locker,所有實(shí)現(xiàn)了這個(gè)接口的類型理論上都不可以被復(fù)制,所有的代碼檢查工具包括自帶的go vet都會(huì)在看到實(shí)現(xiàn)了sync.Locker的類型被拷貝時(shí)發(fā)出警告。
而且noCopy的底層類型是空結(jié)構(gòu)體,不會(huì)占用內(nèi)存,因此這種用法也不需要我們支付額外的運(yùn)行時(shí)代價(jià)。
美中不足的是這只能產(chǎn)生一些警告,對這些結(jié)構(gòu)體進(jìn)行拷貝的代碼還是能正常編譯的。
強(qiáng)制指定初始化方式
在golang中用字面量初始化結(jié)構(gòu)體有方式:
type A struct {
B int64
C uint64
D string
}
a := A{1, 2, "3"}
b := A{
B: 1,
C: 2,
D: "3",
}
一個(gè)是在初始化時(shí)不指定字段的名稱,我們叫匿名初始化,在這種方式下所有字段的值都需要給出,且順序從左到右要和字段定義的順序一致。
第二個(gè)是在初始化時(shí)明確給出字段的名字,我們叫它具名初始化。具名初始化時(shí)不需要給出所有字段的值,未給出的會(huì)用零值進(jìn)行初始化;字段的順序也可以和定義時(shí)的順序不同(不過有的IDE會(huì)給出警告)。其中a := A{}算是一種特殊的具名初始化——沒給出字段名,所有全部的字段都用零值初始化。
如果結(jié)構(gòu)體里字段很多,而這些字段中的大多數(shù)又可以使用默認(rèn)的零值,那么具名初始化是一種安全又方便的做法。
匿名初始化則不僅繁瑣,而且因?yàn)橐蕾囎侄沃g的相對順序,很容易造成錯(cuò)誤或者因?yàn)樵鰟h字段導(dǎo)致代碼出錯(cuò)。因此一些項(xiàng)目里禁止了這種初始化。然而go并沒有在編譯器里提供這種禁止機(jī)制,所以我們又只能用下劃線字段模擬了。
我們可以反向利用匿名初始化需要給出每一個(gè)字段的值的特點(diǎn)來阻止匿名初始化??磦€(gè)例子:
// package a
package a
type A struct {
_ struct{}
B int64
C uint64
D string
}
// package main
func main() {
obj := a.A{1, 2, "3"} // 編譯報(bào)錯(cuò)
fmt.Println(obj)
}
編譯代碼會(huì)得到類似implicit assignment to unexported field _ in struct literal of type a.A的報(bào)錯(cuò)。
那如果我們偷看了源代碼,發(fā)現(xiàn)A的第一個(gè)字段就是一個(gè)空結(jié)構(gòu)體,然后把代碼改成下面的會(huì)怎么樣:
func main() {
- obj := a.A{1, 2, "3"} // 編譯報(bào)錯(cuò)
+ obj := a.A{struct{}{}, 1, 2, "3"} // ?
fmt.Println(obj)
}
答案依然是編譯報(bào)錯(cuò):implicit assignment to unexported field _ in struct literal of type a.A。
還記得我們在開頭就說過的嗎,下劃線字段不可訪問,這個(gè)訪問包含“初始化”,不可訪問意味著沒法給它初始值,這導(dǎo)致了匿名初始化無法進(jìn)行。所以偷看答案也沒有用,我們得老老實(shí)實(shí)對A使用具名初始化。
同樣因?yàn)槭怯玫目战Y(jié)構(gòu)體,我們不用付出運(yùn)行時(shí)代價(jià)。不過我推薦還是給出一個(gè)初始化函數(shù)如NewA比較好。
防止錯(cuò)誤的類型轉(zhuǎn)換
這個(gè)應(yīng)用我在以前的博客golang的類型轉(zhuǎn)換中詳細(xì)介紹過。
簡單的說golang只要兩個(gè)類型的底層類型相同,那么就運(yùn)行兩個(gè)類型的值之間互相轉(zhuǎn)換。這會(huì)給泛型類型帶來問題:
// 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的類型轉(zhuǎn)換規(guī)則,atomic.Pointer[byte]可以和atomic.Pointer[map[int]string]互相轉(zhuǎn)換,因?yàn)樗鼈兂祟愋蛥?shù)不同,底層類型是完全相同的。這當(dāng)然很荒謬,因?yàn)閎yte好map別說內(nèi)存布局完全不一樣,它們的實(shí)際大小也不同,相互轉(zhuǎn)換不僅沒有意義還會(huì)造成安全問題。
我們需要讓泛型類型的底層類型不同,那么就需要把類型參數(shù)加入字段里;而我們又不想這一補(bǔ)救措施產(chǎn)生運(yùn)行時(shí)開銷和影響使用。這時(shí)候就需要下劃線字段救場了:
// 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,我們在字段里使用了類型參數(shù),現(xiàn)在atomic.Pointer[byte]會(huì)有一個(gè)_ [0]*byte字段,atomic.Pointer[map[int]string]會(huì)有一個(gè)_ [0]*map[int]string字段,兩者類型完全不同,所以泛型類型之間也不再可以互相轉(zhuǎn)換了。
至于零長度數(shù)組,我們前面已經(jīng)介紹過了,它和空結(jié)構(gòu)體一樣不會(huì)產(chǎn)生實(shí)際的運(yùn)行開銷。
這個(gè)應(yīng)用其實(shí)不是很常見,但隨著泛型代碼越來越常用,我想大多數(shù)人早晚有一天會(huì)見到類似代碼的。
緩存行對齊
我們之前提到,下劃線字段不可訪問,但仍然實(shí)際占用內(nèi)存空間。所以之前的應(yīng)用都給下劃線字段一些大小為0的類型以避免產(chǎn)生開銷。
但下面要介紹的這種應(yīng)用反其道而行之,它需要占用空間的特性來實(shí)現(xiàn)緩存行對齊。
想象一下你有兩個(gè)原子變量,線程1會(huì)操作變量A,線程2操作變量B:
type Obj struct {
A atomic.Int64
B atomic.Int64
}
現(xiàn)代的x86 cpu上一個(gè)緩存行有64字節(jié)(Apple的一些芯片上甚至是128字節(jié)),所以一個(gè)Obj的對象多半會(huì)存儲(chǔ)在同一個(gè)緩存行里。線程1和線程2看似安全得操作這個(gè)兩個(gè)不同的原子變量,但在運(yùn)行時(shí)看來兩個(gè)線程會(huì)互相修改同一個(gè)緩存行里的內(nèi)容,這是典型的false sharing,會(huì)造成可觀的性能損失。
我這里不想對偽共享做過多的解釋,現(xiàn)在你只要知道想避免它,就得讓AB存儲(chǔ)在不同的緩存行里。最典型的就是在AB之間加上其他數(shù)據(jù)做填充,這些數(shù)據(jù)的大小要只是有一個(gè)緩存行也就是64字節(jié)那么大。
我們需要數(shù)據(jù)填充,但又不想填充的數(shù)據(jù)被訪問到,那肯定只能選擇下劃線字段了。以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
}
三個(gè)字段都用_ cpu.CacheLinePad分隔開了。而cpu.CacheLinePad的大小是正好一個(gè)緩存行,在arm上它的定義是:
type CacheLinePad struct{ _ [CacheLinePadSize]byte }
// mac arm64
const CacheLinePadSize = 128
CacheLinePad也使用下劃線字段,并且用一個(gè)byte數(shù)組占足了長度。
我們可以利用類似的方法來保證字段之間按緩存行對齊。
注意下劃線字段的位置
最后一點(diǎn)不是應(yīng)用場景,而是注意事項(xiàng)。
可以看到,如果我們不想下劃線字段占用內(nèi)存的時(shí)候,這個(gè)字段通常都是結(jié)構(gòu)體的第一個(gè)字段。
這當(dāng)然有可讀性更好的因素在,但還有一個(gè)更重要的影響:
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字節(jié)
fmt.Println(unsafe.Sizeof(B{})) // 24字節(jié)
}
是的,字段一樣,對齊規(guī)則一樣,但B會(huì)多出8字節(jié)。
這是因?yàn)間olang對結(jié)構(gòu)體的內(nèi)存布局有規(guī)定,結(jié)構(gòu)體里的字段可以有重疊,但這個(gè)重疊不能超過這個(gè)結(jié)構(gòu)體本身的內(nèi)存范圍。
舉個(gè)例子:
type B struct {
A *string
C int
D struct{}
}
array := [2]B{}
我們有一個(gè)數(shù)組存了兩個(gè)類型B的元素,字段D的大小理論上為0,所以如果我們用&array[0].D取D的地址,那么理論上有兩種情況:
- D和C共享地址,因?yàn)榍懊嬲f過結(jié)構(gòu)體內(nèi)部字段之間發(fā)生重疊是允許的,但在這里這個(gè)方案不行,因?yàn)樽侄沃g還有offset的規(guī)定,字段的offset必須大于等于前面所有字段和內(nèi)存對齊留下的空洞的大小之和(換句話說,也就是當(dāng)前字段的地址到結(jié)構(gòu)體內(nèi)存開始地址的距離),如果C和D共享地址,那么D的offset就錯(cuò)了,正確的應(yīng)該是16(D前面有8字節(jié)的A和8字節(jié)的C)而共享地址后會(huì)變成8。offset對反射和編譯器生成代碼有很重要的影響,所以容不得錯(cuò)誤。
- 數(shù)組的內(nèi)存是連續(xù)的,所以D和
array[1]共享地址,這是不引入填充時(shí)的第二個(gè)選擇,然而這會(huì)導(dǎo)致array[0]的字段可以訪問到array[1]的內(nèi)存,往嚴(yán)重說這是一種內(nèi)存破壞,只不過恰好我們的字段大小為0沒法進(jìn)行有效讀寫罷了。而且你考慮過array[1]的字段D的地址上應(yīng)該放啥了嗎,按照目前的想法是沒法處理的。
所以go選擇了一種折中的辦法,如果末尾的字段大小為0,則會(huì)在結(jié)構(gòu)體尾部加入一個(gè)內(nèi)存對齊大小的填充,在我們的結(jié)構(gòu)體里這個(gè)大小是8。這樣offset的計(jì)算不會(huì)出錯(cuò),同時(shí)也不會(huì)訪問到不該訪問的地址,而D的地址就是填充內(nèi)容起始處的地址。
如果大小為0的字段出現(xiàn)在結(jié)構(gòu)體的開頭,上面兩個(gè)問題就都不存在了,編譯器自然也不會(huì)再插入不必要的填充物。
所以對于大小為0的下劃線字段,我們一般放在結(jié)構(gòu)體的開頭處,以免產(chǎn)生不必要的開銷。
總結(jié)
上面列舉的只是一些最常見的下劃線字段的應(yīng)用,你完全可以因地制宜創(chuàng)造出新的用法。
但別忘了代碼可讀性是第一位的,不要為了炫技而濫用下劃線字段。同時(shí)也要小心不要踩到注意事項(xiàng)里說的坑。
到此這篇關(guān)于一文詳解下劃線字段在golang結(jié)構(gòu)體中的應(yīng)用的文章就介紹到這了,更多相關(guān)go下劃線字段內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
golang使用net/rpc庫實(shí)現(xiàn)rpc
這篇文章主要為大家詳細(xì)介紹了golang如何使用net/rpc庫實(shí)現(xiàn)rpc,文章的示例代碼講解詳細(xì),具有一定的借鑒價(jià)值,需要的小伙伴可以參考一下2024-01-01
利用Go實(shí)現(xiàn)一個(gè)簡易DAG服務(wù)的示例代碼
DAG的全稱是Directed Acyclic Graph,即有向無環(huán)圖,DAG廣泛應(yīng)用于表示具有方向性依賴關(guān)系的數(shù)據(jù),如任務(wù)調(diào)度、數(shù)據(jù)處理流程、項(xiàng)目管理以及許多其他領(lǐng)域,下面,我將用Go語言示范如何實(shí)現(xiàn)一個(gè)簡單的DAG服務(wù),需要的朋友可以參考下2024-03-03
Go到底能不能實(shí)現(xiàn)安全的雙檢鎖(推薦)
這篇文章主要介紹了Go到底能不能實(shí)現(xiàn)安全的雙檢鎖,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-05-05
理解Golang中的數(shù)組(array)、切片(slice)和map
這篇文章主要介紹了理解Golang中的數(shù)組(array)、切片(slice)和map,本文先是給出代碼,然后一一分解,并給出一張內(nèi)圖加深理解,需要的朋友可以參考下2014-10-10

