4.1

4.1 代码复杂度指标

概述与动机

圈复杂度由Thomas J. McCabe于1976年提出,计算一段代码控制流中独立路径的数量:每个if、循环和分支都会增加这个计数。近五十年后的今天,它仍然是使用最广泛的代码复杂度指标,与它相关的还有认知复杂度(相比McCabe最初的线性计数,它对嵌套和难以理解的控制流赋予更高的权重)和嵌套深度。这些指标共享一个真实的、经过验证的洞见:一段拥有更多独立路径的代码更难被彻底测试,更难被理解,并且在数十年的实证研究中,被可测量地证明更可能包含缺陷。

本主题既真正尊重这个洞见,也同样认真地对待它的局限。复杂度指标衡量的是代码的一种特定属性,一个代码库可以在每一项复杂度指标上都显得简单,同时却在任何分支计数算法都无法检测到的方式上设计糟糕、命名糟糕,或者在概念上不连贯。反过来,一些不可化简的复杂问题,确实需要复杂的代码才能被正确解决,而一个被要求把复杂度分数降到最低的团队,可能产生出分数很好看、实际上却更难理解的代码,因为它只是把必要的复杂性分散到了更多的文件和间接层次中,而不是真正减少了它。

对大团队而言,复杂度指标作为一种分诊工具而发挥价值:在成千上万个文件中,找出最值得进一步细看的一小部分,而不是作为对代码质量的独立裁决。维护着大到任何个人都无法通读全部内容的代码库的企业和政府组织,依赖这种分诊功能,把稀缺的重构和评审精力,引导到最能发挥作用的地方。

核心原则

  • 复杂度指标预测的是测试和缺陷方面的难度,而不是直接衡量质量。 把它们当作一项输入,而不是一项裁决。
  • 复杂度分数暴露在通过混淆、而不仅仅是真正简化来操纵的风险之下。 把复杂度拆分到更多文件中,可以在不让代码真正更容易理解的情况下降低这个分数。
  • 一些复杂性是本质性的,而不是偶然的。 一个真正困难的问题,可能需要真正复杂的代码;目标是把偶然复杂度降到最低,而不是不分青红皂白地消除全部复杂性。
  • 把复杂度指标用于分诊,而不是用作个人或团队的记分卡。 它们指出该往哪里看,而不是该责怪谁。
  • 趋势与离群值比任何绝对阈值都更重要。 一个上升的趋势,或一个极端的离群值,比一个全团队的平均值更具可操作性。

建议

用复杂度指标来对评审和重构精力进行分诊

在整个代码库上运行复杂度分析,用结果来确定更细致的人工评审或重构投入在哪里最能获得回报:分数远高于代码库自身典型范围的函数或文件,是最值得优先查看的地方。这种分诊用法,找出该往哪里看,是复杂度指标最站得住脚、最有价值的应用,远比把它用作一道绝对的通过/失败关卡更有价值。

相对于你自己的代码库设定阈值,而不是采用一个普适的数字

不加批判地从行业惯例中借用的绝对复杂度阈值(复杂度分数为十是一条常被引用的经验法则),可能因为你的领域不同而或者过于宽松,或者过于严格:一个解析器或一个规则引擎,可能合理地拥有比典型的增删改查服务更高的基准复杂度。根据你自己代码库的实际分布来校准你自己的阈值,把超出阈值当作一个促使你更细致查看的提示,而不是一次自动的构建失败,除非你的团队已经在充分了解其权衡的情况下,刻意选择了那种更严格的政策。

留意通过分解而非真正简化所进行的操纵

复杂度分数最常见的被操纵方式,正是主题 1.2 的替代性模式应用在这个具体指标上:把一个真正复杂的函数拆分成若干个单独分数都不错的小函数,而整个系统依然同样难以理解,有时甚至变得更难理解,因为逻辑现在被分散到了更多的文件中,文件之间又多了更多的间接层次。把复杂度指标与一次定性评审配对,评估这次分解是否真正让代码变得更清晰,还是只是把复杂性挪到了这个指标看不见的地方。

在做出反应之前,先把本质复杂度与偶然复杂度区分开来

在把一个高复杂度分数当作一个需要修复的问题来处理之前,先问一问,底层问题是否真的需要那么多独立路径,比如税法计算逻辑合理地拥有很多分支,还是这种复杂性来自可以避免的原因:本可以被拉平的深度嵌套条件,本可以被合并的重复逻辑,或者本可以被重新划定的不清晰职责边界。只有第二类才是这个指标应当驱使你去修复的真正质量问题。

跟踪趋势与离群值,而不只是一份快照平均值

