很多 Go 泛型库会在一个看似"空"的结构体里塞一个很奇怪的字段:0 长度数组 ,元素类型是 函数且带类型参数。这不是炫技,而是在用编译器帮你"堵住误用"。
1. 先从一个真实需求出发:可插拔的"比较策略"
假设你在写一个泛型工具:对切片做去重、查找、或比较;你希望用户可以自定义"怎么判断两个元素相等"。
go
package main
type Eq[T any] interface {
Equal(a, b T) bool
}
type Finder[T any, E Eq[T]] struct {
eq E
}
func (f Finder[T, E]) IndexOf(xs []T, target T) int {
for i, v := range xs {
if f.eq.Equal(v, target) {
return i
}
}
return -1
}
你还想给一个默认实现:当 T 可比较时,直接用 ==。
go
type DefaultEq[T comparable] struct{}
func (DefaultEq[T]) Equal(a, b T) bool { return a == b }
到这里看起来很完美,对吧?但实际上它埋了两个"类型安全"层面的坑。
2. 坑 A:不同 T 的默认策略"长得一样",可能引发误用
DefaultEq[int] 和 DefaultEq[string] 在内存布局上都是空结构体 :struct{}。
空结构体最大的特点:没有任何字段,于是很多时候"看起来完全相同"。
你在项目里做大规模泛型封装时,很可能出现这样的情况:
- 你把"默认策略"作为参数、作为字段、作为返回值在很多地方传递
- 有人(包括未来的你)做了一些泛型包装/类型别名/反射/unsafe 或中间层抽象
- 一旦某个环节把"策略类型"当成"只是个空壳",就容易出现"拿错策略也编译过了"或"通过转换绕过去"的情况
直观理解:当一个泛型类型实例化后仍然是空的,类型系统可约束的东西就少了,误用空间就大。
我们想要的是:
让 DefaultEq[int] 和 DefaultEq[string] 在"结构上就不一样",从而尽量把错误挡在编译期。
3. 坑 B:策略对象被比较、被当成 map key ------ 这通常是 bug
策略对象(比如"比较器""哈希器""排序规则")一般只承载行为,不承载数据。
如果它是一个可比较的空结构体,那么下面这些"看起来合理但通常有坑"的写法就能通过编译:
go
// 例:把策略当成 key 来缓存某些结果
// map[DefaultEq[int]]something // 这在"空结构体可比较"的情况下是可行的
更常见的是你写了一个容器/缓存结构,未来某个改动把策略对象塞进 struct,然后有人顺手就 == 比较整个 struct,结果"比较成功"但语义完全不对,bug 非常隐蔽。
我们希望:
策略对象最好不要支持 == 比较,这样一旦有人试图比较就立刻编译失败。
4. 解决方案:放一个"0 字节但类型强绑定"的字段
我们把默认策略改成这样:
go
type SaferDefaultEq[T comparable] struct {
_ [0]func(T)
}
func (SaferDefaultEq[T]) Equal(a, b T) bool { return a == b }
这行字段同时完成两件事:
4.1 [0]...:0 长度数组,不占内存(零运行时成本)
[0]X 的大小永远是 0,不管 X 是什么。
所以这个字段不会让 struct 变大,不会增加分配成本,也不会影响逃逸分析结果------它几乎纯粹是"给类型系统看的"。
4.2 func(T):函数类型不可比较 → struct 也不可比较
在 Go 里:
- 函数值(
func(...))是不可比较类型 - 如果一个 struct 含有不可比较字段,那么这个 struct 也不可比较
于是:
go
var a, b SaferDefaultEq[int]
// _ = (a == b) // 编译错误:该类型不可比较
这就把"策略对象被拿去比较/当 key"这种误用直接扼杀在编译期。
4.3 func(T) 里带 T:把类型参数"烙"进结构里
重点是 func(T) 这个字段类型包含了类型参数 T。
当 T 不同,字段类型也不同:
SaferDefaultEq[int]的字段是[0]func(int)SaferDefaultEq[string]的字段是[0]func(string)
它们在结构层面不再"长得一样",从而更难在中间层被当成可互换的东西(尤其是当你有很多 wrapper、type alias、泛型适配器时,这种"强区分"很值钱)。
5. 用一组"能看懂就懂"的对照实验
5.1 对照:空结构体策略可以比较(常常不想要)
go
type PlainDefaultEq[T comparable] struct{}
func (PlainDefaultEq[T]) Equal(a, b T) bool { return a == b }
func compareStrategy() {
var x, y PlainDefaultEq[int]
_ = (x == y) // 可以编译:但比较它通常没有意义
}
5.2 加上 _[0]func(T) 后:比较直接被禁止
go
type StrictDefaultEq[T comparable] struct {
_ [0]func(T)
}
func (StrictDefaultEq[T]) Equal(a, b T) bool { return a == b }
func compareStrategy2() {
var x, y StrictDefaultEq[int]
// _ = (x == y) // 编译失败:不可比较(更安全)
}
5.3 依然 0 成本:对象大小不变(仍然等于 0)
空结构体大小是 0;加了 [0]func(T) 仍然是 0。
(你可以用 unsafe.Sizeof 验证:两者一般都为 0;放进另一个 struct 时也不会额外占空间------0 长度数组不会贡献布局。)
6. 为什么不直接用其他"绑定 T 的办法"?
你可能会问:我也可以写这些啊:
6.1 _ T ------ 不行,会占空间且需要值
go
type Tag[T any] struct { _ T } // 会占用 T 的大小,完全不零成本
6.2 _ *T ------ 会占一个指针大小
go
type Tag[T any] struct { _ *T } // 通常 8 字节(64 位)
6.3 struct{} ------ 0 成本,但绑定不够"强"
go
type Tag[T any] struct { _ struct{} } // 0 成本,但没把 T 烙进字段类型
而 [0]func(T) 同时满足:
- 0 成本(0 字节)
- 强绑定 T(字段类型直接依赖 T)
- 顺手让 struct 不可比较(因为 func 不可比较)
属于"一个字段,三个收益"。
7. 什么时候你应该用这种写法?
适用场景(很典型):
- 策略/配置/适配器对象:比如 Equal/Hash/Compare/Encode/Decode 策略
- 你不希望它被
==比较:比较往往无意义且易隐藏 bug - 你希望不同类型参数的实例化在类型层面强区分:避免在多层封装里被"当成一样的空壳"
不适用场景:
- 你真的需要比较该类型的值(那就不要让它不可比较)
- 你需要该类型携带真实数据(那就直接加字段,不必玩标签)
8. 一个更完整的"使用姿势"示例(仍然全新)
go
package main
type Eq[T any] interface {
Equal(a, b T) bool
}
type StrictDefaultEq[T comparable] struct {
_ [0]func(T)
}
func (StrictDefaultEq[T]) Equal(a, b T) bool { return a == b }
type Finder[T any, E Eq[T]] struct {
eq E
}
func (f Finder[T, E]) Contains(xs []T, target T) bool {
for _, v := range xs {
if f.eq.Equal(v, target) {
return true
}
}
return false
}
func main() {
f := Finder[int, StrictDefaultEq[int]]{eq: StrictDefaultEq[int]{}}
_ = f.Contains([]int{1, 2, 3}, 2)
}
这个例子里:
- 策略类型是 0 大小(几乎零成本)
- 策略对象不可比较(防误用)
StrictDefaultEq[int]和StrictDefaultEq[string]是强区分的类型实体
9. 一句话总结
_ [0]func(T) 是一种 "零字节字段 + 强类型绑定 + 禁止比较" 的组合技巧。
它用极低的成本换来更强的编译期约束,尤其适合做泛型库里的"默认策略/类型标签/行为适配器"。