1 minute read

“简单是可靠性的先决条件。” — 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 出现时,试着在改阈值前回答一个问题:新增的那条路径,究竟是编排的选择,还是一个还没有被命名的策略?

Updated: