欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

一文詳解下劃線字段在golang結構體中的應用

 更新時間:2025年08月18日 08:16:07   作者:apocelipes  
這篇文章主要為大家詳細介紹了下劃線字段在golang結構體中應用的相關知識,文中的示例代碼講解詳細,感興趣的小伙伴可以跟隨小編一起學習一下

最近公司里的新人問了我一個問題:這段代碼是啥意思。這個問題很普通也很常見,我還是個新人的時候也經(jīng)常問,當然,現(xiàn)在我不是新人了但我也經(jīng)常發(fā)出類似的提問。

代碼是長這樣的:

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

新人問我_ [0]func()是什么。不得不說這是個好問題,因為這樣的代碼第一眼看上去誰都會覺得很奇怪,這種叫沒有名字只有一個下劃線占位符的我們暫且叫做“下劃線字段”,下劃線字段會占用實際的空間但又不能被訪問,使用這樣一個字段有什么用呢?

今天我就來講講下劃線字段在Golang中的實際應用,除了能回答上面新人的疑問,還能幫你了解一些開源項目中的golang慣用法。

使結構體不能被比較

默認情況下golang的結構體是可以進行相等和不等判斷的,編譯器會自動生成比較每個字段的值的代碼。

這和其他語言是很不一樣的,在c語言里想要比較兩個結構體你需要自寫比較函數(shù)或者借助memcmp等標準庫接口,在c++/Java/python中則需要重載/重寫指定的運算符或者方法,而在go里除了少數(shù)特殊情況之外這些工作都由編譯器代勞了。

然而天下沒有免費的午餐,讓編譯器代勞等價于失去對比較操作的控制權。

舉個簡單的例子,你有一個字段都是指針類型的結構體,這些結構體可以進行等值判斷,判斷的依據(jù)是指針指向的實際內(nèi)容:

type A struct {
    Name *string
    Age  int
}

這種結構體在JSON序列化和數(shù)據(jù)庫操作中很常見,理想中的判斷操作應該是先解引用Name,比較他們指向的字符串的值,然后再比較Age是否相同。

但編譯器生成的是先比較Name存儲的地址值而不是他們指向的字符串的具體內(nèi)容,然后再比較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
}

函數(shù)getString模擬了序列化和反序列化時的場景:相同內(nèi)容的字符串每次都是獨立分配的,導致了他們的地址不同。從結果可以看到golang默認生成的比較是不正確。

更糟糕的是這個默認生成的行為無法禁止,會導致==的誤用。

實際生產(chǎn)中還有另一種情況,編譯器覺得結構體符合比較的規(guī)則,但邏輯上這種結構體的等值比較沒有實際意義。顯然放任編譯器的默認行為沒有任何好處。

這時候新人問的那行代碼就發(fā)揮用處了,我們把那行代碼加進結構體里:

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

現(xiàn)在程序會報錯了:invalid operation: a == b (struct containing [0]func() cannot be compared)

這就是之前說的少數(shù)幾種特殊情況:函數(shù)、切片、map是不能比較的,包含這些類型字段的結構體或者數(shù)組也不可以進行比較操作。

我們的下劃線字段是一個元素為函數(shù)的數(shù)組。在Go中,數(shù)組可以進行等值比較,但函數(shù)不能,因此[0]func()類型的下劃線
字段將無法參與比較。接著由于go語法的規(guī)定,只要有一個字段不能進行比較,那么整個結構體也不能,所以==不再能應用在結構體A上。

解釋到這里新人又有了疑問:如果只是禁止使用==,那么_ func()的效果不是一樣的嗎,為什么還要費事再套一層數(shù)組呢?

新人的洞察力真的很敏銳,如果只是禁止自動生成比較操作的代碼,直接使用函數(shù)類型或者切片和map效果是一樣的。但是我們忘了一件事:下劃線字段雖然無法訪問但仍然會占用實際的內(nèi)存空間,也就是說如果我們用函數(shù)、切片,那么結構體就會多占用一個函數(shù)/切片的內(nèi)存。

