“类型断言很慢”是一个流传很广的说法。这篇先给一条基线,再分别量具体类型断言、接口类型断言和类型开关,用汇编看它们各自做了什么,最后看 case 数量与位置对耗时有没有影响。
基线:调用 //go:noinline 空函数(不断言) 中位 1.95 ns/op(最小 1.94 / 最大 1.97) x.(A) 命中 中位 2.02 ns/op(最小 1.94 / 最大 3.39) v, ok := x.(A) 命中 中位 1.97 ns/op(最小 1.95 / 最大 2.01) v, ok := x.(A) 未命中(装的是 H) 中位 1.98 ns/op(最小 1.97 / 最大 2.05) x.(Mer) 命中(接口类型断言) 中位 2.33 ns/op(最小 2.30 / 最大 2.39) m, ok := x.(Mer) 命中 中位 2.40 ns/op(最小 2.39 / 最大 2.42) m, ok := x.(Other) 未命中 中位 2.10 ns/op(最小 2.09 / 最大 2.11) switch 2 个具体类型:命中第 1 个 中位 1.98 ns/op(最小 1.97 / 最大 2.00) switch 8 个具体类型:命中第 1 个(A) 中位 1.98 ns/op(最小 1.97 / 最大 1.99) switch 8 个具体类型:命中第 8 个(H) 中位 1.99 ns/op(最小 1.97 / 最大 2.06) switch 2 个接口类型:命中第 2 个 中位 2.36 ns/op(最小 2.35 / 最大 2.37) AllocsPerRun:断言命中 0 次,未命中 0 次
单次断言的耗时已经小到和调用本身的开销混在一起,所以没有基线的“xx 纳秒”没有意义。这里每个函数都是 //go:noinline,基线就是调用一个空函数。需要诚实地说明的是:这些数字是紧循环里装在 any 里的固定值,分支预测与缓存都很理想,真实程序里的值会更分散(上一篇看到类型随机时接口调用从约 2 ns 变成 7 ns)。
main.assertConcrete:
(<gtmp>/ta2/main.go:36) CMP R16, RSP
(<gtmp>/ta2/main.go:36) MOVD $type:main.A(SB), R3
(<gtmp>/ta2/main.go:36) CMP R3, R0
(<gtmp>/ta2/main.go:36) MOVD $type:interface {}(SB), R2
(<gtmp>/ta2/main.go:36) CALL runtime.panicdottypeE(SB)
main.assertConcreteOK:
(<gtmp>/ta2/main.go:40) MOVD $type:main.A(SB), R2
(<gtmp>/ta2/main.go:40) CMP R2, R0
main.assertIfaceOK:
(<gtmp>/ta2/main.go:50) CMP R16, RSP
(<gtmp>/ta2/main.go:51) CBZ R0, 60
(<gtmp>/ta2/main.go:51) CBZ R0, 88
(<gtmp>/ta2/main.go:52) CALL (R2)
(<gtmp>/ta2/main.go:51) CMP R7, R0
(<gtmp>/ta2/main.go:51) CBNZ R7, 104
(<gtmp>/ta2/main.go:51) CALL runtime.typeAssert(SB)
main.switch2:
(<gtmp>/ta2/main.go:67) CBZ R0, 100
(<gtmp>/ta2/main.go:67) CMPW R3, R2
(<gtmp>/ta2/main.go:68) MOVD $type:main.A(SB), R2
(<gtmp>/ta2/main.go:68) CMP R2, R0
(<gtmp>/ta2/main.go:67) CMPW R3, R2
(<gtmp>/ta2/main.go:70) MOVD $type:main.B(SB), R2
(<gtmp>/ta2/main.go:70) CMP R2, R0
main.switchIface:
(<gtmp>/ta2/main.go:100) CMP R16, RSP
(<gtmp>/ta2/main.go:101) CBZ R0, 60
(<gtmp>/ta2/main.go:101) CMP R8, R0
(<gtmp>/ta2/main.go:101) CBNZ R8, 76
(<gtmp>/ta2/main.go:101) CALL runtime.interfaceSwitch(SB)
(<gtmp>/ta2/main.go:101) CBZ R0, 184
(<gtmp>/ta2/main.go:101) CMP $1, R0
(<gtmp>/ta2/main.go:105) CALL (R2)
(<gtmp>/ta2/main.go:103) CALL (R2)具体类型断言在汇编里就是一次比较:把类型字与 type:main.A 的地址比较,v, ok := 形式甚至没有任何调用。接口类型断言则要判断一个动态类型是否实现了某个接口,这件事交给 runtime.typeAssert(调用之前还有几条比较和跳转,我没有逐条核对它们在做什么)。类型开关在汇编里先比较类型的 hash(CMPW),命中再比较类型地址确认;含接口类型的 case 走 runtime.interfaceSwitch。
switch8 命中第 1 个 中位 1.96 ns/op(最小 1.96 / 最大 1.97) switch8 命中第 2 个 中位 1.96 ns/op(最小 1.96 / 最大 1.97) switch8 命中第 3 个 中位 1.96 ns/op(最小 1.96 / 最大 1.97) switch8 命中第 4 个 中位 1.97 ns/op(最小 1.96 / 最大 1.97) switch8 命中第 5 个 中位 1.96 ns/op(最小 1.96 / 最大 1.97) switch8 命中第 6 个 中位 1.96 ns/op(最小 1.96 / 最大 1.97) switch8 命中第 7 个 中位 1.97 ns/op(最小 1.96 / 最大 2.05) switch8 命中第 8 个 中位 1.97 ns/op(最小 1.95 / 最大 1.97) switch8 全部未命中(装 int) 中位 2.01 ns/op(最小 2.01 / 最大 2.02)
8 个具体类型的 switch 命中每一个都是同样的耗时,说明编译器没有把它编译成从上到下逐个比较;汇编里能看到的是 hash 比较(CMPW),至于具体用二分还是跳转表,我没有逐条核对。结论只限于这个实验:在 8 个 case 内,case 的位置不影响耗时。