Go 中空結構體的用法,我幫你總結全了!

在 Go 語言中,空結構體 struct{} 是一個非常特殊的類型,它不包含任何字段並且不佔用任何內存空間。雖然聽起來似乎沒什麼用,但空結構體在 Go 編程中實際上有着廣泛的應用。本文將詳細探討空結構體的幾種典型用法,並解釋爲何它們在特定場景下非常有用。

空結構體不佔用內存空間

首先我們來驗證下空結構體是否佔用內存空間:

type Empty struct{}

var s1 struct{}
s2 := Empty{}
s3 := struct{}{}

fmt.Printf("s1 addr: %p, size: %d\n"&s1, unsafe.Sizeof(s1))
fmt.Printf("s2 addr: %p, size: %d\n"&s2, unsafe.Sizeof(s2))
fmt.Printf("s3 addr: %p, size: %d\n"&s3, unsafe.Sizeof(s3))
fmt.Printf("s1 == s2 == s3: %t\n"s1 == s2 && s2 == s3)

NOTE: 爲了保持代碼邏輯清晰,這裏只展示了代碼主邏輯。後文中所有示例代碼都會如此,完整代碼可以在文末給出的示例代碼 GitHub 鏈接中獲取。

在 Go 語言中,我們可以使用 unsafe.Sizeof 計算一個對象佔用的字節數。

執行以上示例代碼,輸出結果如下:

$ go run main.go
s1 addr: 0x1044ef4a0, size: 0
s2 addr: 0x1044ef4a0, size: 0
s3 addr: 0x1044ef4a0, size: 0
s1 == s2 == s3: true

根據輸出結果可知:

  1. 多個空結構體內存地址相同。

  2. 空結構體佔用字節數爲 0,即不佔用內存空間。

  3. 多個空結構體值相等。

後面兩個結論很好理解,第一個結論有點反常識。爲什麼不同變量實例化的空結構體內存地址會相同?

真的是這樣嗎?我們可以看下另一個示例:

var (
 a struct{}
 b struct{}
 c struct{}
 d struct{}
)

println("&a:"&a)
println("&b:"&b)
println("&c:"&c)
println("&d:"&d)

println("&a == &b:"&a == &b)
x := &a
y := &b
println("x == y:"x == y)

fmt.Printf("&c(%p) == &d(%p): %t\n"&c, &d, &c == &d)

這段代碼中定義了 4 個空結構體,依次打印它們的內存地址,然後又分別對比了 ab 的內存地址和 cd 的內存地址兩兩是否相等。

執行示例代碼,輸出結果如下:

$ go run -gcflags='-m -N -l' main.go
# command-line-arguments
./main.go:11:3: moved to heap: c
./main.go:12:3: moved to heap: d
./main.go:23:12: ... argument does not escape
./main.go:23:50: &c == &d escapes to heap
&a: 0x1400010ae84
&b: 0x1400010ae84
&c: 0x104ec74a0
&d: 0x104ec74a0
&a == &b: false
x == y: true
&c(0x104ec74a0) == &d(0x104ec74a0): true

在 Go 語言中使用 go run 命令時,可以通過 -gcflags 選項向 Go 編譯器傳遞多個標誌,這些標誌會影響編譯器的行爲。

根據輸出可以發現,變量 cd 發生了內存逃逸,並且最終二者的內存地址相同,相等比較結果爲 true

ab 兩個變量的輸出結果就比較有意思了,兩個變量沒有發生內存逃逸,並且二者打印出來的內存地址相同,但內存地址相等比較結果卻爲 false

所以,我們可以推翻之前的結論,新結論爲:「多個空結構體內存地址可能相同」。

在 Go 官方的語言規範中 Size and alignment guarantees 部分對關於空結構體內存地址進行了說明:

A struct or array type has size zero if it contains no fields (or elements, respectively) that have a size greater than zero. Two distinct zero-size variables may have the same address in memory.

大概意思是說:如果一個結構體或數組類型不包含任何佔用內存大小大於零的字段(或元素),那麼它的大小爲零。兩個不同的零大小變量可能在內存中具有相同的地址

注意⚠️,這裏說的是可能may have the same。所以前文所述「多個空結構體內存地址相同」的結論並不準確。

NOTE: 本文示例執行結果基於 Go 1.22.0 版本,對於多個空結構體內存地址打印結果既存在相同情況,也存在不同情況,這跟 Go 編譯器實現有關,後續實現可能會有變化。