我們可以算一下,以官方的編譯器為準,在64位操作系統(tǒng)上指針和int都是8字節(jié)大小,一個函數(shù)的大小大概是8字節(jié),一個切片目前是24字節(jié),原始結構體A大小是16字節(jié),如果使用_ func(),則大小變成24字節(jié),膨脹50%,如果我們使用_ []int,則大小變成40字節(jié),膨脹了150%!另外添加了新的有實際大小的字段,還會影響整個結構體的內(nèi)存對齊,導致浪費內(nèi)存或者在有特殊要求的接口中出錯。

這時候_ [0]func()便派上用場了,go規(guī)定大小為0的數(shù)組不占用內(nèi)存空間,但字段依舊實際存在,編譯器也會照常進行類型檢查。所以我們既不用浪費內(nèi)存空間和改變內(nèi)存對齊,又可以禁止編譯器生成結構體的比較操作。

至此新人的疑問解答完畢,下劃線字段的第一個實際應用也介紹完了。

阻止結構體被拷貝

首先要聲明,僅靠下劃線字段是不能阻止結構體被拷貝的,我們只能做到讓代碼在幾乎所有代碼檢查工具和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實現(xiàn)了sync.Locker,所有實現(xiàn)了這個接口的類型理論上都不可以被復制,所有的代碼檢查工具包括自帶的go vet都會在看到實現(xiàn)了sync.Locker的類型被拷貝時發(fā)出警告。

而且noCopy的底層類型是空結構體,不會占用內(nèi)存,因此這種用法也不需要我們支付額外的運行時代價。

美中不足的是這只能產(chǎn)生一些警告,對這些結構體進行拷貝的代碼還是能正常編譯的。

強制指定初始化方式

在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{}算是一種特殊的具名初始化——沒給出字段名,所有全部的字段都用零值初始化。

如果結構體里字段很多,而這些字段中的大多數(shù)又可以使用默認的零值,那么具名初始化是一種安全又方便的做法。

匿名初始化則不僅繁瑣,而且因為依賴字段之間的相對順序,很容易造成錯誤或者因為增刪字段導致代碼出錯。因此一些項目里禁止了這種初始化。然而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的報錯。

那如果我們偷看了源代碼,發(fā)現(xiàn)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使用具名初始化。

同樣因為是用的空結構體,我們不用付出運行時代價。不過我推薦還是給出一個初始化函數(shù)如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的類型轉換規(guī)則,atomic.Pointer[byte]可以和atomic.Pointer[map[int]string]互相轉換,因為它們除了類型參數(shù)不同,底層類型是完全相同的。這當然很荒謬,因為byte好map別說內(nèi)存布局完全不一樣,它們的實際大小也不同,相互轉換不僅沒有意義還會造成安全問題。

我們需要讓泛型類型的底層類型不同,那么就需要把類型參數(shù)加入字段里;而我們又不想這一補救措施產(chǎn)生運行時開銷和影響使用。這時候就需要下劃線字段救場了:

// 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]會有一個_ [0]*byte字段,atomic.Pointer[map[int]string]會有一個_ [0]*map[int]string字段,兩者類型完全不同,所以泛型類型之間也不再可以互相轉換了。

至于零長度數(shù)組,我們前面已經(jīng)介紹過了,它和空結構體一樣不會產(chǎn)生實際的運行開銷。

這個應用其實不是很常見,但隨著泛型代碼越來越常用,我想大多數(shù)人早晚有一天會見到類似代碼的。

緩存行對齊

我們之前提到,下劃線字段不可訪問,但仍然實際占用內(nèi)存空間。所以之前的應用都給下劃線字段一些大小為0的類型以避免產(chǎn)生開銷。

但下面要介紹的這種應用反其道而行之,它需要占用空間的特性來實現(xiàn)緩存行對齊。

想象一下你有兩個原子變量,線程1會操作變量A,線程2操作變量B:

type Obj struct {
    A atomic.Int64
    B atomic.Int64
}

