跳至正文
-
关注
IT极限技术分享汇

ITmax

IT极限技术分享汇

ITmax

  • 首页
  • 商店
  • 我的帐户
  • 结账
  • 购物车
  • 首页
  • 商店
  • 我的帐户
  • 结账
  • 购物车
关

搜索

技术分享

深入理解 Go 的 struct{}

作者 product
2026年9月7日 2 分钟阅读
0

在 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{} 值本身不需要保存任何数据,但围绕它运行的容器、接口和同步机制仍然存在成本。

https://segmentfault.com/a/1190000048125256

作者

product

关注我
其他文章
上一个

阿里云企业专享版镜像仓库拉取速度实测——解决 k8s 一直卡 pulling image 的问题

下一个

我发现私域运营最大的误区:天天想着转化,却忘了如何赢得客户信任

暂无评论!成为第一个。

发表回复 取消回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Copyright 2026 — IT极限技术分享汇. 备案号:浙ICP备17006560号-2