你的静态分析工具还在用过时的规则?
“简单是可靠性的先决条件。” — Edsger W. Dijkstra
你的静态分析工具还在用过时的规则?
一条 C901 红灯,真正要你处理的是控制流,不是函数的页数。
error[C901]: `_process_batch` is too complex (12 > 10)
--> /path/to/repo/module.py:1:1
这不是测试失败,也不是某个处理结果出了问题。它说的是:这个函数的 McCabe 圈复杂度是 12,超过了配置的 10。
先在仓库根目录跑这一条:
ruff check .
如果你看到 C901,先别删注释,别急着给函数加 # noqa,更别把阈值改成“刚好能过”的数字。读完这篇,你能从日志确认它拦住了什么,手算出新增规则为什么让它超线,并在“抽 helper / 局部豁免 / 提高阈值”之间做有根据的选择。
C901 不计算函数占了多少行;它计算控制流给读者留下了多少条可选的路。
先看规则码,别被安装日志带偏
CI 日志经常先刷出一串下载、构建、安装信息。那些通常只说明环境准备完成,不说明业务测试已经开始。真正决定失败位置的是这几项:
| 日志片段 | 它告诉你的事实 | 下一步 |
|---|---|---|
error[C901] |
触发的是 Ruff 的 McCabe 复杂度规则 | 检查控制流与阈值 |
12 > 10 |
实测复杂度为 12,允许上限为 10 | 解释多出的路径从哪里来 |
--> /path/to/repo/module.py:1:1 |
定位到函数定义处;不是某一行语句写错 | 检查整个函数 |
Found 1 error 与非零退出码 |
lint job 已失败 | 确认后续命令是否被短路 |
许多测试脚本长这样:
ruff check . && pytest ...
&& 左边失败,右边根本不会执行。所以在测试用例或后续处理逻辑里排查半天,很可能是在错的层级用力。
C901 的处理顺序:读
12 > 10→ 找新增分支 → 抽完整的策略 helper;不要先删注释,也不要先抬阈值。
这也是一个适合截图留给团队的排查顺序:规则码先决定你该数分支,还是查未使用导入、类型错误或业务异常。
12 不是行数:它是从入口到出口的分岔
McCabe 圈复杂度把程序看作控制流图。对一个连通的函数,常见的手算近似是:
圈复杂度 = 1 + 决策点数量
if、循环和异常分支会让路径增加。复杂度的严格图论定义是“线性无关路径数”;“每个决策点加一”是日常读代码时很好用的近似,而不是要求你拿着尺子数代码行。
这里有个常见但需要纠正的简化:不要把所有 and、or、三元表达式或推导式里的条件,一律当作 C901 的固定加分项。不同工具、不同实现对 AST 节点的计数细节并不完全相同。Ruff 实际报出的数值才是这次门禁的裁判;手算的作用是解释变化,而不是替代工具。
例如,一个本来已经在阈值附近的编排函数,新塞进两层处理判断:
if _matches_special_case(item):
if item.get("tag"):
...
这两个 if 增加了两个明确的决策点。若函数原先是 10,Ruff 报出 12 就完全说得通。
反过来,200 行顺序赋值和字段搬运,复杂度仍可能很低;30 行连续分支,也足以触发 C901。嵌套定义的函数会作为自己的函数接受分析,不能拿它替父函数背账。
路多的函数,测试也多。门禁算的是路,不是页面长度。
新规则该住进策略层,不该挤进编排中枢
这类问题最容易出现于“编排函数”:它负责决定一批输入要经过哪些步骤、按什么顺序调用哪些处理、如何汇总结果。它天然会碰到对象分流和步骤协调;再把每条规则的细节塞进去,很快就变成一间谁都往里放东西的储物间。
可以用这个边界判断新代码放在哪里:
| 层次 | 应该回答的问题 | 典型形状 |
|---|---|---|
| 策略 | 这条记录为什么应被处理、跳过或归类? | _should_process_*、_partition_* |
| 转换 | 一条记录如何变成目标结构? | _serialize_*、_transform_* |
| 编排 | 这一批要调用哪些步骤、收集哪些结果? | _process_batch、_run_* |
假设某个匹配条件已经在 _should_process_item 里,过滤与分组逻辑仍留在 _process_batch 里,问题就没有真正搬家:策略名字搬走了,策略的控制流还在中枢付路径费。
更合适的形状,是让 helper 接管完整的一块处理规则:
if should_run_summary_step:
_append_summary_rows(results, items)
父函数只保留“这一批是否需要这个步骤”的编排决定;helper 内部处理匹配、筛选、转换和局部状态。这样做的关键不是把代码切成更小的文件,而是让“为什么走这条路径”只有一个稳定的归属。
编排层知道要调用哪个步骤;它不必知道每条记录为什么被归入该步骤。重构后,原有的可观察行为仍需保持一致;复杂度下降不能靠悄悄改变行为换来。
三种修法,代价并不相同
C901 不是必须“抽函数”的机械命令。它给了你设计压力,怎么回应取决于代码的寿命和边界。
| 做法 | 你省下什么 | 你付出的代价 | 适用条件 |
|---|---|---|---|
# noqa: C901 |
立刻解除这一处阻断 | 此函数不再受该规则保护 | 生成代码、一次性脚本,或复杂度确有不可拆的理由 |
提高 max-complexity |
减少全局告警 | 整个仓库的容忍线一起上移 | 现有基线普遍高于当前阈值,且团队能说明新阈值的含义 |
| 抽取完整 helper | 让中枢恢复可读的职责 | 多一个命名和调用边界 | 新增分支本身构成独立业务规则 |
我的立场很明确:不要为了放过一个编排函数而提高全仓库的复杂度阈值。
反对意见也成立:阈值 10 不是自然定律。有些领域函数必须覆盖很多合法状态,硬拆会把连续逻辑打散,读者得在多个函数之间跳转。若整个代码库长期稳定地超过 10,并且团队能用维护成本证明 12 或更高更合适,调整配置比四处制造空壳 helper 更诚实。
但“这次是 12,改成 12 吧”不是那个论证。它没有说明下一个对象、下一条规则、下一层条件出现时,谁负责重新划边界。
Ruff 可以自动修正一部分格式或语法类问题;C901 不在这类自动修复之中。工具无法替你判断:这两个分支属于现有职责,还是一个应被命名、测试和隔离的新策略。
门禁该把设计压力放在变更发生时
把 C90 放进 Ruff 的 select,再由 CI 执行它,作用不只是让某次合并变绿。它把“别再让编排函数继续膨胀”从一次 code review 的口头提醒,变成每个变更都会面对的契约。
这条原则成立,是因为复杂度在新增分支时最容易被定位:代码作者仍看得见新路径的语义,也还能决定它属于旧职责还是新策略。等路径堆积成一个大函数,再回头拆分,边界通常只会更难辨认。
它也有边界。复杂度指标只能看见控制流,看不见命名质量、数据耦合和副作用;把一个复杂函数拆成十个相互隐式依赖的小函数,同样会难维护。门禁应该促成职责讨论,不能代替代码审查。
举一反三:每次新增一个条件时,问一句——它是在扩展现有步骤,还是在暴露一个尚未命名的策略?
下一次 C901 出现时,试着在改阈值前回答一个问题:新增的那条路径,究竟是编排的选择,还是一个还没有被命名的策略?