Go 泛型中的 [0]func(T)

14 阅读6分钟

很多 Go 泛型库会在一个看似“空”的结构体里塞一个很奇怪的字段:0 长度数组,元素类型是 函数且带类型参数。这不是炫技,而是在用编译器帮你“堵住误用”。


1. 先从一个真实需求出发:可插拔的“比较策略”

假设你在写一个泛型工具:对切片做去重、查找、或比较;你希望用户可以自定义“怎么判断两个元素相等”。

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 可比较时,直接用 ==

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

策略对象(比如“比较器”“哈希器”“排序规则”)一般只承载行为,不承载数据。
如果它是一个可比较的空结构体,那么下面这些“看起来合理但通常有坑”的写法就能通过编译:

// 例:把策略当成 key 来缓存某些结果
// map[DefaultEq[int]]something  // 这在“空结构体可比较”的情况下是可行的

更常见的是你写了一个容器/缓存结构,未来某个改动把策略对象塞进 struct,然后有人顺手就 == 比较整个 struct,结果“比较成功”但语义完全不对,bug 非常隐蔽。

我们希望:
策略对象最好不要支持 == 比较,这样一旦有人试图比较就立刻编译失败。


4. 解决方案:放一个“0 字节但类型强绑定”的字段

我们把默认策略改成这样:

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 也不可比较

于是:

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 对照:空结构体策略可以比较(常常不想要)

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) 后:比较直接被禁止

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 —— 不行,会占空间且需要值

type Tag[T any] struct { _ T } // 会占用 T 的大小,完全不零成本

6.2 _ *T —— 会占一个指针大小

type Tag[T any] struct { _ *T } // 通常 8 字节(64 位)

6.3 struct{} —— 0 成本,但绑定不够“强”

type Tag[T any] struct { _ struct{} } // 0 成本,但没把 T 烙进字段类型

[0]func(T) 同时满足:

  • 0 成本(0 字节)
  • 强绑定 T(字段类型直接依赖 T)
  • 顺手让 struct 不可比较(因为 func 不可比较)

属于“一个字段,三个收益”。


7. 什么时候你应该用这种写法?

适用场景(很典型):

  1. 策略/配置/适配器对象:比如 Equal/Hash/Compare/Encode/Decode 策略
  2. 你不希望它被 == 比较:比较往往无意义且易隐藏 bug
  3. 你希望不同类型参数的实例化在类型层面强区分:避免在多层封装里被“当成一样的空壳”

不适用场景:

  • 你真的需要比较该类型的值(那就不要让它不可比较)
  • 你需要该类型携带真实数据(那就直接加字段,不必玩标签)

8. 一个更完整的“使用姿势”示例(仍然全新)

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) 是一种 “零字节字段 + 强类型绑定 + 禁止比较” 的组合技巧。
它用极低的成本换来更强的编译期约束,尤其适合做泛型库里的“默认策略/类型标签/行为适配器”。