🤔 什么是零尺寸类型(ZST)?
零尺寸类型(Zero-Sized Type, ZST)是指不占用任何内存的类型。常见例子包括:
struct{} // 空结构体
[0]int // 长度为 0 的数组
NotImplementedError{} // 自定义空 struct
它们常用于:
- 信号通道:
chan struct{} - 集合模拟:
map[string]struct{} - 错误类型:
type ErrNotFound struct{}
看起来人畜无害?但一旦你对它取地址(&),事情就变得诡异了。
🧪 一个“看似相同”的 bug
看下面这段代码
type NotImplementedError struct{}
func (*NotImplementedError) Error() string {
return "internal not implemented"
}
func Translate1(err error) error {
if err == &NotImplementedError{} { // ⚠️ 指针比较!
return errors.ErrUnsupported
}
return nil
}
func Translate2(err error) error {
if err == &NotImplementedError{} {
return errors.ErrUnsupported
}
return err // ← 关键区别!
}
func DoWork() error {
return &NotImplementedError{}
}
func main() {
fmt.Printf("translate1: %v\n", Translate1(DoWork())) // unsupported operation
fmt.Printf("translate2: %v\n", Translate2(DoWork())) // internal not implemented
}
❓ 问题来了:两个函数逻辑几乎一样,为什么输出不同?
答案藏在 “指针逃逸” + “零尺寸优化” 里。
🔍 背后发生了什么?
1. 指针逃逸(Escape Analysis)
当你写 return err(如 Translate2),编译器认为 err 可能被外部使用,于是把它 “逃逸到堆上”。
而 &NotImplementedError{} 这个字面量,在函数内部没逃逸,就留在 栈上。
2. Go 的“零尺寸魔法”
Go 对堆上的零尺寸类型做了优化:所有堆分配的 ZST 共享同一个地址 —— runtime.zerobase。
但栈上的 ZST 地址是独立的(即使内容相同)。
所以:
Translate1:两个 ZST 都在栈上 → 可能地址相同 → 比较成功 ✅Translate2:一个在堆(err),一个在栈(字面量)→ 地址不同 → 比较失败 ❌
📌 Go 官方明确说:
“指向不同零尺寸变量的指针,可能相等,也可能不相等。”
—— 这不是 bug,这是未定义行为!
🎯 为什么 errors.Is 也不保险?
你可能会说:“我用 errors.Is 啊!”
if errors.Is(err, &NotImplementedError{}) { ... }
但 errors.Is 内部首先尝试直接指针比较!如果两个 ZST 指针地址不同,它就认为“不匹配”。
更糟的是:如果两个 ZST 都逃逸到堆,它们都指向 zerobase,反而会“错误匹配”!
💡 举个极端例子:
你有两个不同的错误类型ErrA{}和ErrB{},但如果都用&ErrA{}和&ErrB{}且都逃逸,它们可能指向同一个地址!errors.Is会误判!
✅ 正确姿势:别用指针比较 ZST!
✅ 方案 1:用哨兵错误(Sentinel Error)
var ErrNotImplemented = &NotImplementedError{}
func Translate(err error) error {
if errors.Is(err, ErrNotImplemented) {
return errors.ErrUnsupported
}
return err
}
✅ 所有地方用同一个实例,地址固定,安全可靠!
✅ 方案 2:让错误类型非零尺寸
type NotImplementedError struct{ _ int } // 加一个匿名字段
现在它占 8 字节,每个 &NotImplementedError{} 都是独立地址,指针比较有意义。
⚠️ 但通常没必要,哨兵错误更简洁。
✅ 方案 3:用值接收者 + 值比较
type NotImplementedError struct{}
func (NotImplementedError) Error() string { // 值接收者
return "not implemented"
}
// 比较时用 errors.As 或类型断言
if _, ok := err.(NotImplementedError); ok {
// ...
}
✅ 值比较是确定的,不会受地址影响。
🛠️ 工具推荐:zerolint
这个静态分析工具专门检测 ZST 指针陷阱:
go install fillmore-labs.com/zerolint@latest
zerolint .
它会警告:
- 指针接收者用于 ZST
- 用
&ZST{}做错误比较 - 嵌入指针到 ZST
🎯 在 CI 中加入
zerolint,提前拦截“玄学 bug”!
🧱 实战场景:嵌入 ZST 到 struct
✅ 正确:值嵌入(零开销)
type ReadOnlyFS struct {
afero.OsFs // 值嵌入,OsFs 是 ZST → 总大小仍是 0!
}
func (ReadOnlyFS) Remove(name string) error {
return ErrInvalidOperation
}
❌ 错误:指针嵌入(8 字节 + nil 风险)
type ReadOnlyFS struct {
*afero.OsFs // 指针嵌入 → 占 8 字节,且默认为 nil!
}
// 调用 a.Open() 会 panic!
📌 gRPC-Go 就用
UnimplementedServer struct{}值嵌入,避免 nil 指针!
🎉 结语:ZST 是好东西,但别乱用指针!
| 做法 | 是否推荐 | 原因 |
|---|---|---|
&ZST{} 用于错误比较 | ❌ | 地址不确定,行为不可靠 |
| 哨兵错误(全局变量) | ✅ | 地址固定,安全 |
| ZST 值嵌入 struct | ✅ | 零内存开销 |
| ZST 指针嵌入 struct | ❌ | 浪费 8 字节 + nil 风险 |
| 方法用值接收者 | ✅ | 避免指针歧义 |
记住:
ZST 作为值是天使,作为指针是魔鬼。
用对了,性能飞升;用错了,bug 难寻。
