詳解Go語言中空結(jié)構(gòu)體的慣用法
在 Go 語言中,空結(jié)構(gòu)體 struct{}
是一個非常特殊的類型,它不包含任何字段并且不占用任何內(nèi)存空間。雖然聽起來似乎沒什么用,但空結(jié)構(gòu)體在 Go 編程中實際上有著廣泛的應(yīng)用。本文將詳細探討空結(jié)構(gòu)體的幾種典型用法,并解釋為何它們在特定場景下非常有用。
空結(jié)構(gòu)體不占用內(nèi)存空間
首先我們來驗證下空結(jié)構(gòu)體是否占用內(nèi)存空間:
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
計算一個對象占用的字節(jié)數(shù)。
執(zhí)行以上示例代碼,輸出結(jié)果如下:
$ go run main.go
s1 addr: 0x1044ef4a0, size: 0
s2 addr: 0x1044ef4a0, size: 0
s3 addr: 0x1044ef4a0, size: 0
s1 == s2 == s3: true
根據(jù)輸出結(jié)果可知:
- 多個空結(jié)構(gòu)體內(nèi)存地址相同。
- 空結(jié)構(gòu)體占用字節(jié)數(shù)為 0,即不占用內(nèi)存空間。
- 多個空結(jié)構(gòu)體值相等。
后面兩個結(jié)論很好理解,第一個結(jié)論有點反常識。為什么不同變量實例化的空結(jié)構(gòu)體內(nèi)存地址會相同?
真的是這樣嗎?我們可以看下另一個示例:
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 個空結(jié)構(gòu)體,依次打印它們的內(nèi)存地址,然后又分別對比了 a
與 b
的內(nèi)存地址和 c
與 d
的內(nèi)存地址兩兩是否相等。
執(zhí)行示例代碼,輸出結(jié)果如下:
$ 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 編譯器傳遞多個標(biāo)志,這些標(biāo)志會影響編譯器的行為。
-m
標(biāo)志用于啟動編譯器的內(nèi)存逃逸分析。-N
標(biāo)志用于禁用編譯器優(yōu)化。-l
標(biāo)志用于禁用函數(shù)內(nèi)聯(lián)。
根據(jù)輸出可以發(fā)現(xiàn),變量 c
和 d
發(fā)生了內(nèi)存逃逸,并且最終二者的內(nèi)存地址相同,相等比較結(jié)果為 true
。
而 a
和 b
兩個變量的輸出結(jié)果就比較有意思了,兩個變量沒有發(fā)生內(nèi)存逃逸,并且二者打印出來的內(nèi)存地址相同,但內(nèi)存地址相等比較結(jié)果卻為 false
。
所以,我們可以推翻之前的結(jié)論,新結(jié)論為:「多個空結(jié)構(gòu)體內(nèi)存地址可能相同」。
在 Go 官方的語言規(guī)范中 Size and alignment guarantees 部分對關(guān)于空結(jié)構(gòu)體內(nèi)存地址進行了說明:
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.
大概意思是說:如果一個結(jié)構(gòu)體或數(shù)組類型不包含任何占用內(nèi)存大小大于零的字段(或元素),那么它的大小為零。兩個不同的零大小變量可能在內(nèi)存中具有相同的地址。
注意,這里說的是可能:may have the same
。所以前文所述「多個空結(jié)構(gòu)體內(nèi)存地址相同」的結(jié)論并不準(zhǔn)確。
NOTE: 本文示例執(zhí)行結(jié)果基于 Go 1.22.0
版本,對于多個空結(jié)構(gòu)體內(nèi)存地址打印結(jié)果既存在相同情況,也存在不同情況,這跟 Go 編譯器實現(xiàn)有關(guān),后續(xù)實現(xiàn)可能會有變化。
另外,對于嵌套的空結(jié)構(gòu)體,其表現(xiàn)結(jié)果與普通空結(jié)構(gòu)體相同:
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))
執(zhí)行示例代碼,輸出結(jié)果如下:
$ go run main.go
s1 addr: 0x1044ef4a0, size: 0
s2 addr: 0x1044ef4a0, size: 0
空結(jié)構(gòu)體影響內(nèi)存對齊
空結(jié)構(gòu)體也并不是什么時候都不會占用內(nèi)存空間,比如空結(jié)構(gòu)體作為另一個結(jié)構(gòu)體字段時,根據(jù)位置不同,可能因內(nèi)存對齊原因,導(dǎo)致外層結(jié)構(gòu)體大小不一樣:
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))
以上示例中,定義了三個結(jié)構(gòu)體 A
、B
、C
,并且都定義了三個字段,類型分別是 int
、string
、struct{}
,空結(jié)構(gòu)體字段分別放在最后、中間、最前面不同的位置。
執(zhí)行示例代碼,輸出結(jié)果如下:
$ go run main.go
struct a size: 32
struct b size: 24
struct c size: 24
可以發(fā)現(xiàn),當(dāng)空結(jié)構(gòu)體放在另一個結(jié)構(gòu)體最后一個字段時,會觸發(fā)內(nèi)存對齊。
此時外層結(jié)構(gòu)體會占用更多的內(nèi)存空間,所以如果你的程序?qū)?nèi)存要求比較嚴(yán)格,則在使用空結(jié)構(gòu)體作為字段時需要考慮這一點。
NOTE: 這里先挖個坑,我會再寫一篇 Go 中結(jié)構(gòu)體內(nèi)存對齊的文章,分析下為什么 struct{}
放在結(jié)構(gòu)體字段最后會出現(xiàn)內(nèi)存對齊現(xiàn)象,敬請期待。防止迷路,
空結(jié)構(gòu)體用法
根據(jù)前文的講解,我們對 Go 中空結(jié)構(gòu)體的特性和一些使用時注意事項已經(jīng)有所了解,是時候探索空結(jié)構(gòu)體的用處了。
實現(xiàn) Set
首先,空結(jié)構(gòu)體最常用的地方,就是用來實現(xiàn) set(集合)
類型了。
我們知道 Go 語言在語法層面沒有提供 set
類型。不過我們可以很方便的使用 map
+ struct{}
來實現(xiàn) set
類型,代碼如下:
// Set 基于空結(jié)構(gòu)體實現(xiàn) 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())
執(zhí)行示例代碼,輸出結(jié)果如下:
$ 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
和空結(jié)構(gòu)體非常容易實現(xiàn) set
類型。map
的 key
實際上與 set
不重復(fù)的特性剛好一致,一個不需要關(guān)心 value
的 map
即為 set
。
也正因為如此,空結(jié)構(gòu)體類型最適合作為這個不需要關(guān)心的 value
的 map
了,因為它不占空間,沒有語義。
也許有人會認(rèn)為使用 any
作為 map
的 value
也可以實現(xiàn) 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"]))
執(zhí)行示例代碼,輸出結(jié)果如下:
$ go run main.go
set t1 value: <nil>, size: 16
set t2 value: {}, size: 16
可以發(fā)現(xiàn),any
類型的 value
是有大小的,所以并不合適。
日常開發(fā)中,我們還會用到一種 set
的慣用法:
s := map[string]struct{}{ "one": {}, "two": {}, "three": {}, } for element := range s { fmt.Println(element) }
這種用法也比較常見,無需聲明一個 set
類型,直接通過字面量定義一個 value
為空結(jié)構(gòu)體的 map
,非常方便。
申請超大容量 Array
基于空結(jié)構(gòu)體不占內(nèi)存空間的特性,我們可以考慮創(chuàng)建一個容量為 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))
執(zhí)行示例代碼,輸出結(jié)果如下:
$ go run main.go
array a size: 16000000
array b size: 0
使用空結(jié)構(gòu)體創(chuàng)建的 array
其大小依然為 0
。
申請超大容量 Slice
我們還以考慮創(chuàng)建一個容量為 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))
執(zhí)行示例代碼,輸出結(jié)果如下:
$ go run main.go
slice a size: 24
slice b size: 24
當(dāng)然,可以發(fā)現(xiàn),其實不管是否使用空結(jié)構(gòu)體,slice
只占用 header
的空間。
信號通知
空結(jié)構(gòu)體另一個我經(jīng)常使用的方法是與 channel
結(jié)合當(dāng)作信號來使用,示例如下:
done := make(chan struct{}) go func() { time.Sleep(1 * time.Second) // 執(zhí)行一些操作... fmt.Printf("goroutine done\n") done <- struct{}{} // 發(fā)送完成信號 }() fmt.Printf("waiting...\n") <-done // 等待完成 fmt.Printf("main exit\n")
這段代碼中聲明了一個長度為 0
的 channel
,其類型為 chan struct{}
。
然后啟動一個 goroutine
執(zhí)行業(yè)務(wù)邏輯,主協(xié)程等待信號退出,二者使用 channel
進行通信。
執(zhí)行示例代碼,輸出結(jié)果如下:
$ go run main.go
waiting...
goroutine done
main exit
主協(xié)程先輸出 waiting...
,然后等待 1s,goroutine
輸出 goroutine done
,接著主協(xié)程收到退出信號,輸出 main exit
程序執(zhí)行完成。
由于 struct{}
并不占用內(nèi)存,所以實際上 channel
內(nèi)部只需要將計數(shù)器加一即可,不涉及數(shù)據(jù)傳輸,故沒有額外內(nèi)存開銷。
這段代碼還有另一種實現(xiàn):
done := make(chan struct{}) go func() { time.Sleep(1 * time.Second) // 執(zhí)行一些操作... fmt.Printf("goroutine done\n") close(done) // 不需要發(fā)送 struct{}{},直接 close,發(fā)送完成信號 }() fmt.Printf("waiting...\n") <-done // 等待完成 fmt.Printf("main exit\n")
這里 goroutine
中都不需要發(fā)送空結(jié)構(gòu)體,直接對 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{}
。
無操作的方法接收器
有時候,我們需要“組合”一些方法,并且這些方法內(nèi)部并不會用到方法接收器
,這時就可以使用 struct{}
作為方法接收器。
type NoOp struct{} func (n NoOp) Perform() { fmt.Println("Performing no operation.") }
方法中代碼并沒有引用 n
,如果換成其他類型則會占用內(nèi)存空間。
在實際開發(fā)過程中,有時候代碼寫到一半,為了編譯通過,我們也會寫出這種代碼,先寫出代碼整體框架,再實現(xiàn)內(nèi)部細節(jié)。
作為接口實現(xiàn)
用 struct{}
作為方法接收器,還有另一個用途,就是作為接口的實現(xiàn)。常用于忽略不需要的輸出,和單元測試。啥意思呢?往下看。
我們知道 Go 中有個 io.Writer
接口:
type Writer interface { Write(p []byte) (n int, err error) }
我們還知道,Go 的 io
包中有個 io.Discard
變量,它的主要作用是提供一個“黑洞”設(shè)備,任何寫入到 io.Discard
的數(shù)據(jù)都會被消耗掉而不會有任何效果(這類似于 Unix 中的 /dev/null
設(shè)備)。
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
代碼定義極其簡單,它實現(xiàn)了 io.Writer
接口,并且這個 Writer
方法的實現(xiàn)也極其簡單,什么都沒做直接返回。
根據(jù)注釋也能發(fā)現(xiàn),Writer
方法的目的就是啥都不做,所有調(diào)用都會成功,所以可以類比為 Unix 系統(tǒng)中的 /dev/null
。
io.Discard
可以用于忽略日志:
// 設(shè)置日志輸出為 `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 }
這就是空結(jié)構(gòu)體作為接口實現(xiàn)的另一種用途,編寫測試用 fake object
時非常有用。
即我們定義一個 struct{}
類型 fakeUserStore
,然后實現(xiàn) UserStore
接口,這樣在單元測試代碼中,就可以用 fakeUserStore
來替換真實的 UserStore
實例對象,以此來解決對象間的依賴問題。
標(biāo)識符
最后,我們再來介紹一種空結(jié)構(gòu)體比較好玩的用法。
相信很多同學(xué)都直接或間接的使用過 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
即為一個空結(jié)構(gòu)體,其實現(xiàn)也非常簡單,僅定義了兩個空方法。
而這個 noCopy
屬性看似沒什么用,實際上卻有著大作用。這個字段的主要作用是阻止 sync.Pool
被意外復(fù)制。它是一種通過編譯器靜態(tài)分析來防止結(jié)構(gòu)體被不當(dāng)復(fù)制的技巧,以確保正確的使用和內(nèi)存安全性。
可以通過 go vet
命令檢測出 sync.Pool
是否被意外復(fù)制。
在這里,noCopy
屬性對當(dāng)前結(jié)構(gòu)體本身沒有作用,但可以將其作為一個是否允許復(fù)制的標(biāo)識符,有了這個標(biāo)記,就代表結(jié)構(gòu)體不能被復(fù)制,go vet
命令就可以檢查出來。
我們自定義的 struct
也可以通過嵌入 noCopy
屬性來實現(xiàn)禁止復(fù)制:
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
命令檢查是否存在意外的結(jié)構(gòu)體復(fù)制:
$ 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
可以發(fā)現(xiàn),go vet
已經(jīng)檢測出我們通過 _ = a
復(fù)制了 noCopy
結(jié)構(gòu)體 A
。
總結(jié)
空結(jié)構(gòu)體 struct{}
在 Go 中雖小卻有著巧妙的用途。
從節(jié)省內(nèi)存的角度看,它是表示空概念的理想選擇。從語義上考慮,使用 struct{}
語義更明確,就是不關(guān)注值。
由于內(nèi)存對齊的影響,空結(jié)構(gòu)體字段順序可能影響外層結(jié)構(gòu)體的大小,建議將空結(jié)構(gòu)體放在外層結(jié)構(gòu)體的第一個字段。
無論是作使用空結(jié)構(gòu)體實現(xiàn)集合、信號通知、方法載體還是占位符等,struct{}
都顯示了其獨特的價值。
以上就是詳解Go語言中空結(jié)構(gòu)體的慣用法的詳細內(nèi)容,更多關(guān)于Go語言空結(jié)構(gòu)體的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
go語言題解LeetCode1299將每個元素替換為右側(cè)最大元素
這篇文章主要為大家介紹了go語言LeetCode刷題1299將每個元素替換為右側(cè)最大元素示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-01-01Golang并發(fā)繞不開的重要組件之Goroutine詳解
Goroutine、Channel、Context、Sync都是Golang并發(fā)編程中的幾個重要組件,這篇文中主要為大家介紹了Goroutine的相關(guān)知識,需要的可以參考一下2023-06-06go中string、int、float相互轉(zhuǎn)換方式
這篇文章主要介紹了go中string、int、float相互轉(zhuǎn)換方式,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-07-07