隐形 Bug 类:代码正确却永远不执行
分析一类特殊的生产 Bug——代码逻辑正确、单元测试通过但在真实流程中从不执行的情况。结合实际案例揭示根本原因和识别方法。
分析一类特殊的生产 Bug——代码逻辑正确、单元测试通过但在真实流程中从不执行的情况。结合实际案例揭示根本原因和识别方法。
在我们的一个 Agent 中,绝大多数真正的缺陷——我回头分类后发现,九个里面有八个——都属于同一类。不是差一错误,不是竞态条件,也不是写错了正则表达式。
这类问题是:代码写对了,测试也做对了,但它从未在真正关键的路径上执行。
在监控面板上,它们每一个看起来都很正常。每一个都有通过的单元测试。测试之所以能通过,是因为它们直接调用了函数,而函数本身没有问题。有问题的是连接各部分的 wiring。
我们会发布预测区间。之前某个时候,我们加入了条件化机制:不再读取涵盖所有市场状况的历史分位数,而是将样本筛选为与当前状况相似的时期。这一点非常重要——无条件区间会永久计入一部分应对市场动荡的余量,而平静的市场并不需要这部分余量。
代码已经写好了。代码是正确的。它还有一个开关:
useConditioning = input.bool(false, "Condition on volatility regime")
这个 false 就是整个 bug。
机制已经存在,测试也全部通过,但它从未真正运行。连续几周,我们发布的每张图表绘制的都是无条件区间,而文档描述的却是条件化区间。没有任何报错,也没有任何东西看起来不对。实际覆盖率达到了 84%,而我们声明的是 50%。由于过度覆盖不会产生失败——每个结果都会落在过宽的区间内——因此根本没有值得追查的症状。
默认值就是一条代码路径。请把它当作代码路径来对待。
这个问题与具体语言有关,但它的模式具有普遍性,而且更加棘手,因为你找不到任何开关。
有些函数会在多次调用之间保留内部状态。移动平均线、相关系数,以及任何维护滚动窗口的东西都是如此。在 Pine Script 中,这类函数属于 ta.* 系列;但只要存在有状态的辅助函数,并且它假定自己每个 tick 都会被调用一次,这种模式就会出现。
写成下面这样,看起来完全没问题:
ma(src, len, kind) =>
kind == "EMA" ? ta.ema(src, len) : ta.sma(src, len)
它能编译,也会返回看似合理的数字。但它是错的。
每根 bar 只会执行一个分支,因此两个移动平均线中只有一个会推进其内部状态。另一个收到的是一段存在空洞的历史数据。它返回的值并不是整个序列的移动平均值,而只是碰巧选中该分支的那些 bar 所构成子集的移动平均值。
修复方法是无条件计算两者,然后再进行选择:
ma(src, len, kind) =>
e = ta.ema(src, len)
s = ta.sma(src, len)
kind == "EMA" ? e : s
计算量略有增加,但答案是正确的。我在同一天、同一个代码库的两个不同位置发现了这种写法。这足以说明,错误版本写起来有多么自然。
同一周还有一个更小的问题。之所以提到它,是因为它补全了整个模式。
我们有一个文本清理工具,用于把排版字符转换成 ASCII,因为某些发布平台会使用旧版代码页解码 UTF-8,导致 em dash 显示成乱码。这个需求本身没问题。但工具中还包含下面这段代码:
while " - " in text:
text = text.replace(" - ", " - ")
它的本意是清理由带空格的 em dash 转换成带空格的连字符后遗留下来的双空格。实际效果却是破坏它所处理的每个文件中用于对齐的缩进。以检查模式运行时,它会对源文件报告误报;以修复模式运行时,它则会悄无声息地破坏这些文件。
对于编写这条规则时考虑的场景,它是正确的;对于其他所有输入,它都是错误的。一个全局运行的清理步骤并不是清理步骤,而是一种转换。
因为单元测试会直接调用函数。
针对条件化逻辑的测试会导入条件化函数,向它传入数据,然后断言输出结果。测试通过了,但它完全没有说明生产环境是否真的会执行到这个函数。针对 ma() 的测试使用 kind="EMA" 调用 ma(),并得到正确的 EMA,因为在这项测试中,每次调用都会进入 EMA 分支,其状态也能正常推进。
缺陷存在于组件之间的关系中,而单元测试在设计上恰恰不会关注这些关系。通常来说,这是一种优点。但在这里,它成了盲区。
有三种方法,下面按照它们为我们带来回报的多少排序。
审查调用图,而不是孤立的组件。真正有效的问题不是“这段代码正确吗”,而是“它会在什么条件下执行,我是否在这些条件下验证过它”。对于你关心的每个函数,都要从它反向追踪到入口点,并检查在生产配置下,这条路径是否可达。
把每个默认值都当成一次决策。任何 flag、任何可选参数、任何 if enabled 分支都是如此。写清楚在没有人修改任何设置时究竟会运行什么,因为实际运行的就是它。
对可观察的输出做断言,而不是对内部实现做断言。如果我们当时有一个检查,将发布区间的宽度与条件化区间的宽度进行比较,并在两者相同时发出警告,那么条件化 bug 一天之内就会被发现。这个检查非常简单,但我们没有做,因为我们知道代码是正确的。
如果你只打算记住一件事,那就是:在 flag 后面添加功能后,用 grep 搜索这个 flag,并阅读引用它的每一行代码。目的不是检查逻辑,而是确认生产环境确实设置了它。
这个习惯只需要两分钟,却原本可以避免我们连续几周发布那些悄无声息地采用了另一种计算方式、与文档描述不符的数据。
bug 从来不在代码本身。它存在于“写出来就意味着运行起来”这一假设之中。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。