在 Go 语言中,空结构体 struct{}
是一个非常特殊的类型,它不包含任何字段且不占用任何内存空间。
空结构体不占用内存空间
1type Empty struct{}
2
3func main() {
4
5 var s1 struct{}
6 s2 := Empty{}
7 s3 := struct{}{}
8
9 fmt.Printf("s1 addr: %p, size: %d\n", &s1, unsafe.Sizeof(s1))
10 fmt.Printf("s2 addr: %p, size: %d\n", &s2, unsafe.Sizeof(s2))
11 fmt.Printf("s3 addr: %p, size: %d\n", &s3, unsafe.Sizeof(s3))
12 fmt.Printf("s1 == s2 == s3: %t\n", s1 == s2 && s2 == s3)
13}
得到输出如下
1s1 addr: 0x1048507c0, size: 0
2s2 addr: 0x1048507c0, size: 0
3s3 addr: 0x1048507c0, size: 0
4s1 == s2 == s3: true
由上可得:
- 多个空结构体内存地址相同。
- 空结构体占用字节数为 0,即不占用内存空间。
- 空结构体之间的值相等。
但是,对于结论 1,有一些特殊情况。
1func main() {
2
3 var (
4 a struct{}
5 b struct{}
6 c struct{}
7 d struct{}
8 )
9
10 println("&a:", &a)
11 println("&b:", &b)
12 println("&c:", &c)
13 println("&d:", &d)
14
15 println("&a == &b:", &a == &b)
16 x := &a
17 y := &b
18 println("x == y:", x == y)
19
20 fmt.Printf("&c(%p) == &d(%p): %t\n", &c, &d, &c == &d)
21}
这段代码中定义了 4 个空结构体,依次打印它们的内存地址,然后又分别对比了 a
与 b
的内存地址,和 c
与 d
的内存地址两两是否相等。输出结果如下:
1go run -gcflags='-m -N -l' main.go
使用 -gcflags
选项向 Go 编译器传递标志,这些标志会影响编译器的行为。
-m
标志用于启动编译器的内存逃逸分析。
-N
标志用于禁用编译器优化。
-l
标志用于禁用函数内联。
1# command-line-arguments
2./main.go:12:3: moved to heap: c
3./main.go:13:3: moved to heap: d
4./main.go:26:12: ... argument does not escape
5./main.go:26:50: &c == &d escapes to heap
6&a: 0x1400011ae84
7&b: 0x1400011ae84
8&c: 0x1048987e0
9&d: 0x1048987e0
10&a == &b: false
11x == y: true
12&c(0x1048987e0) == &d(0x1048987e0): true
根据输出可以发现,变量 c 和 d 发生了内存逃逸,并且最终二者的内存地址相同,相等比较结果为 true。
而 a 和 b 两个变量的输出结果就比较有意思了,两个变量没有发生内存逃逸,并且二者打印出来的内存地址相同,但内存地址相等比较结果却为 false。
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. Size and alignment guarantees¶
因此,正确结论为:「多个空结构体内存地址可能相同」。
空结构体影响内存对齐
空结构体也并不是什么时候都不会占用内存空间,比如空结构体作为另一个结构体字段时,根据位置不同,可能因内存对齐原因,导致外层结构体大小不一样:
1func main() {
2
3 type A struct {
4 x int
5 y string
6 z struct{}
7 }
8
9 type B struct {
10 x int
11 z struct{}
12 y string
13 }
14
15 type C struct {
16 z struct{}
17 x int
18 y string
19 }
20
21 a := A{}
22 b := B{}
23 c := C{}
24 fmt.Printf("struct a size: %d\n", unsafe.Sizeof(a))
25 fmt.Printf("struct b size: %d\n", unsafe.Sizeof(b))
26 fmt.Printf("struct c size: %d\n", unsafe.Sizeof(c))
27}
执行示例代码,输出结果如下:
1struct a size: 32
2struct b size: 24
3struct c size: 24
由上可得,当空结构体放在另一个结构体最后一个字段时,会触发内存对齐。
此时外层结构体会占用更多的内存空间,所以如果你的程序对内存要求比较严格,则在使用空结构体作为字段时需要考虑这一点。
空结构体常见用法
实现 Set
结构体最常用的地方,就是用来实现 set(集合) 类型。
Go 语言在语法层面没有提供 set 类型。不过我们可以很方便的使用 map + struct{} 来实现 set 类型,代码如下:
1// Set 基于空结构体实现 set
2type Set map[string]struct{}
3
4// Add 添加元素到 set
5func (s Set) Add(element string) {
6 s[element] = struct{}{}
7}
8
9// Remove 从 set 中移除元素
10func (s Set) Remove(element string) {
11 delete(s, element)
12}
13
14// Contains 检查 set 中是否包含指定元素
15func (s Set) Contains(element string) bool {
16 _, exists := s[element]
17 return exists
18}
19
20// Size 返回 set 大小
21func (s Set) Size() int {
22 return len(s)
23}
24
25// String implements fmt.Stringer
26func (s Set) String() string {
27 format := "("
28 for element := range s {
29 format += element + " "
30 }
31 format = strings.TrimRight(format, " ") + ")"
32 return format
33}
使用 map 和空结构体,可以非常容易实现 set 类型。map 的 key 实际上与 set 不重复的特性刚好一致,一个不需要关心 value 的 map 即为 set。
空结构体类型最适合作为这个不需要关心的 value 的 map 了,因为它不占空间,没有语义。
也许有人会认为使用 any 作为 map 的 value 也可以实现 set。但 any 是会占用内存空间。
1func main() {
2 s := make(map[string]any)
3 s["t1"] = nil
4 s["t2"] = struct{}{}
5 fmt.Printf("set t1 value: %v, size: %d\n", s["t1"], unsafe.Sizeof(s["t1"]))
6 fmt.Printf("set t2 value: %v, size: %d\n", s["t2"], unsafe.Sizeof(s["t2"]))
7}
执行示例代码,输出结果如下:
1set t1 value: <nil>, size: 16
2set t2 value: {}, size: 16
可以发现,any
类型的 value
是有大小的,所以并不合适。
日常中,还有另一种 set 的常见用法:
1s := map[string]struct{}{
2 "one": {},
3 "two": {},
4 "three": {},
5}
6for element := range s {
7 fmt.Println(element)
8}
这种用法也比较常见,无需声明一个 set 类型,直接通过字面量定义一个 value 为空结构体的 map,非常方便。
信号通知
空结构体另一个经常使用的方法是与 channel
结合,当作信号来使用:
1func main() {
2 done := make(chan struct{})
3
4 go func() {
5 time.Sleep(1 * time.Second) // 执行一些操作...
6 fmt.Printf("goroutine done\n")
7 done <- struct{}{} // 发送完成信号
8 }()
9
10 fmt.Printf("waiting...\n")
11 <-done // 等待完成
12 fmt.Printf("main exit\n")
13}
这段代码中声明了一个长度为 0
的 channel
,其类型为 chan struct{}
。
然后启动一个 goroutine
执行业务逻辑,主协程等待信号退出,二者使用 channel
进行通信。由于 struct{}
并不占用内存,所以实际上 channel
内部只需要将计数器加一即可,不涉及数据传输,故没有额外内存开销。
在 Go 语言 context
源码中也使用了 struct{}
作为完成信号:
1type Context interface {
2 Deadline() (deadline time.Time, ok bool)
3 // See https://blog.golang.org/pipelines for more examples of how to use
4 // a Done channel for cancellation.
5 Done() <-chan struct{}
6 Err() error
7 Value(key any) any
8}
接口实现
用 struct{}
作为作为接口的实现。
1// Discard is a [Writer] on which all Write calls succeed
2// without doing anything.
3var Discard Writer = discard{}
4
5type discard struct{}
6
7func (discard) Write(p []byte) (int, error) {
8 return len(p), nil
9}
标识符
Go 中的 sync.Pool
其定义如下:
1type Pool struct {
2 noCopy noCopy
3
4 local unsafe.Pointer
5 localSize uintptr
6
7 victim unsafe.Pointer
8 victimSize uintptr
9
10 New func() any
11}
其中, noCopy
属性定义如下:
1type noCopy struct{}
2
3func (*noCopy) Lock() {}
4func (*noCopy) Unlock() {}
noCopy
即为一个空结构体,其实现也非常简单,仅定义了两个空方法。该字段的主要作用是阻止 sync.Pool
被意外复制。
它通过编译器静态分析,来防止结构体被不当复制,以确保正确的使用和内存安全性。
通过 go vet
命令检可以测出 sync.Pool
是否被意外复制。所以,有了 noCopy
这个标记,就代表结构体不可复制。
1package main
2
3type noCopy struct{}
4
5func (*noCopy) Lock() {}
6func (*noCopy) Unlock() {}
7
8func main() {
9 type A struct {
10 noCopy noCopy
11 a string
12 }
13
14 type B struct {
15 b string
16 }
17
18 a := A{a: "a"}
19 b := B{b: "b"}
20
21 _ = a
22 _ = b
23}
使用 go vet
命令检查是否存在意外的结构体复制:
1go vet main.go
2# command-line-arguments
3# [command-line-arguments]
4./main.go:21:6: assignment copies lock value to _: command-line-arguments.A contains command-line-arguments.noCopy
可以发现,go vet
已经检测出我们通过 _ = a
复制了 noCopy
结构体 A
。