另外,對於嵌套的空結構體,其表現結果與普通空結構體相同:

type Empty struct{}

type MultiEmpty struct {
 A Empty
 B struct{}
}

s1 := Empty{}
s2 := MultiEmpty{}
fmt.Printf("s1 addr: %p, size: %d\n"&s1, unsafe.Sizeof(s1))
fmt.Printf("s2 addr: %p, size: %d\n"&s2, unsafe.Sizeof(s2))

執行示例代碼,輸出結果如下:

$ go run main.go
s1 addr: 0x1044ef4a0, size: 0
s2 addr: 0x1044ef4a0, size: 0

空結構體影響內存對齊

空結構體也並不是什麼時候都不會佔用內存空間,比如空結構體作爲另一個結構體字段時,根據位置不同,可能因內存對齊原因,導致外層結構體大小不一樣:

type A struct {
 x int
 y string
 z struct{}
}

type B struct {
 x int
 z struct{}
 y string
}

type C struct {
 z struct{}
 x int
 y string
}

a := A{}
b := B{}
c := C{}
fmt.Printf("struct a size: %d\n", unsafe.Sizeof(a))
fmt.Printf("struct b size: %d\n", unsafe.Sizeof(b))
fmt.Printf("struct c size: %d\n", unsafe.Sizeof(c))

以上示例中,定義了三個結構體 ABC,並且都定義了三個字段,類型分別是 intstringstruct{},空結構體字段分別放在最後、中間、最前面不同的位置。

執行示例代碼,輸出結果如下:

$ go run main.go
struct a size: 32
struct b size: 24
struct c size: 24

可以發現,當空結構體放在另一個結構體最後一個字段時,會觸發內存對齊。

此時外層結構體會佔用更多的內存空間,所以如果你的程序對內存要求比較嚴格,則在使用空結構體作爲字段時需要考慮這一點。

NOTE: 這裏先挖個坑,我會再寫一篇 Go 中結構體內存對齊的文章,分析下爲什麼 struct{} 放在結構體字段最後會出現內存對齊現象,敬請期待。防止迷路,可以關注下我的公衆號:Go 編程世界。

空結構體用法

根據前文的講解,我們對 Go 中空結構體的特性和一些使用時注意事項已經有所瞭解,是時候探索空結構體的用處了。

實現 Set

首先,空結構體最常用的地方,就是用來實現 set(集合) 類型了。

我們知道 Go 語言在語法層面沒有提供 set 類型。不過我們可以很方便的使用 map + struct{} 來實現 set 類型,代碼如下:

// Set 基於空結構體實現 set
type Set map[string]struct{}

// Add 添加元素到 set
func (s Set) Add(element string) {
 s[element] = struct{}{}
}

// Remove 從 set 中移除元素
func (s Set) Remove(element string) {
 delete(s, element)
}

// Contains 檢查 set 中是否包含指定元素
func (s Set) Contains(element string) bool {
 _, exists := s[element]
 return exists
}

// Size 返回 set 大小
func (s Set) Size() int {
 return len(s)
}

// String implements fmt.Stringer
func (s Set) String() string {
 format := "("
 for element := range s {
  format += element + " "
 }
 format = strings.TrimRight(format, " ") + ")"
 return format
}

s := make(Set)

s.Add("one")
s.Add("two")
s.Add("three")

fmt.Printf("set: %s\n", s)
fmt.Printf("set size: %d\n", s.Size())
fmt.Printf("set contains 'one': %t\n", s.Contains("one"))
fmt.Printf("set contains 'onex': %t\n", s.Contains("onex"))

s.Remove("one")

fmt.Printf("set: %s\n", s)
fmt.Printf("set size: %d\n", s.Size())

執行示例代碼,輸出結果如下:

$ go run main.go
set: (one two three)
set size: 3
set contains 'one'true
set contains 'onex'false
set: (three two)
set size: 2

使用 map 和空結構體非常容易實現 set 類型。mapkey 實際上與 set 不重複的特性剛好一致,一個不需要關心 valuemap 即爲 set

也正因爲如此,空結構體類型最適合作爲這個不需要關心的 valuemap 了,因爲它不佔空間,沒有語義

也許有人會認爲使用 any 作爲 mapvalue 也可以實現 set。但其實 any 是會佔用空間的。

示例如下:

