在 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

参考资料