現(xiàn)代的x86 cpu上一個緩存行有64字節(jié)(Apple的一些芯片上甚至是128字節(jié)),所以一個Obj的對象多半會存儲在同一個緩存行里。線程1和線程2看似安全得操作這個兩個不同的原子變量,但在運行時看來兩個線程會互相修改同一個緩存行里的內(nèi)容,這是典型的false sharing,會造成可觀的性能損失。

我這里不想對偽共享做過多的解釋,現(xiàn)在你只要知道想避免它,就得讓AB存儲在不同的緩存行里。最典型的就是在AB之間加上其他數(shù)據(jù)做填充,這些數(shù)據(jù)的大小要只是有一個緩存行也就是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
}

三個字段都用_ cpu.CacheLinePad分隔開了。而cpu.CacheLinePad的大小是正好一個緩存行,在arm上它的定義是:

type CacheLinePad struct{ _ [CacheLinePadSize]byte }

// mac arm64
const CacheLinePadSize = 128

CacheLinePad也使用下劃線字段,并且用一個byte數(shù)組占足了長度。

我們可以利用類似的方法來保證字段之間按緩存行對齊。

注意下劃線字段的位置

最后一點不是應用場景,而是注意事項。

可以看到,如果我們不想下劃線字段占用內(nèi)存的時候,這個字段通常都是結構體的第一個字段。

這當然有可讀性更好的因素在,但還有一個更重要的影響:

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會多出8字節(jié)。

這是因為golang對結構體的內(nèi)存布局有規(guī)定,結構體里的字段可以有重疊,但這個重疊不能超過這個結構體本身的內(nèi)存范圍。

舉個例子:

type B struct {
    A *string
    C int
    D struct{}
}

array := [2]B{}

我們有一個數(shù)組存了兩個類型B的元素,字段D的大小理論上為0,所以如果我們用&array[0].D取D的地址,那么理論上有兩種情況:

  • D和C共享地址,因為前面說過結構體內(nèi)部字段之間發(fā)生重疊是允許的,但在這里這個方案不行,因為字段之間還有offset的規(guī)定,字段的offset必須大于等于前面所有字段和內(nèi)存對齊留下的空洞的大小之和(換句話說,也就是當前字段的地址到結構體內(nèi)存開始地址的距離),如果C和D共享地址,那么D的offset就錯了,正確的應該是16(D前面有8字節(jié)的A和8字節(jié)的C)而共享地址后會變成8。offset對反射和編譯器生成代碼有很重要的影響,所以容不得錯誤。
  • 數(shù)組的內(nèi)存是連續(xù)的,所以D和array[1]共享地址,這是不引入填充時的第二個選擇,然而這會導致array[0]的字段可以訪問到array[1]的內(nèi)存,往嚴重說這是一種內(nèi)存破壞,只不過恰好我們的字段大小為0沒法進行有效讀寫罷了。而且你考慮過array[1]的字段D的地址上應該放啥了嗎,按照目前的想法是沒法處理的。

所以go選擇了一種折中的辦法,如果末尾的字段大小為0,則會在結構體尾部加入一個內(nèi)存對齊大小的填充,在我們的結構體里這個大小是8。這樣offset的計算不會出錯,同時也不會訪問到不該訪問的地址,而D的地址就是填充內(nèi)容起始處的地址。

如果大小為0的字段出現(xiàn)在結構體的開頭,上面兩個問題就都不存在了,編譯器自然也不會再插入不必要的填充物。

所以對于大小為0的下劃線字段,我們一般放在結構體的開頭處,以免產(chǎn)生不必要的開銷。

總結

上面列舉的只是一些最常見的下劃線字段的應用,你完全可以因地制宜創(chuàng)造出新的用法。

但別忘了代碼可讀性是第一位的,不要為了炫技而濫用下劃線字段。同時也要小心不要踩到注意事項里說的坑。

到此這篇關于一文詳解下劃線字段在golang結構體中的應用的文章就介紹到這了,更多相關go下劃線字段內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!

