詳解 Go 空結構體的 3 種使用場景

煎魚發表於2021-10-15

大家好,我是煎魚。

在 Go 語言中,有一個比較特殊的型別,經常會有剛接觸 Go 的小夥伴問到,又或是不理解。

他就是 Go 裡的空結構體(struct)的使用,常常會有看到有人使用:

ch := make(chan struct{})

還清一色的使用結構體,也不用其他型別。高度常見,也就不是一個偶發現象了,肯定是背後必然有什麼原因。

今天煎魚這篇文章帶大家瞭解一下為什麼要這麼用,知其然知其所以然。

一起愉快地開始吸魚之路。

為什麼使用

說白了,就是希望節省空間。但,新問題又來了,為什麼不能用其他的型別來做?

這就涉及到在 Go 語言中 ”寬度“ 的概念,寬度描述了一個型別的例項所佔用的儲存空間的位元組數。

寬度是一個型別的屬性。在 Go 語言中的每個值都有一個型別,值的寬度由其型別定義,並且總是 8 bits 的倍數。

在 Go 語言中我們可以藉助 unsafe.Sizeof 方法,來獲取:

// Sizeof takes an expression x of any type and returns the size in bytes
// of a hypothetical variable v as if v was declared via var v = x.
// The size does not include any memory possibly referenced by x.
// For instance, if x is a slice, Sizeof returns the size of the slice
// descriptor, not the size of the memory referenced by the slice.
// The return value of Sizeof is a Go constant.
func Sizeof(x ArbitraryType) uintptr

該方法能夠得到值的寬度,自然而然也就能知道其型別對應的寬度是多少了。

我們對應看看 Go 語言中幾種常見的型別寬度大小:

func main() {
    var a int
    var b string
    var c bool
    var d [3]int32
    var e []string
    var f map[string]bool

    fmt.Println(
        unsafe.Sizeof(a),
        unsafe.Sizeof(b),
        unsafe.Sizeof(c),
        unsafe.Sizeof(d),
        unsafe.Sizeof(e),
        unsafe.Sizeof(f),
    )
}

輸出結果:

8 16 1 12 24 8

你可以發現我們列舉的幾種型別,只是單純宣告,我們也啥沒幹,依然佔據一定的寬度。

如果我們的場景,只是佔位符,那怎麼辦,系統裡的開銷就這麼白白浪費了?

空結構體的特殊性

空結構體在各類系統中頻繁出現的原因之一,就是需要一個佔位符。而恰恰好,Go 空結構體的寬度是特殊的。

如下:

func main() {
    var s struct{}
    fmt.Println(unsafe.Sizeof(s))
}

輸出結果:

0

空結構體的寬度是很直接了當的 0,即便是變形處理:

type S struct {
    A struct{}
    B struct{}
}

func main() {
    var s S
    fmt.Println(unsafe.Sizeof(s))
}

其最終輸出結果也是 0,完美切合人們對佔位符的基本訴求,就是佔著坑位,滿足基本輸入輸出就好。

但這時候問題又出現了,為什麼只有空結構會有這種特殊待遇,其他型別又不行?

這是 Go 編譯器在記憶體分配時做的優化項

// base address for all 0-byte allocations
var zerobase uintptr

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size == 0 {
        return unsafe.Pointer(&zerobase)
    }
}

當發現 size 為 0 時,會直接返回變數 zerobase 的引用,該變數是所有 0 位元組的基準地址,不佔據任何寬度。

因此空結構體的廣泛使用,是 Go 開發者們藉助了這個小優化,達到了佔位符的目的。

使用場景

瞭解清楚為什麼空結構作為佔位符使用的原因後,我們更進一步瞭解其真實的使用場景有哪些。

主要分為三塊:

  • 實現方法接收者。
  • 實現集合型別。
  • 實現空通道。

實現方法接收者

在業務場景下,我們需要將方法組合起來,代表其是一個 ”分組“ 的,便於後續擴充和維護。

但是如果我們使用:

type T string

func (s *T) Call()

又似乎有點不大友好,因為作為一個字串型別,其本身會佔據定的空間。

這種時候我們會採用空結構體的方式,這樣也便於未來針對該型別進行公共欄位等的增加。如下:

type T struct{}

func (s *T) Call() {
    fmt.Println("腦子進煎魚了")
}

func main() {
    var s T
    s.Call()
}

在該場景下,使用空結構體從多維度來考量是最合適的,易擴充,省空間,最結構化。

另外你會發現,其實你在日常開發中下意識就已經這麼做了,你可以理解為設計模式和日常生活相結合的另類案例。

實現集合型別

在 Go 語言的標準庫中並沒有提供集合(Set)的相關實現,因此一般在程式碼中我們圖方便,會直接用 map 來替代。

但有個問題,就是集合型別的使用,只需要用到 key(鍵),不需要 value(值)。

這就是空結構體大戰身手的場景了:

type Set map[string]struct{}

func (s Set) Append(k string) {
    s[k] = struct{}{}
}

func (s Set) Remove(k string) {
    delete(s, k)
}

func (s Set) Exist(k string) bool {
    _, ok := s[k]
    return ok
}

func main() {
    set := Set{}
    set.Append("煎魚")
    set.Append("鹹魚")
    set.Append("蒸魚")
    set.Remove("煎魚")

    fmt.Println(set.Exist("煎魚"))
}

空結構體作為佔位符,不會額外增加不必要的記憶體開銷,很方便的就是解決了。

實現空通道

在 Go channel 的使用場景中,常常會遇到通知型 channel,其不需要傳送任何資料,只是用於協調 Goroutine 的執行,用於流轉各類狀態或是控制併發情況。

如下:

func main() {
    ch := make(chan struct{})
    go func() {
        time.Sleep(1 * time.Second)
        close(ch)
    }()

    fmt.Println("腦子好像進...")
    <-ch
    fmt.Println("煎魚了!")
}

輸出結果:

腦子好像進...
煎魚了!

該程式會先輸出 ”腦子好像進...“ 後,再睡眠一段時間再輸出 "煎魚了!",達到間斷控制 channel 的效果。

由於該 channel 使用的是空結構體,因此也不會帶來額外的記憶體開銷。

總結

在今天這篇文章中,給大家介紹了 Go 語言中幾種常見型別的寬度,並且基於開頭的問題 ”空結構體“ 進行了剖析。

最後分析了在業內程式碼最常見的三種模式,進入真實場景。不知道你以前是否有過類似本文的疑惑呢?

歡迎大家在評論區留言和交流:)

若有任何疑問歡迎評論區反饋和交流,最好的關係是互相成就,各位的點贊就是煎魚創作的最大動力,感謝支援。

文章持續更新,可以微信搜【腦子進煎魚了】閱讀,本文 GitHub github.com/eddycjy/blog 已收錄,歡迎 Star 催更。

相關文章