s := make(map[string]any)
s["t1"] = nil
s["t2"] = struct{}{}
fmt.Printf("set t1 value: %v, size: %d\n", s["t1"], unsafe.Sizeof(s["t1"]))
fmt.Printf("set t2 value: %v, size: %d\n", s["t2"], unsafe.Sizeof(s["t2"]))

執行示例代碼,輸出結果如下:

$ go run main.go
set t1 value: <nil>, size: 16
set t2 value: {}, size: 16

可以發現,any 類型的 value 是有大小的,所以並不合適。

日常開發中,我們還會用到一種 set 的慣用法:

s := map[string]struct{}{
 "one":   {},
 "two":   {},
 "three"{},
}
for element := range s {
 fmt.Println(element)
}

這種用法也比較常見,無需聲明一個 set 類型,直接通過字面量定義一個 value 爲空結構體的 map,非常方便。

申請超大容量 Array

基於空結構體不佔內存空間的特性,我們可以考慮創建一個容量爲 100 萬的 array

var a [1000000]string
var b [1000000]struct{}

fmt.Printf("array a size: %d\n", unsafe.Sizeof(a))
fmt.Printf("array b size: %d\n", unsafe.Sizeof(b))

執行示例代碼,輸出結果如下:

$ go run main.go
array a size: 16000000
array b size: 0

使用空結構體創建的 array 其大小依然爲 0

申請超大容量 Slice

我們還以考慮創建一個容量爲 100 萬的 slice

var a = make([]string, 1000000)
var b = make([]struct{}, 1000000)
fmt.Printf("slice a size: %d\n", unsafe.Sizeof(a))
fmt.Printf("slice b size: %d\n", unsafe.Sizeof(b))

執行示例代碼,輸出結果如下:

$ go run main.go
slice a size: 24
slice b size: 24

當然,可以發現,其實不管是否使用空結構體,slice 只佔用 header 的空間。

信號通知

空結構體另一個我經常使用的方法是與 channel 結合當作信號來使用,示例如下:

done := make(chan struct{})

go func() {
    time.Sleep(1 * time.Second) // 執行一些操作...
    fmt.Printf("goroutine done\n")
    done <- struct{}{} // 發送完成信號
}()

fmt.Printf("waiting...\n")
<-done // 等待完成
fmt.Printf("main exit\n")

這段代碼中聲明瞭一個長度爲 0channel,其類型爲 chan struct{}

然後啓動一個 goroutine 執行業務邏輯,主協程等待信號退出,二者使用 channel 進行通信。

執行示例代碼,輸出結果如下:

$ go run main.go
waiting...
goroutine done
main exit

主協程先輸出 waiting...,然後等待 1s,goroutine 輸出 goroutine done,接着主協程收到退出信號,輸出 main exit 程序執行完成。

由於 struct{} 並不佔用內存,所以實際上 channel 內部只需要將計數器加一即可,不涉及數據傳輸,故沒有額外內存開銷。

這段代碼還有另一種實現:

done := make(chan struct{})

go func() {
 time.Sleep(1 * time.Second) // 執行一些操作...
 fmt.Printf("goroutine done\n")
 close(done) // 不需要發送 struct{}{},直接 close,發送完成信號
}()

fmt.Printf("waiting...\n")
<-done // 等待完成
fmt.Printf("main exit\n")

這裏 goroutine 中都不需要發送空結構體,直接對 channel 進行 close 就行了,struct{} 在這裏起到的作用更像是一個「佔位符」的作用。

在 Go 語言 context 源碼中也使用了 struct{} 作爲完成信號:

type Context interface {
 Deadline() (deadline time.Time, ok bool)
 // See https://blog.golang.org/pipelines for more examples of how to use
 // a Done channel for cancellation.
 Done() <-chan struct{}
 Err() error
 Value(key any) any
}

context.ContextDone 方法返回值即爲 chan struct{}

無操作的方法接收器

有時候,我們需要 “組合” 一些方法,並且這些方法內部並不會用到方法接收器,這時就可以使用 struct{} 作爲方法接收器。

type NoOp struct{}

func (n NoOp) Perform() {
 fmt.Println("Performing no operation.")
}

方法中代碼並沒有引用 n,如果換成其他類型則會佔用內存空間。

在實際開發過程中,有時候代碼寫到一半,爲了編譯通過,我們也會寫出這種代碼,先寫出代碼整體框架,再實現內部細節。

作爲接口實現

struct{} 作爲方法接收器,還有另一個用途,就是作爲接口的實現。常用於忽略不需要的輸出,和單元測試。啥意思呢?往下看。

