每次 s[i] 都要检查 i 是否在范围内,编译器会尽量证明“一定在范围内”并去掉检查。这篇不靠猜测,直接看汇编里还剩多少个 panic 出口:哪些写法能让检查消失,哪些提示写法有效、哪些反而没用,以及检查到底占多少成本。
rangeLoop (没有 panic 调用点) rangeValue (没有 panic 调用点) lenLoop (没有 panic 调用点) unknownN 1 处 CALL runtime.panicBounds hintBefore 2 处 CALL runtime.panicBounds hintUnguarded 1 处 CALL runtime.panicBounds clampN (没有 panic 调用点) resliceFirst 1 处 CALL runtime.panicBounds fourConst 4 处 CALL runtime.panicBounds fourConstHint 1 处 CALL runtime.panicBounds maskIndex (没有 panic 调用点) modIndex 1 处 CALL runtime.panicBounds uintIndex (没有 panic 调用点) twoIdx (没有 panic 调用点) reverse (没有 panic 调用点) backwards (没有 panic 调用点) sliceExpr 2 处 CALL runtime.panicBounds fromData 1 处 CALL runtime.panicBounds
失败的边界检查在汇编里是一个对 runtime.panicBounds 的调用点,数调用点就知道还剩几处检查(-d=ssa/check_bce/debug=1 也能列出,但它的行号归属不如汇编直观,所以这里用汇编)。每个被测函数都加了 //go:noinline,避免内联后被调用点的常量把检查消掉。
编译器的证明基于“下标的取值范围”:range 与 i < len(s) 天然在范围内;i&7 用在长度为 8 的数组上、先判断 uint(i) < uint(len(s))、倒序到 0 也都能被证明。clampN(先把 n 夹到 len(s))让循环里的检查消失,是比较稳妥的写法。
在循环前写 _ = s[3] 能把后面几个常量下标的检查合并成这一处(4 处变 1 处)。但提示要写对:不带 if 的 _ = s[n-1] 让循环里的检查消失;带 if n > 0 守卫的版本反而留下 2 处(守卫让编译器没能把“n ≤ len(s)”用于整个循环)。这是对实验现象的描述,编译器内部的证明规则我没有读源码。
默认编译:
range 遍历(没有检查) 中位 296.53 ns/op(最小 296.13 / 最大 301.88)
i < n,n 来自参数(每次迭代检查) 中位 296.91 ns/op(最小 296.56 / 最大 299.94)
先 _ = s[n-1] 再循环 中位 298.35 ns/op(最小 297.69 / 最大 301.59)
下标来自另一个切片(无法消除) 中位 312.35 ns/op(最小 309.04 / 最大 312.98)
-gcflags=-B(关闭边界检查):
range 遍历(没有检查) 中位 296.07 ns/op(最小 295.94 / 最大 299.31)
i < n,n 来自参数(每次迭代检查) 中位 296.13 ns/op(最小 295.76 / 最大 299.80)
先 _ = s[n-1] 再循环 中位 296.12 ns/op(最小 295.84 / 最大 299.10)
下标来自另一个切片(无法消除) 中位 309.91 ns/op(最小 309.06 / 最大 312.05)在顺序读的求和循环里,默认编译与 -B(关掉所有边界检查)的差别只有 0.3% 到 0.8%:检查是一个几乎总是不跳转的比较,分支预测很准。所以不要为了省检查把代码写得难懂;只有热循环里每个元素都做很少事、而且编译器没能消除检查时才值得去调整。
runtime error: index out of range [5] with length 3
越界时仍然是运行时 panic,消息里写明下标与长度。消除检查不改变语义:只有编译器能证明下标一定在范围内时才会去掉检查。