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

技术颜良發表於2024-07-09

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 編譯器傳遞多個標誌,這些標誌會影響編譯器的行為。

  • -m 標誌用於啟動編譯器的記憶體逃逸分析。
  • -N 標誌用於禁用編譯器最佳化。
  • -l 標誌用於禁用函式內聯。

根據輸出可以發現,變數 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(p []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(p []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 中,歡迎點選檢視。

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

延伸閱讀

  • The empty struct: https://dave.cheney.net/2014/03/25/the-empty-struct
  • The Ingenious World of Empty Structs in Go: Zeroing in on Zero Memory: https://medium.com/@cosmicray001/the-ingenious-world-of-empty-structs-in-go-zeroing-in-on-zero-memory-a7050279fe18
  • What is the use of empty struct in GoLang: https://www.pixelstech.net/article/1677371161-What-is-the-use-of-empty-struct-in-GoLang
  • Using empty structs as context keys: https://gist.github.com/SammyOina/6eb54babd618ab6a850e8f1af4f4ac7d
  • Size and alignment guarantees: https://go.dev/ref/spec#Size_and_alignment_guarantees
  • What uses a type with empty struct has in Go?: https://stackoverflow.com/questions/47544156/what-uses-a-type-with-empty-struct-has-in-go
  • runtime/malloc.go: https://github.com/golang/go/blob/master/src/runtime/malloc.go#L904
  • 在 Go 語言單元測試中如何解決 MySQL 儲存依賴問題: https://jianghushinian.cn/2023/07/16/how-to-resolve-mysql-dependencies-in-go-testing/#Fake-%E6%B5%8B%E8%AF%95
  • 本文 GitHub 示例程式碼:https://github.com/jianghushinian/blog-go-example/tree/main/struct/empty

聯絡我

  • 公眾號:Go程式設計世界
  • 微信:jianghushinian
  • 郵箱:jianghushinian007@outlook.com
  • 部落格:https://jianghushinian.cn
程式設計技巧 · 目錄
下一篇在 Go 中如何讓結構體不可比較?
閱讀 1001

相關文章