有些 bug 只存在于"代码是否正确"和"功能是否生效"之间的那道缝里。一个功能上线将近一个月,代码安安静静地待在那里,测试也测不出来,可它从来没有真正起过作用。
现象
某个功能上线后,相关的分数(评分、权重、标签)一直没有任何变化,跟没上线时一模一样。翻代码:函数在、调用在、逻辑也看着对。可它就是不生效——所有该被调整的分数,一个都没动过。
更诡异的是:同一个函数的另一个调用方是正常工作的。也就是说,这个功能"只有一半是死的"。
根因
这是一类典型的"接线 bug"——功能本身写对了,但接到了错误的线路上。
出问题的调用处,传入的是一个 A 类对象的字典,而目标函数期望的却是 B 类对象的字典。要命的是,这两类对象恰好有同一个键名。于是函数按这个键去取值时,取到的是 B 类里碰巧存在的那个同名字段(或者取不到、返回 None),结果整段逻辑的返回值恒为 None,整段代码成了死代码。
大致是这样:
def adjust_score(table):
code = table.get("code") # 期望 B 类:code -> 行业代码
if code in WEIGHTS: # A 类里也有 "code",但含义不同
return apply_weight(...) # 永远进不来 / 或算错
return None
调用方把 A 类字典传了进来,键名一样,取值不报错,逻辑却走进了错误的分支。同一个函数的另一个调用方传的是正确的 B 类字典,所以走的是对的路径——这就是"一半活、一半死"的由来。
最要命的是,静态检查抓不到它。 因为类型在运行时才确定,同一个函数里同名变量可能先后指向两种不同的字典,静态分析器无法判断哪一次传错了。
解决
这类 bug 靠读代码几乎发现不了,必须实际跑一遍,看它有没有真的触发。
第一步,加"生效性断言"。 不要只断言"函数存在",要断言"功能确实改变了输出"。例如:
before = compute_scores(data)
run_feature(data)
after = compute_scores(data)
assert after != before, "功能未生效:分数没有任何变化!"
把这样的断言放进测试或脚本自检里,功能一旦变成死代码,断言会立刻失败。
第二步,让"接线错误"无处可藏。 短期内可以在函数入口显式校验类型或必填字段,用 哨兵值 代替 None 兜底:
_SENTINEL = object()
def adjust_score(table):
code = table.get("code", _SENTINEL)
if code is _SENTINEL:
raise ValueError("adjust_score 收到的是错误类型的字典")
...
这样传错对象时会直接报错,而不是静默返回 None。
第三步,统一数据模型。 治本的做法是别再用"裸字典 + 约定键名"来传递不同种类的对象——两种东西长着一样的键名,正是这场事故的温床。改用具有显式字段名的数据结构(dataclass / 具名类),传错类型时要么类型检查器报错,要么运行时报 AttributeError,问题当场暴露。
延伸与预防
这条坑最有价值的一句总结是:改了代码 ≠ 功能生效;判断一个功能是否活着,要看它的输出,而不是它的存在。
落到工程习惯上有几条:
- 断言要断言"效果",不要断言"存在"。
assert hasattr(obj, 'foo')只能证明代码写了;assert output_changed才能证明它跑了。 - 警惕"同名不同物"。 两种对象共用一个键名(或字段名)时,是静默失效的高发区。命名上能区分就区分,别让 A 类的
code和 B 类的code混在一起。 - 用
None当失败信号是最糟的设计之一。 "正常返回空"和"出错了"都变成None,等于主动放弃线索。用一个独立的哨兵对象或抛异常,能区分这两者。 - 上线后加观测。 功能是否被真正调用、调用时命中了哪个分支,用日志或计数器记录下来。一个"上线后从没触发过的功能",如果有观测数据,第二天就能发现,而不是拖一个月。
- 测试要覆盖"接线"。 单元测试测的是函数内部逻辑,接线错误在函数之外——所以要补一层"集成级"检查,真正把数据喂进去跑通链路,看结果有没有变。