我們知道 Go 中有個 io.Writer 接口:

type Writer interface {
 Write([]byte) (n int, err error)
}

我們還知道,Go 的 io 包中有個 io.Discard 變量,它的主要作用是提供一個 “黑洞” 設備,任何寫入到 io.Discard 的數據都會被消耗掉而不會有任何效果(這類似於 Unix 中的 /dev/null 設備)。

io.Discard 定義如下:

// Discard is a [Writer] on which all Write calls succeed
// without doing anything.
var Discard Writer = discard{}

type discard struct{}

func (discard) Write([]byte) (int, error) {
 return len(p), nil
}

io.Discard 代碼定義極其簡單,它實現了 io.Writer 接口,並且這個 Writer 方法的實現也極其簡單,什麼都沒做直接返回。

根據註釋也能發現,Writer 方法的目的就是啥都不做,所有調用都會成功,所以可以類比爲 Unix 系統中的 /dev/null

io.Discard 可以用於忽略日誌:

// 設置日誌輸出爲 `io.Discard`,忽略所有日誌
log.SetOutput(io.Discard)
// 這條日誌不會在任何地方顯示
log.Println("This log will not be shown anywhere")

此外,我曾寫過一篇文章《在 Go 語言單元測試中如何解決 MySQL 存儲依賴問題》。裏面有這樣一段示例代碼:

type UserStore interface {
 Create(user *User) error
 Get(id int) (*User, error)
}

...

type fakeUserStore struct{}

func (f *fakeUserStore) Create(user *store.User) error {
 return nil
}

func (f *fakeUserStore) Get(id int) (*store.User, error) {
 return &store.User{ID: id, Name: "test"}, nil
}

這就是空結構體作爲接口實現的另一種用途,編寫測試用 fake object 時非常有用。

即我們定義一個 struct{} 類型 fakeUserStore,然後實現 UserStore 接口,這樣在單元測試代碼中,就可以用 fakeUserStore 來替換真實的 UserStore 實例對象,以此來解決對象間的依賴問題。

標識符

最後,我們再來介紹一種空結構體比較好玩的用法。

相信很多同學都直接或間接的使用過 Go 中的 sync.Pool,其定義如下:

type Pool struct {
 noCopy noCopy

 local     unsafe.Pointer
 localSize uintptr

 victim     unsafe.Pointer
 victimSize uintptr

 New func() any
}

其中有一個 noCopy 屬性,其定義如下:

type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

noCopy 即爲一個空結構體,其實現也非常簡單,僅定義了兩個空方法。

而這個 noCopy 屬性看似沒什麼用,實際上卻有着大作用。這個字段的主要作用是阻止 sync.Pool 被意外複製。它是一種通過編譯器靜態分析來防止結構體被不當複製的技巧,以確保正確的使用和內存安全性。

可以通過 go vet 命令檢測出 sync.Pool 是否被意外複製。

在這裏,noCopy 屬性對當前結構體本身沒有作用,但可以將其作爲一個是否允許複製的標識符,有了這個標記,就代表結構體不能被複制,go vet 命令就可以檢查出來。

我們自定義的 struct 也可以通過嵌入 noCopy 屬性來實現禁止複製:

package main

type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

func main() {
 type A struct {
  noCopy noCopy
  a      string
 }

 type B struct {
  b string
 }

 a := A{a: "a"}
 b := B{b: "b"}

 _ = a
 _ = b
}

使用 go vet 命令檢查是否存在意外的結構體複製:

$ go vet main.go

# command-line-arguments
# [command-line-arguments]
./main.go:21:6: assignment copies lock value to _: command-line-arguments.A contains command-line-arguments.noCopy

可以發現,go vet 已經檢測出我們通過 _ = a 複製了 noCopy 結構體 A

總結

空結構體 struct{} 在 Go 中雖小卻有着巧妙的用途。

從節省內存的角度看,它是表示空概念的理想選擇。從語義上考慮,使用 struct{} 語義更明確,就是不關注值。

由於內存對齊的影響,空結構體字段順序可能影響外層結構體的大小,建議將空結構體放在外層結構體的第一個字段。

無論是作使用空結構體實現集合、信號通知、方法載體還是佔位符等,struct{} 都顯示了其獨特的價值。

你還知道空結構體還有哪些用途,可以分享出來大家一起交流學習。

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

延伸閱讀

聯繫我

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Qi0RrRyHLx8Q4SmhQeb-uQ