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
根據輸出結果可知:
-
多個空結構體內存地址相同。
-
空結構體佔用字節數爲 0,即不佔用內存空間。
-
多個空結構體值相等。
後面兩個結論很好理解,第一個結論有點反常識。爲什麼不同變量實例化的空結構體內存地址會相同?
真的是這樣嗎?我們可以看下另一個示例:
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 個空結構體,依次打印它們的內存地址,然後又分別對比了 a 與 b 的內存地址和 c 與 d 的內存地址兩兩是否相等。
執行示例代碼,輸出結果如下:
$ 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標誌用於禁用函數內聯。
根據輸出可以發現,變量 c 和 d 發生了內存逃逸,並且最終二者的內存地址相同,相等比較結果爲 true。
而 a 和 b 兩個變量的輸出結果就比較有意思了,兩個變量沒有發生內存逃逸,並且二者打印出來的內存地址相同,但內存地址相等比較結果卻爲 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))
以上示例中,定義了三個結構體 A、B、C,並且都定義了三個字段,類型分別是 int、string、struct{},空結構體字段分別放在最後、中間、最前面不同的位置。
執行示例代碼,輸出結果如下:
$ 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 類型。map 的 key 實際上與 set 不重複的特性剛好一致,一個不需要關心 value 的 map 即爲 set。
也正因爲如此,空結構體類型最適合作爲這個不需要關心的 value 的 map 了,因爲它不佔空間,沒有語義。
也許有人會認爲使用 any 作爲 map 的 value 也可以實現 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")
這段代碼中聲明瞭一個長度爲 0 的 channel,其類型爲 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.Context 的 Done 方法返回值即爲 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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Qi0RrRyHLx8Q4SmhQeb-uQ