深入理解 Go 的 struct{}
在 Go 代码中,我们经常看到下面两种写法:
set := make(map[string]struct{})
done := make(chan struct{})
它们都使用了空结构体 struct{}。我们通常说:
struct{} 不占用任何空间。
更准确地说,struct{} 是一个大小为 0、不携带任何状态的合法类型。但包含它的指针、接口、切片、map 和 channel 仍然可能产生内存与运行时开销。
1. struct{} 为什么是零大小
struct{} 是一个没有任何字段的匿名结构体:
var value struct{}
由于没有字段需要存储,它的大小为 0:
package main
import (
"fmt"
"unsafe"
)
func main() {
fmt.Println(unsafe.Sizeof(struct{}{})) // 0
}
还可以给空结构体定义一个具名类型:
type Empty struct{}
它具有以下特点:
- 不包含任何字段
- 零值为
struct{}{} - 大小为 0
- 属于可比较类型
- 所有 struct{} 值都相等
- 可以定义方法
a := struct{}{}
b := struct{}{}
fmt.Println(a == b) // true
因为结构体按照字段逐一比较,而空结构体没有任何字段,所以不存在不相等的情况。
2. 零大小不等于零成本
空结构体本身不占空间,但承载它的数据结构仍然有成本:
var value struct{}
var pointer *struct{}
var iface any = struct{}{}
var slice []struct{}
var channel chan struct{}
var mapping map[string]struct{}
在常见的 64 位系统中:
fmt.Println(unsafe.Sizeof(value)) // 0
fmt.Println(unsafe.Sizeof(pointer)) // 通常为 8
fmt.Println(unsafe.Sizeof(iface)) // 通常为 16
fmt.Println(unsafe.Sizeof(slice)) // 通常为 24
fmt.Println(unsafe.Sizeof(channel)) // 通常为 8
fmt.Println(unsafe.Sizeof(mapping)) // 通常为 8
这些数字与具体平台和实现有关,但可以说明一个问题:
struct{}的值不保存数据,不代表管理这个值的容器也没有成本。
例如,map[T]struct{} 仍然需要存储 key、哈希桶和运行时元数据;chan struct{} 仍然涉及锁、等待队列和 goroutine 调度。
3. 零大小对象的地址
不同的零大小变量可能拥有相同地址:
a := struct{}{}
b := struct{}{}
fmt.Printf("%p/n", &a) // 0x104bdeb20
fmt.Printf("%p/n", &b) // 0x104bdeb20
Go 规范允许不同的零大小变量拥有相同地址。编译器没有必要为不保存数据的对象分配独立空间。
因此,下面的逻辑是不可靠的:
type Token struct{}
a := &Token{}
b := &Token{}
// 不要通过零大小对象的地址判断对象身份
if a == b {
// ...
}
// 如果对象需要身份语义,应增加 ID 或非零大小字段:
type Token struct {
ID uint64
}
4. 空结构体数组和切片
由空结构体构成的数组也可能是零大小:
var values [1_000_000]struct{}
fmt.Println(unsafe.Sizeof(values)) // 0
fmt.Println(len(values)) // 1000000
数组长度属于类型信息,元素数量很大不代表需要为每个空结构体分配空间。
切片则不同:
values := make([]struct{}, 1_000_000)
每个元素不需要保存数据,但切片头仍然需要保存:
- 底层数据指针
- 长度
- 容量
同时,遍历仍然会执行一百万次:
for range values {
// 仍然执行一百万次
}
所以,零存储成本不代表零计算成本。
5. 使用 map[T]struct{} 实现 Set
Go 中经常使用 map[T]struct{} 表示集合:
languages := map[string]struct{}{
"Go": {},
"Java": {},
}
languages["Rust"] = struct{}{}
if _, exists := languages["Go"]; exists {
fmt.Println("Go exists")
}
delete(languages, "Java")
为什么不用 map[T]bool ?
values := map[string]bool{
"Go": false,
}
此时 “Go” 作为 key 是存在的,但 value 又是 false,语义上容易产生歧义。
map[T]struct{} 表达得更加准确:
只关心 key 是否存在,不需要保存额外状态。
泛型 Set 可以简单封装为:
type Set[T comparable] map[T]struct{}
func (s Set[T]) Add(value T) {
s[value] = struct{}{}
}
func (s Set[T]) Contains(value T) bool {
_, exists := s[value]
return exists
}
func (s Set[T]) Remove(value T) {
delete(s, value)
}
6. 使用 chan struct{} 传递信号
当 channel 只负责通知事件、不需要传递数据时,可以使用:
done := make(chan struct{})
单次通知
go func() {
done <- struct{}{}
}()
<-done
广播通知
关闭 channel 可以唤醒所有等待接收的 goroutine:
done := make(chan struct{})
for i := 0; i < 3; i++ {
go func() {
<-done
fmt.Println("stopped")
}()
}
close(done)
这也是 context.Context 的 Done 方法返回 <-chan struct{} 的原因:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-results:
return handle(result)
}
Done() 只负责通知取消事件,取消原因通过 ctx.Err() 获取。
需要注意:
- 重复关闭 channel 会 panic
chan struct{}仍然有同步和调度成本- 复杂的任务取消通常应优先使用 context.Context
7. 使用空结构体定义无状态类型
空结构体也可以作为只有行为、没有状态的类型:
type JSONEncoder struct{}
func (JSONEncoder) Encode(value any) ([]byte, error) {
return json.Marshal(value)
}
它适合用于:
- 实现接口
- 表达无状态策略
- 将相关行为组织到一个类型中
type Encoder interface {
Encode(value any) ([]byte, error)
}
var encoder Encoder = JSONEncoder{}
虽然 JSONEncoder{} 本身大小为 0,但放入接口后,接口仍然需要保存动态类型和值相关信息,因此接口变量并不是零大小。
8. 零大小字段也可能影响内存布局
下面两个结构体只是字段顺序不同:
type A struct {
Empty struct{}
Value int64
}
type B struct {
Value int64
Empty struct{}
}
在某些 Go 编译器实现和架构中,位于末尾的零大小字段可能使外层结构体增加填充空间。
因此:
struct{} 自身大小为 0,不代表加入外层结构体后一定不会改变其大小。
如果对象数量很大并且确实关心内存布局,可以通过 unsafe.Sizeof 在目标架构上验证。通常可优先把零大小字段放在结构体前部,但不要在没有 benchmark 或 profile 数据的情况下过度优化。
9. 常见误区
map[T]struct{} 完全不占内存
错误。只有 value 不携带数据,map 仍然需要保存 key、哈希桶和元数据。
chan struct{} 没有成本
错误。channel 仍然涉及同步、等待队列和 goroutine 调度。
不同空结构体对象一定有不同地址
错误。不同零大小变量可能拥有相同地址。
struct{} 一定比 bool 更快
不一定。内存占用和运行速度是两个问题,实际性能应通过 benchmark 验证:
go test -bench=. -benchmem
总结
struct{} 是一个不携带状态、大小为 0 的 Go 类型,常见用途包括:
- 使用
map[T]struct{}表示集合 - 使用
chan struct{}传递通知或取消信号 - 定义不保存状态的行为类型
- 表达只关心“存在”,不关心“数据”的语义
但需要记住:
- 零大小不等于零运行时成本
- 容器和接口仍然占用空间
- 零大小对象的地址不能用于表达身份
- 零大小字段仍可能影响外层结构体布局
struct{}值本身不需要保存任何数据,但围绕它运行的容器、接口和同步机制仍然存在成本。