下劃線字段在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的類型轉換中詳細介紹過。
簡單的說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的地址,那么理論上有兩種情況:
- 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的下劃線字段,我們一般放在結構體的開頭處,以免產生不必要的開銷。
總結
上面列舉的只是一些最常見的下劃線字段的應用,你完全可以因地制宜創造出新的用法。
但別忘了代碼可讀性是第一位的,不要為了炫技而濫用下劃線字段。同時也要小心不要踩到注意事項里說的坑。


浙公網安備 33010602011771號