相關文章

  • GoLang nil與interface的空指針深入分析

    GoLang nil與interface的空指針深入分析

    Go語言中任何類型在未初始化時都對應一個零值:布爾類型是false,整型是0,字符串是"",而指針、函數(shù)、interface、slice、channel和map的零值都是nil
    2022-12-12
  • golang使用net/rpc庫實現(xiàn)rpc

    golang使用net/rpc庫實現(xiàn)rpc

    這篇文章主要為大家詳細介紹了golang如何使用net/rpc庫實現(xiàn)rpc,文章的示例代碼講解詳細,具有一定的借鑒價值,需要的小伙伴可以參考一下
    2024-01-01
  • 一文理解Go 中的可尋址和不可尋址

    一文理解Go 中的可尋址和不可尋址

    如果字典的元素不存在,則返回零值,而零值是不可變對象,如果能尋址問題就大了。而如果字典的元素存在,考慮到 Go 中 map 實現(xiàn)中元素的地址是變化的,這意味著尋址的結果也是無意義的。下面我們就圍繞這個話題寫一篇文章吧,需要的朋友可以參考一下
    2021-10-10
  • 使用Go語言編寫一個極簡版的容器Container

    使用Go語言編寫一個極簡版的容器Container

    Docker作為一種流行的容器化技術,對于每一個程序開發(fā)者而言都具有重要性和必要性,因為容器化相關技術的普及大大簡化了開發(fā)環(huán)境配置、更好的隔離性和更高的安全性,對于部署項目和團隊協(xié)作而言也更加方便,本文將嘗試使用Go語言編寫一個極簡版的容器
    2023-10-10
  • 如何避免Go語言常見錯誤之意外的變量隱藏

    如何避免Go語言常見錯誤之意外的變量隱藏

    在Go語言中,變量隱藏(Variable Shadowing)是一個常見的錯誤來源,變量隱藏發(fā)生在一個內(nèi)部作用域中聲明的變量與外部作用域的變量同名時,這可能導致開發(fā)者無意中使用了錯誤的變量,造成難以追蹤的bug,本文講解一些關于變量隱藏的常見錯誤和如何避免它們的方法
    2024-01-01
  • go語言中函數(shù)的用法示例詳解

    go語言中函數(shù)的用法示例詳解

    Go語言中函數(shù)是基本的代碼組織單元,用于封裝一段代碼,使代碼結構更清晰、可復用,本文詳細講解了基本函數(shù)定義、參數(shù)傳遞、返回值、多返回值、匿名函數(shù)、遞歸和defer語句的使用,感興趣的朋友一起看看吧
    2024-10-10
  • 利用Go實現(xiàn)一個簡易DAG服務的示例代碼

    利用Go實現(xiàn)一個簡易DAG服務的示例代碼

    DAG的全稱是Directed Acyclic Graph,即有向無環(huán)圖,DAG廣泛應用于表示具有方向性依賴關系的數(shù)據(jù),如任務調度、數(shù)據(jù)處理流程、項目管理以及許多其他領域,下面,我將用Go語言示范如何實現(xiàn)一個簡單的DAG服務,需要的朋友可以參考下
    2024-03-03
  • Go到底能不能實現(xiàn)安全的雙檢鎖(推薦)

    Go到底能不能實現(xiàn)安全的雙檢鎖(推薦)

    這篇文章主要介紹了Go到底能不能實現(xiàn)安全的雙檢鎖,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下
    2022-05-05
  • 理解Golang中的數(shù)組(array)、切片(slice)和map

    理解Golang中的數(shù)組(array)、切片(slice)和map

    這篇文章主要介紹了理解Golang中的數(shù)組(array)、切片(slice)和map,本文先是給出代碼,然后一一分解,并給出一張內(nèi)圖加深理解,需要的朋友可以參考下
    2014-10-10
  • Go語言執(zhí)行系統(tǒng)命令行命令的方法

    Go語言執(zhí)行系統(tǒng)命令行命令的方法

    這篇文章主要介紹了Go語言執(zhí)行系統(tǒng)命令行命令的方法,實例分析了Go語言操作系統(tǒng)命令行的技巧,具有一定參考借鑒價值,需要的朋友可以參考下
    2015-02-02

最新評論