一个覆盖整个代码库的平均复杂度分数发生轻微变动,其本身很少具有可操作性;一个具体文件的复杂度在几次变更中急剧上升,或者一个在其他方面表现良好的代码库中出现少数极端的离群值,则是远更有用的信号。同时跟踪随时间变化的趋势和离群值尾部,用它们来触发一次具体、有针对性的调查,而不是一场宽泛、不聚焦的复杂度削减行动。

权衡取舍:利与弊

方案优点缺点
绝对的普适阈值简单、一致,容易自动化忽视了合理的领域差异;容易被分解操纵
相对于代码库的阈值更好地校准到实际情境需要更多的前期设置和定期重新校准
复杂度作为自动化构建关卡在没有人工评审开销的情况下强制执行一致性可能阻挡合理复杂但设计良好的代码,或奖励被混淆的分解
复杂度作为人工评审的分诊信号捕捉到单靠分解本会遗漏的真正质量问题比全自动关卡需要更多的人工评审时间

核心张力是自动化与判断力之间的张力。一道完全自动化的复杂度关卡执行起来成本低、也一致,但它既可能阻挡合理复杂、设计良好的代码,也可能奖励那种在不真正简化任何东西的情况下操纵分数的表面化分解。解决这种张力的办法,是用自动化的复杂度分析来找出候选评审对象,而把真正的判断,这种复杂性是本质性的还是偶然的,这次重构是真正让代码变清晰了,还是只是把复杂性挪了个地方,留给人工评审者,而不是单靠一道生硬的自动化关卡。

与团队讨论的问题

  1. 我们的复杂度阈值是根据我们自己代码库的实际分布校准的,还是不加批判地借用了一个通用的行业惯例? 拉出你代码库真实的复杂度分布,检查你当前的阈值相对于它是否合理,而不要假定一个常被引用的数字普适地适用于你的领域。

  2. 我们是否曾见过一个函数被拆分成若干个更小的函数,而由此产生的代码并没有真正变得更容易理解? 这是本主题所警告的分解操纵模式最清晰的迹象。看一次主要由一个复杂度分数驱动的近期重构,诚实地评估它是否改善了真正的可理解性。

  3. 在我们的代码库中,哪里的复杂性是问题本质所固有的,哪里的复杂性是偶然且可修复的? 逐一审视你复杂度最高的那些离群值,明确地把它们归入这两类,因为只有第二类才代表一个真正的、可操作的质量问题。

  4. 我们是把复杂度指标用于评审精力的分诊,还是把它当作一道没有人工判断参与的强制自动化关卡? 讨论你当前的执行方式,是否为本主题所建议的本质与偶然的区分留出了空间,还是不论情境如何,都对每一次超标一视同仁。

  5. 复杂度分数是否曾经,哪怕只是非正式地,被用来评判某位具体工程师的工作质量? 这带来了与主题 3.4 针对活动指标所警告的相同的个人评估陷阱,只是这次应用在代码指标上,并且会招致同样的操纵反应。

  6. 对我们最关键、最频繁变更的文件而言,过去一年我们的复杂度趋势是什么样子? 把这个和主题 4.3 的变动量与热点分析结合起来,因为一个既高度复杂又频繁变更的文件,比一个复杂但很少被触碰的文件,更值得优先关注。

行业视角

初创公司。 在这个规模上,复杂度指标通常没那么紧迫;代码库规模小到非正式的熟悉程度往往能替代正式的测量。值得及早养成的习惯,只是偶尔运行一次复杂度扫描,在团队还没有大到无法非正式察觉之前,捕捉到某个具体文件正在悄悄变得难以管理。

小型企业。 大多数现代静态分析工具,作为一套更广泛、免费或低成本的代码检查设置的一部分,会报告复杂度指标;把输出用作一个定期的分诊信号,而不是投资于专门的工具。优先关注你最频繁修改的文件。

企业。 在这个规模上,复杂度指标与变动量数据(主题 4.3)结合使用时最有价值,用来在一个大到任何个人都无法手动调查的代码库中,确定重构投入的优先级。按服务或领域分别校准阈值,而不是套用一个全组织统一的数字,因为不同类型系统之间的合理复杂度差异很大。

政府。 长寿命的政府系统,往往在数年或数十年的渐进式需求变更中逐渐积累复杂性,一次复杂度审计可以成为一个有说服力、具体的工具,用来向那些可能只把系统看作”能用”、因此不值得投资的利益相关方,论证现代化或重构投资的正当性。

案例

企业。 一家支付处理公司第一次在整个代码库上运行复杂度审计,发现一个单一的交易验证函数,其圈复杂度分数超过代码库中位数的十倍以上。调查发现,这种复杂性几乎完全是偶然的:多年来为特定支付提供商逐步添加的特殊情况处理,累积成了深度嵌套的条件语句,本可以被重构成一种更干净的策略模式,把特定于提供商的逻辑分离出来。这次重构,因为复杂度审计将其确定为代码库中价值最高的单一目标而被直接优先处理,把该函数的复杂度分数降低了超过80%,更重要的是,在随后两个季度里,可衡量地降低了该特定代码路径中的缺陷率。

政府。 一家税务机构一个已有数十年历史的福利计算引擎,几乎在每一个函数上的复杂度指标都极高,起初让人假定整个系统需要从头重写。一次更细致的、逐函数区分本质复杂度与偶然复杂度的审查发现,大部分复杂性真实地反映了底层的法律规则,这些规则确实拥有法规所要求的那么多合理的分支和特殊情况,而较小的一部分复杂性,则来自相似计算路径之间可避免的重复。该团队只针对偶然复杂度的那一部分进行重构,避免了一次代价高昂、风险很高的全面重写,同时依然有意义地改善了系统中真正有问题的那些区域。

商业理由:动机、投资回报与总拥有成本

把复杂度指标用好的回报,是有针对性的、高价值的重构投资:上面的支付公司案例展示了一次单一的、通过复杂度分析精准定位的修复,可衡量地降低了恰恰是风险最高的那条代码路径中的缺陷,所需成本只是一场宽泛、无针对性的重构行动本会要求的成本的一小部分。

总拥有成本很低:大多数现代开发工具链,作为静态分析(主题 4.4)的一部分,会自动计算复杂度指标,真正的投入是正确解读结果所需的人工判断时间,区分本质复杂度与偶然复杂度,捕捉分解操纵,而不是任何重大的新工具成本。

反模式与陷阱

  • 把复杂度分数当作一个直接的质量裁决: 它衡量的是一种特定属性,而不是整体代码质量。
  • 拆分一个函数来操纵分数,而没有真正的简化: 本主题专门指出的分解操纵模式。
  • 套用一个普适阈值,而不针对你自己的代码库进行校准: 根据领域不同,产生要么过于宽松、要么过于严格的执行标准。
  • 用复杂度指标对工程师进行个人评估: 招致操纵,也误用了一项本应用于分诊、而不是评判的指标。
  • 把所有复杂性都当作同等可修复: 来自一个真正困难问题的本质复杂性,不是一个应当被消除的缺陷。
  • 忽视趋势和离群值,转而偏好一个扁平的、全代码库平均值: 错过了这个指标家族所提供的最具可操作性的信号。

成熟度模型

  • 第一级,启动: 复杂度不被测量,或者被套用一个未经审视的、通用的普适阈值来测量。
  • 第二级,发展: 复杂度指标被收集,但很少据以采取行动,也没有对本质复杂度和偶然复杂度做出区分。
  • 第三级,标准化: 阈值根据代码库自身的分布进行校准,复杂度指标在全组织范围内一贯地驱动评审和重构分诊。
  • 第四级,管理: 复杂度趋势和离群值被主动监控,并与变动量数据(主题 4.3)结合,以确定重构投资的优先级;分解操纵被主动留意。
  • 第五级,协奏: 组织能够指出具体的、可衡量的缺陷率改善,这些改善可以直接追溯到复杂度所指导的重构投资,复杂度数据是工程投资决策中一项常规的、可信赖的输入。

讨论思路

  1. 我们最复杂的单一函数或文件是什么,它的复杂性是本质性的还是偶然的?
  2. 我们是否曾经通过分解而不是真正的简化来操纵过一个复杂度分数?
  3. 我们的阈值是根据我们自己的代码库校准的,还是不加批判地借用而来的?
  4. 目前我们代码库中,高复杂度与高变动量在哪里重叠?
  5. 复杂度数据是否曾为一项重构投资决策提供依据,还是它一直闲置未被使用?

要点回顾

  • 像圈复杂度这样的复杂度指标预测的是测试和缺陷方面的难度,它们并不直接衡量整体代码质量。
  • 在对一个高分数做出反应之前,先把本质复杂度(来自一个真正困难的问题)与偶然复杂度(可以通过更好的设计避免)区分开来。
  • 留意分解操纵:拆分代码来降低分数,却没有真正简化任何东西。
  • 把复杂度指标用于分诊,引导人工评审和重构精力,而不是用作个人记分卡或一道僵化的自动化关卡。
  • 根据你自己代码库的分布校准阈值,并跟踪趋势和离群值,而不只是一个扁平的平均值。

参考文献与延伸阅读

  • McCabe, Thomas J., “A Complexity Measure,” IEEE Transactions on Software Engineering (1976):圈复杂度的原始论文。
  • Code Complete,Steve McConnell著(关于在软件构建中管理复杂性的实用指导)。
  • Working Effectively with Legacy Code,Michael Feathers著(在现有的、难以改动的代码中安全降低复杂性的技术)。
  • Campbell, G. Ann, “Cognitive Complexity: A New Way of Measuring Understandability” (SonarSource, 2018):认知复杂度指标及其与圈复杂度的区别。