周末想露一手,你翻开一本菜谱:番茄炒蛋,三步,干净利落。再翻另一本“禅意慢炖小羊排”,光是“如果客人不吃香菜”“假如买不到迷迭香”“否则改用烤箱”就占了整整两页,还没开火,你已经有点头大。代码本质上就是把“做一件事”的步骤写给电脑看,可步骤里一旦塞满“如果、否则、或者、循环”,就变成了一道让人一读就累的菜。怎么衡量一份菜谱有多难照做?怎么衡量一份代码有多难读懂?这就是代码复杂度分析要解决的事——它给“难懂”打分:数岔路口的圈复杂度、算思维负担的认知复杂度、统计词汇信息量的 Halstead 指标。有了这些数字,团队能在代码评审时统一口径、给重构排优先级、在技术债失控前踩下刹车。今天我们就用一本“菜谱”当尺子,把这些指标逐个量明白,并看看它们如何从理论走进真实的研发流程。

岗位关联

学会量化复杂度,等于给代码评审装上一副 X 光镜——你能在 Bug 爆发前定位高风险模块,用数据说服团队修债而不是扯皮,这是中级工程师迈向工程化思维的关键一步。

目录
  1. 可视化演示
  2. 复杂度概述:菜谱为什么会“看着就难”
  3. 圈复杂度:数一数岔路口
  4. 认知复杂度:脑子要转多少个弯
  5. Halstead指标:菜谱的词库与总字数
  6. 复杂度可视化:给菜谱画一张难度地图
  7. 自动化扫描:请一个读菜谱机器人
  8. 重构优先级:先改厨房里的哪道菜
  9. 技术债管理:厨房的欠账本
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

\u5c55\u793a\u5708\u590d\u6742\u5ea6(CC)\u4e0e\u8ba4\u77e5\u590d\u6742\u5ea6(CG)\u7684\u51fd\u6570\u7ea7\u5bf9\u6bd4\uff0c\u4ee5\u53ca\u6a21\u5757\u590d\u6742\u5ea6\u70ed\u529b\u56fe\u7684\u98ce\u9669\u5206\u7ea7\u53ef\u89c6\u5316\u3002

本节配有交互动效(进入视口后自动加载,约 3KB)。

\u6307\u6807\u5bf9\u6bd4\u6a21\u5f0f \u5708\u590d\u6742\u5ea6(CC)=\u51b3\u7b56\u70b9\u6570+1\uff0c\u8ba4\u77e5\u590d\u6742\u5ea6(CG)=\u5d4c\u5957\u60e9\u7f5a F4/F6\u7684CG\u660e\u663e\u9ad8\u4e8eCC\uff0c\u5d4c\u5957\u6df1\u5ea6\u662f\u4e3b\u8981\u96be\u70b9 CC\u226415 \u5b89\u5168, CC>20 \u9ad8\u98ce\u9669

复杂度概述:菜谱为什么会“看着就难”

翻开那本“分子料理”菜谱时,你最先感受到的不是某一步难,而是整本菜谱处处透着“不简单”——岔路多、嵌套深、术语密。代码也一样,难懂往往来自三个方面:控制流的岔路太多、嵌套层级太深、信息量太大。复杂度分析要做的,就是把这三股“难”分别变成数字。这就是本节的底层逻辑:先承认“难懂”可以拆解,再去谈怎么度量。

上世纪 70 年代起,计算机科学家开始给代码“测体温”。1976 年 McCabe 提出圈复杂度:把代码的控制流画成一张图,数图里有多少条独立路径,路径越多越难测、越难懂。1977 年 Halstead 提出软件科学指标:把代码当成一篇由“运算符”和“操作数”组成的文章,统计词汇量与篇幅。2017 年 SonarSource 又提出认知复杂度:专门衡量“读代码时大脑要转多少个弯”,嵌套越深扣分越狠。三个流派分别回答三个问题:代码有多少条路?代码有多少信息?代码要费多少脑力?

这三套指标就是团队评审时的“共同语言”。以前你说“这函数好绕”,我说“还能用”,谁也说服不了谁;现在你说“圈复杂度 28,超阈值了”,我无话可说。

回到厨房场景:大厨选菜谱时如果只看“好不好吃”就会踩坑,聪明的做法是翻一翻步骤、数一数岔路、掂一掂食材种类——这就是用维度代替感觉。复杂度分析就是给团队配了一个“菜谱难度测量仪”。

复杂度=f(岔路数, 嵌套深度, 信息量)\displaystyle \text{复杂度} = f(\text{岔路数},\ \text{嵌套深度},\ \text{信息量})
公式通俗说明:复杂度不是一个单一数字,而是由三个维度共同决定——岔路(控制流分支)、深度(嵌套维度)、信息量(词符多样度)。三个维度分别由圈复杂度、认知复杂度、Halstead 指标来度量。
  1. 岔路数代码里 if / else / switch / 循环 这类“二选一”或多选一的节点。
    岔路数=if+else+switch+循环\displaystyle \text{岔路数} = \text{if} + \text{else} + \text{switch} + \text{循环}
  2. 嵌套深度一个分支套在另一个分支里,读到最内层时大脑要“记账”记几层。
    深度=max⁡(嵌套层级)\displaystyle \text{深度} = \max(\text{嵌套层级})
  3. 信息量代码本身携带的“词”的量与多样性,词越多、种类越杂,越难记。
    信息量=f(运算符, 操作数)\displaystyle \text{信息量} = f(\text{运算符},\ \text{操作数})

圈复杂度:数一数岔路口

菜谱里每出现一个“如果客人不吃香菜,那就……”,新手就要在灶台前停一次、想一次:走哪条路?这就是一个岔路口。圈复杂度(Cyclomatic Complexity)的思路极其朴素:把代码画成一张路线图,数一数图上到底有多少条不同的走法。走法越多,说明线路越绕,测试时需要考虑的用例也越多。

McCabe 用图论给这个直觉定了量:把每段顺序执行的语句块当作一个“节点”,把语句之间的跳转当作“边”,代码就变成了一张有向图。从入口走到出口,独立路径的总数就是圈复杂度。在实际工程中,有个更快的口算法则:数出代码里所有判定节点的个数,再加 1。

工程上习惯用阈值分级:圈复杂度在 10 以下是“小炒菜”,清爽利落;10 到 20 是“红烧肉”,能吃但费功夫;超过 20 是“佛跳墙”,每一步都要小心翼翼;超过 30 基本就是“烧糊的锅”,别犹豫,直接重做。记住:圈复杂度不只代表难读,它直接等于“做到完全覆盖所需的最少测试用例数”——复杂度 8,至少要写 8 个用例才能走完全部路径。

V(G)=E−N+2=判定节点数+1\displaystyle V(G) = E - N + 2 = \text{判定节点数} + 1
公式通俗说明:圈复杂度等于“边数减节点数加 2”,工程上通常简化为“判定节点数加 1”。判定节点就是每个 if、else if、switch case、for、while、catch 之类会分叉的地方。
  1. 统计节点数 N把每个“无岔路的顺序语句块”看作一个节点,数出全部节点总数。
    N=节点总数\displaystyle N = \text{节点总数}
  2. 统计边数 E节点之间的跳转关系就是一条边,分支、循环、异常处理都会产生边。
    E=边总数\displaystyle E = \text{边总数}
  3. 代入公式算独立路径数用边的数量减去节点的数量,再加上 2,就是独立路径数。
    V(G)=E−N+2\displaystyle V(G) = E - N + 2
  4. 换用判定节点口算判定节点会直接分裂路径,所以每条路径对应一个判定节点,起点补 1。
    V(G)=判定节点数+1\displaystyle V(G) = \text{判定节点数} + 1

认知复杂度:脑子要转多少个弯

圈复杂度有个“盲区”:同样是 5 个岔路口,一字排开的乡间小路和五层交错的立交桥,它都算 5。可真正开车的人知道,立交桥比乡间小路难走十倍。代码里也一样:五个 if 平铺,读起来像是念清单;五个 if 层层嵌套,读起来像解九连环。大脑需要不断记住“我现在在第几层、这一层的条件是什么”,这种记忆负担,圈复杂度完全没惩罚。

认知复杂度(Cognitive Complexity)正是为了补这个洞。它由 SonarSource 在 2017 年提出,核心规则有两条:第一,遇到 if / else / switch / for / while / catch 这种结构,基础分加 1;第二,如果这个结构嵌在另一个结构里面,每多一层嵌套再额外加 1。嵌套越深,惩罚越重。打个比方:圈复杂度是“数转了几个弯”,认知复杂度是“算每个弯要用多少脑细胞去记”。

认知复杂度在代码评审中特别实用:它是“可读性”的体温计。一个新同事接手某段逻辑,如果他反复问你“这个 if 是在哪个 if 里面”,大概率是认知复杂度超了。这时候与其解释一百遍,不如把嵌套拆平——这也是重构里“早返回”技巧的由来。记住:圈复杂度关心程序有几条路,认知复杂度关心人脑要转几个弯,后者更贴近“难懂”这个词的本意。

CC=∑i=1kBi其中Bi=1+嵌套深度i\displaystyle CC = \sum_{i=1}^{k} B_i \quad\text{其中}\quad B_i = 1 + \text{嵌套深度}_i
公式通俗说明:认知复杂度是所有结构的加权分数之和。每个判定结构先自带 1 分,然后按它所在的嵌套层级逐层加罚分。平铺的 5 个 if 是 5 分,嵌套 5 层的 if 则是 1+2+3+4+5=15 分。
  1. 列出所有结构把函数里每个“会分叉”的结构(if、else、switch、循环、catch 等)编号。
    CC=∑i=1kBi\displaystyle CC = \sum_{i=1}^{k} B_i
  2. 基础分加 1每个结构不管多简单,先记 1 分,代表“这里有一个需要脑力处理的分叉”。
    Bi=1\displaystyle B_i = 1
  3. 嵌套每层加 1如果这个结构嵌在另一个结构内,它的深度记为 d,就要再额外加 d 分。
    Bi=1+深度i\displaystyle B_i = 1 + \text{深度}_i

Halstead指标:菜谱的词库与总字数

有些菜谱没有太多岔路,但你还是觉得难——因为食材太多、步骤太长。比如一道惠灵顿牛排:牛里脊、酥皮、蘑菇酱、帕尔马火腿……光备料就 20 种,步骤洋洋洒洒 80 步。哪怕一路照做不转弯,你也记不住。这种“难”不在逻辑,而在信息量。Halstead 指标就是干这个的:把代码当作一篇文章,统计词汇量和篇幅,进而估算它的“脑力工作量”。

Halstead 软件科学是 Maurice Halstead 在 1977 年提出的。它把代码里的符号分成两类:操作数(Operand)是数据,包括变量名、常量、字面量;运算符(Operator)是动作,包括 +、-、if、for、while 等。统计四个基础量:运算符种类数 $n_1$、操作数种类数 $n_2$、运算符总出现次数 $N_1$、操作数总出现次数 $N_2$。有了这四个数,就能派生出词汇量、程序长度、程序体积、难度等级和工作量。

Halstead 的实践意义在于缺陷预测:它统计出的“程序体积”与模块缺陷密度有较强相关性,体积越大的模块往往隐藏的 Bug 越多。回到菜谱比喻:如果你看到一本菜谱既有 35 种食材、又有 90 个步骤,哪怕它没有岔路,你也会本能拒绝它。Halstead 就是把这个“本能拒绝”换算成了数字,让你在代码评审时理直气壮地说:“这个函数信息量太大了,拆!”

E=n1×N2×(N1+N2)×log⁡2(n1+n2)2×n2\displaystyle E = \frac{n_1 \times N_2 \times (N_1 + N_2) \times \log_2(n_1 + n_2)}{2 \times n_2}
公式通俗说明:Halstead 工作量公式。分子里 n1 是运算符种类数(动作花样多则难),N2 是操作数出现次数(数据搬来搬去则累),N1+N2 是代码总长度(越长越费劲),再乘上词汇量的对数(词汇越杂大脑越吃力);分母的 2 和 n2 是对“操作数越分散越容易理解”的修正。
  1. 统计词汇量 n运算符的种类数加上操作数的种类数,就是代码的“词汇量”。
    n=n1+n2\displaystyle n = n_1 + n_2
  2. 统计程序长度 N所有运算符和操作数实际出现的总次数,代表“总字数”。
    N=N1+N2\displaystyle N = N_1 + N_2
  3. 估算难度 D运算符种类越多、操作数使用越分散,难度越高。
    D=n12×N2n2\displaystyle D = \frac{n_1}{2} \times \frac{N_2}{n_2}
  4. 计算工作量 E难度乘以程序体积(体积 = 长度 × 词汇量的对数),就是“脑力总功”。
    E=D×(N×log⁡2n)\displaystyle E = D \times (N \times \log_2 n)

复杂度可视化:给菜谱画一张难度地图

餐厅后厨挂着一面墙的菜谱,大厨不会一本本翻,而是看墙上的“难度热区图”:红色标记的灶台就是最容易烧糊的地方。代码仓库动辄上千个文件,靠人肉一个个看数据,效率太低。复杂度可视化的目标,就是把密密麻麻的数字变成一眼能看懂的图形,让团队在 10 秒内回答三个问题:哪里最高危?哪里在恶化?哪里最值得动手?

常见的可视化有四张图。第一是热力图:横轴是文件或模块,纵轴是目录层级,格子颜色从绿到红表示复杂度从低到高,红色区块就是“火灾现场”。第二是散点图:横轴是代码行数,纵轴是圈复杂度,右上角的点是“又大又复杂”的怪兽,是重构的头号目标。第三是雷达图:从多个维度(圈复杂度、认知复杂度、重复率、注释率)给模块画像,方便不同模块横向对比。第四是趋势图:把复杂度随时间画成折线,一旦发现某个模块曲线陡峭上扬,就知道技术债正在积累。

可视化不是装饰,而是决策工具。在评审会议上投出这张热力图,所有人的目光会瞬间聚焦到那几个红色区块,不用再争论“哪个模块最烂”——图已经说了算。记住:复杂度的最终价值不是算出分数,而是让分数驱动行动。

风险排序=按 V(G) 降序且高亮 V(G)>阈值\displaystyle \text{风险排序} = \text{按 } V(G) \text{ 降序} \quad \text{且} \quad \text{高亮 } V(G) > \text{阈值}
公式通俗说明:可视化背后的排序逻辑:把模块按圈复杂度从高到低排列,超过阈值的用红色高亮。人眼对颜色比对数字敏感,红色区块直接引导注意力。
  1. 计算每个模块的复杂度对文件或函数逐一跑圈复杂度算法。
    Vi=V(Gi)\displaystyle V_i = V(G_i)
  2. 按复杂度降序排列从最高分到最低分排好,顶端就是最需要关注的目标。
    rank(Vi) 降序\displaystyle \text{rank}(V_i) \ \text{降序}
  3. 超阈值高亮超过给定阈值(如 10)的模块标红,形成视觉上的“火灾地图”。
    color(Vi)={红Vi>10绿Vi≤10\displaystyle \text{color}(V_i) = \begin{cases} \text{红} & V_i > 10 \\ \text{绿} & V_i \le 10 \end{cases}

自动化扫描:请一个读菜谱机器人

后厨有一百本菜谱,大厨不可能每天让助手逐页数岔路口,太慢、太累、还会数漏。于是团队引入一个“读菜谱机器人”:它扫过整面墙的菜谱,三秒报出每一本的难度分数、超标的段落、以及哪些菜谱比上周更难了。在代码世界里,这个机器人叫“静态分析工具”,它能自动解析源码、计算各类复杂度指标,并生成报告。

自动化扫描的核心价值不只是“算得快”,而是“把关严”。把复杂度检查接入持续集成(CI)流水线,等于在大门口装了一道安检门:开发者提交代码 → 机器人自动扫描 → 超过阈值的代码直接判失败 → 阻止合并。这个机制叫“质量门禁”(Quality Gate),它把“代码评审时提醒一句”升级成“不满足标准就进不了主干”,让复杂度约束变得刚性。

不同语言有不同工具:JavaScript / TypeScript 用 ESLint 的 complexity 规则;Python 用 radon 或 mccabe;Java 用 PMD 或 Checkstyle;全语言一站式方案常用 SonarQube。注意,机器人和人不是替代关系:机器人负责“守门”,人负责“判断这扇门要不要特殊放行”——比如某个核心算法天生复杂,但业务无法简化,这时候可以在门禁里特批豁免并附带说明。机器把关效率,人把关例外。

门禁通过  ⟺  V(G)≤10且CC≤15\displaystyle \text{门禁通过} \iff V(G) \le 10 \quad\text{且}\quad CC \le 15
公式通俗说明:质量门禁就是一个布尔条件:只有当圈复杂度不超过 10、认知复杂度不超过 15(可按团队标准调整)时,合并请求才被放行。条件不满足,CI 直接红灯。
  1. 扫描阶段CI 拉取代码后,运行复杂度分析工具(如 ESLint、SonarQube、radon)。
    scan→V(G), CC, H 等指标\displaystyle \text{scan} \to V(G),\ CC,\ H\ \text{等指标}
  2. 判定阶段把指标与预设阈值比较,看是否在安全区间。
    V(G)≤10\displaystyle V(G) \le 10
  3. 拦截或放行全部达标则流水线继续;任一超标则流水线失败,MR 被阻断。
    result={通过达标拦截超标\displaystyle \text{result} = \begin{cases} \text{通过} & \text{达标} \\ \text{拦截} & \text{超标} \end{cases}

重构优先级:先改厨房里的哪道菜

厨房里挂着五十本复杂菜谱,老板说“都给我重写”,那是灾难。正确的做法是先改“又复杂又常做”的那几本——天天要出品的菜谱,复杂一分,每天的痛苦就多一分。重构优先级也是这个逻辑:不能平均用力,要用数据找出投入产出比最高的目标。两个关键维度:一是复杂度本身,二是修改频率。复杂度高但三年没动过的代码,优先级反而不高;复杂度中等但每周都在改的代码,才是真正的“出血点”。

把两个维度相乘,就得到风险指数。这个指数反映的是“未来在这段代码上栽跟头的概率”:每次改动都可能引入 Bug,而复杂代码里藏 Bug 的概率更高,两者是乘法关系。实践中常把这个指数画成四象限图:横轴是复杂度,纵轴是修改频率。右上角“又高又常改”是头号目标,必须重构;左上角“低复杂但常改”可以顺手优化;右下角“高复杂但少改”列入中长期计划;左下角“低且少改”直接忽略。

确定了目标,具体怎么降复杂度?三板斧:提炼函数——把大函数拆成多个小函数,每个函数只做一个事;早返回——把嵌套的 if 平铺成“条件不满足就 return”,减少嵌套层级;用多态替换条件表达式——用策略模式代替一长串 switch。记住:重构不是炫技,是“先改风险最高的,再用最稳的手法改”,改完立刻看指标有没有降下来,形成闭环。

风险指数=V(G)×修改频率\displaystyle \text{风险指数} = V(G) \times \text{修改频率}
公式通俗说明:风险指数是圈复杂度与修改频率的乘积。复杂度衡量“改起来多危险”,修改频率衡量“多久会碰它一次”,两者相乘就是“未来一段时间内踩雷的期望值”。
  1. 度量复杂度对目标函数算出圈复杂度,作为“危险系数”。
    V=V(G)\displaystyle V = V(G)
  2. 统计修改频率从版本控制日志(如 git log)里统计该函数每月被修改的次数。
    F=修改次数周期(月)\displaystyle F = \frac{\text{修改次数}}{\text{周期(月)}}
  3. 相乘得风险指数危险系数乘以碰触频率,数值越大越先动手。
    R=V×F\displaystyle R = V \times F
  4. 按指数排序对整个仓库的函数排序,榜单前十就是未来两个迭代的重构目标。
    priority=sort(R,desc)\displaystyle \text{priority} = \text{sort}(R, \text{desc})

技术债管理:厨房的欠账本

赶时间开业的小餐馆,主厨为了出菜快,把菜谱写得潦草无比——“反正我自己看得懂”。三个月后新厨师来了,看不懂菜谱,出菜又慢又错,顾客开始流失。这就是“技术债”:用今天的省事,换明天翻倍的偿还成本。复杂代码就是厨房里的欠账本,每一处高复杂度都等着将来有人为它付出理解成本、测试成本和 Bug 修复成本。

技术债理论是 Ward Cunningham 提出的著名比喻:写代码就像借钱,写得快是“借到了时间”,但之后要付“利息”——理解成本、修改时的 Bug 概率、新人的培训成本。复杂度指标在技术债管理中扮演“债的体温计”:圈复杂度告诉你哪些地方借得最多,认知复杂度告诉你哪些地方利息最重。SonarQube 等工具会直接给出“技术债比率”——修复当前所有质量问题需要的估算时间,占项目总开发时间的比例,用百分比贴出账单。

治理技术债的正确姿势不是“一次性大扫除”——那相当于把整本菜谱推倒重写,风险太大。更稳妥的是“日日还小债”:第一,把“新增代码不允许超标”写进 Definition of Done,不欠新债;第二,存量代码按风险指数排序,每迭代还掉 Top 10;第三,用趋势图跟踪债息变化,管理层看到曲线不再上升,就知道工程健康在好转。记住:好厨房不是没有债,而是欠账在变少、利息在下降。

技术债比率=修复成本(估算人时)开发成本(估算人时)×100%\displaystyle \text{技术债比率} = \frac{\text{修复成本(估算人时)}}{\text{开发成本(估算人时)}} \times 100\%
公式通俗说明:分子是“把现有问题全部修好要多少时间”,分母是“当初写出这些代码花了多少时间”。比值越高,说明代码里沉淀的“欠账”越重,比如比率 10% 意味着每写 10 小时的代码,就欠下约 1 小时的修债时间。
  1. 估算修复成本工具对每个超标函数估算重构所需的人时并求和。
    costfixed=∑重构估算人时\displaystyle \text{cost}_{\text{fixed}} = \sum \text{重构估算人时}
  2. 统计开发成本用项目代码总量与人均效率估算出总开发时间。
    costdeveloped=总代码量÷人均效率\displaystyle \text{cost}_{\text{developed}} = \text{总代码量} \div \text{人均效率}
  3. 相除得债息率固定成本比上开发成本,得到技术债占开发投入的比例。
    ratio=costfixedcostdeveloped×100%\displaystyle \text{ratio} = \frac{\text{cost}_{\text{fixed}}}{\text{cost}_{\text{developed}}} \times 100\%

AI 应用场景

代码评审的智能门卫

阶段:代码评审 / 质量门禁

解决的问题:人工评审代码时,逐行翻看找复杂逻辑又慢又主观,评审者常因“不好意思说”或“懒得算”放过高复杂度代码。把复杂度检查交给机器人自动扫描后,能无差别地对每一次提交打分,超阈值直接阻断合并,让质量评审从“靠自觉”变成“靠门禁”。

具体案例:某支付团队在 GitLab CI 中接入 ESLint complexity 规则与 SonarQube 质量门禁,圈复杂度超过 10 的合并请求自动失败并 @ 提交者要求拆函数。运行 3 个月后,新增代码的平均圈复杂度从 12 降到 7,线上故障定位时间缩短约 40%。

技术债季度盘点

阶段:技术债评估 / 工程治理

解决的问题:技术债“看不见摸不着”,向管理层申请重构资源时拿不出数据,只能靠“代码越来越烂”这种模糊说法。用复杂度扫描工具对整个仓库量化,生成技术债比率报告,就能把抽象问题变成可核算的工程账单。

具体案例:某电商团队每季度用 SonarQube 扫描全部 67 个后端仓库,产出“技术债比率 Top 20”报表,并画出去重热力图。在季度技术评审会上,团队凭报表向 CTO 申请到 10% 的迭代产能专门用于清偿头部技术债,一年后整体技术债比率下降了 23%。

重构排期预测

阶段:重构优先级排序 / 排期规划

解决的问题:重构优先级常被“谁嗓门大谁先改”左右,缺乏数据支撑。利用 git log 统计模块修改频率,乘以圈复杂度得到风险指数,就能客观排出“最值得先改”的代码清单,把有限的开发资源投到踩雷概率最高的地方。

具体案例:某 SaaS 公司的前端团队写了一个脚本,每周扫描主仓库,输出“圈复杂度 × 修改频率”Top 20 函数榜。他们把榜单嵌入周会看板,连续 6 个迭代每周重构 2-3 个上榜函数,缺陷率同比下降 31%,新功能开发速度明显提升。

Python 演示

python58 行
import astfrom pathlib import Path  class ComplexityCounter(ast.NodeVisitor):    """遍历语法树,统计一个函数或文件的圈复杂度。"""     def __init__(self):        self.score = 1  # 起点就有 1 条路径     def visit_If(self, node):        self.score += 1        self.generic_visit(node)     def visit_For(self, node):        self.score += 1        self.generic_visit(node)     def visit_While(self, node):        self.score += 1        self.generic_visit(node)     def visit_Try(self, node):        self.score += 1        self.generic_visit(node)     def visit_ExceptHandler(self, node):        self.score += 1        self.generic_visit(node)     def visit_BoolOp(self, node):        self.score += len(node.values) - 1        self.generic_visit(node)  def complexity_of_source(source):    tree = ast.parse(source)    counter = ComplexityCounter()    counter.visit(tree)    return counter.score  def scan_functions(path):    tree = ast.parse(path.read_text(encoding='utf-8'))    results = []    for node in ast.walk(tree):        if isinstance(node, ast.FunctionDef):            counter = ComplexityCounter()            counter.visit(node)            results.append((node.name, counter.score))    return sorted(results, key=lambda x: x[1], reverse=True)  if __name__ == '__main__':    print('scan src/ ...')    for file_path in Path('src').rglob('*.py'):        for name, cc in scan_functions(file_path):            print(f'{file_path}: {name} -> {cc}')
目的:这是一个轻量级圈复杂度扫描器。它不依赖任何第三方库,用 Python 标准库 ast 把源码解析成语法树,然后遍历树中每个判定节点(if、for、while、try、except、and/or),自动统计全仓库所有函数的圈复杂度并按从高到低排序,直接输出“哪段代码最值得重构”的榜单。
  1. class ComplexityCounter(ast.NodeVisitor)继承 Python 内置的“语法树漫步者”,它会自动一格格逛完整棵树。
  2. self.score = 1任何函数天然有 1 条路径,所以起始值就是 1,对应公式里的"+1"。
  3. def visit_If(self, node)魔法方法:只要逛到 if 语句,这个函数就会被自动调用。
  4. self.score += 1每遇到一个判定结构,圈复杂度加 1 分,这就是“岔路口计数”。
  5. self.score += len(node.values) - 1a and b and c 里有 3 个操作数,相当于 2 个判定,所以要加操作数个数减 1。
  6. self.generic_visit(node)别停!继续往下逛这个节点的子节点,保证嵌套的判定也被数到。
  7. ast.walk(tree)从根节点出发,把树里所有节点都拉出来排队检查。
  8. sorted(results, key=lambda x: x[1], reverse=True)按复杂度降序排列,榜单第一名就是“最难缠”的函数。
  9. Path('src').rglob('*.py')递归扫描 src 目录下所有 Python 文件,一个都不漏。

常见误区

误区“我这个函数圈复杂度 18,肯定是我写得不好,赶紧全拆了!”——看到复杂度数字超标就恐慌,不分青红皂白地重构,结果拆出来的代码更绕。

正解圈复杂度高只说明“控制流的路径多”,不代表“写代码的人水平差”。有些业务天生复杂,比如状态机、权限矩阵、解析器,复杂度高是业务本身的映射。合理做法是:先看复杂度是否与业务复杂度匹配。如果业务就是 18 种情况,那高复杂度是正常的,应该做的是用表驱动、策略模式把复杂度收纳好,而不是硬拆成一堆互相调用的碎片函数。

为何常见:因为指标被包装成“越低越好”的 KPI,大家形成条件反射,看到数字高就条件反射式地自我否定,忽略了“指标要结合上下文解释”这一基本原则。

误区“认知复杂度不就是圈复杂度的升级版吗?数值当然是一样的!”——许多开发者认为这两个指标度量同一个东西,只是叫法不同。

正解两者度量的维度完全不同。圈复杂度数的是“独立路径条数”,它不考虑路径怎么排列;认知复杂度算的是“人脑阅读理解时的负担”,专门惩罚嵌套——同样 5 个 if 平铺是 5 分,嵌套 3 层的 if 则要按 1+2+3 累加。圈复杂度适合评估“测试覆盖要写多少用例”,认知复杂度适合评估“同事看代码要费多少脑力”,它们经常数值不同、结论也不同。

为何常见:因为 SonarQube 在展示时把两个指标放在同一个页面上,数字还很接近,不读文档的话很容易把两者画等号。

误区“我的函数圈复杂度只有 4,认知复杂度也只有 5,说明我写的代码质量很好,可以放心了!”——以为只要控制流简单,代码就没有问题。

正解复杂度只覆盖“控制流”和“信息量”两个维度,代码质量还包括命名清晰度、职责单一性、重复率、耦合度等。一个圈复杂度 4 的函数可能起名 do_stuff,参数有 10 个,重复逻辑散落四处——复杂度低也救不了它。复杂度低只是“必要条件”,不是“充分条件”。它告诉我们“这条路不绕”,但没告诉我们“这条路通向哪里、路边干不干净”。

为何常见:因为复杂度是最容易数字化、最容易写进 KPI 的指标,人天生倾向于“KPI 达标=做得好”,忽略了指标只是探照灯,不是全景图。

误区“只要 CI 里加了复杂度门禁,代码质量就稳了,人工评审可以省了。”——把质量保障全权交给机器人。

正解工具能高效地抓“结构异常”,但抓不到“语义问题”——比如这段代码逻辑上根本没有必要存在、接口设计违背直觉、业务规则理解错误。复杂度门禁可以拦截“圈复杂度超 10 的提交”,但拦不住“一个复杂度过 8 但设计荒谬的提交”。正确姿势是人机协作:机器人负责无差别扫描、守住底线;人工评审负责判断例外、审视设计。机器人提高下限,人提高上限。

为何常见:因为自动化门禁“红灯即拦截”的反馈非常直观,容易让人产生“机器已经替我把关了”的错觉,忽略了质量的最上层——设计判断力——是机器无法替代的。

误区“既然复杂度高不好,那我把所有大函数都拆成两行一个的小函数,复杂度不就归零了?”——把“拆分”当作降低复杂度的万能药。

正解拆函数确实能把单个函数的复杂度降下来,但过度拆分会让调用链变长、命名变杂、跳转变多——认知负担从“函数内部”转移到了“函数之间”。一个 50 行的函数,拆成 25 个两行函数再加 25 处调用,读起来反而更累。降复杂度的目标是“让代码更易理解”,而不是“让指标数字更小”。合理的做法是:按职责边界拆分,每个函数有清晰的单一目的,控制拆分的粒度,用可读性而不是数字来检验效果。

为何常见:因为复杂度指标会被纳入绩效考核,开发者会瞄准数字做“指标体操”——只要数字降了就算完成任务,忘记了指标只是逼近目标的工具,目标本身是“人读起来轻松”。

面试题

Q1. 圈复杂度公式 V(G) = E - N + 2 中的 E 代表什么?

单选题 · easy

  • A.模块中边的条数,语句之间的跳转总数← 正确答案
  • B.模块中节点的数目,语句块的总数量
  • C.模块中函数调用的次数,调用语句总和
  • D.模块中条件判断的个数,判定节点总和

答案:A。E 是控制流图中的边数,代表节点之间的跳转关系;N 是节点数。C 选项的函数调用次数和 D 选项的判定节点数都不是 E 的定义。判定节点数实际对应的是另一个常用表达式“V(G) = 判定节点数 + 1”,不要混淆。

Q2. 一个函数里有 5 个互不嵌套的 if 语句,它的圈复杂度是多少?

单选题 · mid

  • A.数值等于 5,直接对应 5 个 if 分支
  • B.数值等于 6,对应 6 条独立执行路径← 正确答案
  • C.数值等于 4,因为 5 减 1 才是有效分支
  • D.数值等于 10,测试用例至少需要 10 个

答案:B。圈复杂度 = 判定节点数 + 1。5 个 if 是 5 个判定节点,所以 V(G) = 5 + 1 = 6,代表代码有 6 条独立路径,也因此至少需要 6 个测试用例才能实现分支全覆盖。A 漏掉了“起点补 1”;D 混淆了用例数与复杂度上限。

Q3. 以下哪些结构会被圈复杂度算法计入判定节点?

多选题 · mid

  • A.if / else 这样的条件分支语句← 正确答案
  • B.for / while 这样的循环语句← 正确答案
  • C.try / catch 这样的异常处理块← 正确答案
  • D.单行赋值与函数定义语句

答案:ABC。if、循环、catch 都会产生控制流分叉,使路径分裂,因此都算判定节点。单行赋值只是顺序执行的语句,不产生新路径,不计入圈复杂度。注意:虽然不同工具对 try 块的计数略有差异,但主流实现都会把异常处理算作一个决策点。

Q4. 认知复杂度与圈复杂度最大的区别是什么?

单选题 · easy

  • A.认知复杂度只统计循环不统计条件分支
  • B.认知复杂度对嵌套结构额外加权惩罚← 正确答案
  • C.认知复杂度只适用于多态机制的代码
  • D.认知复杂度直接统计代码注释的行数

答案:B。认知复杂度的核心创新就是对嵌套层级的惩罚:平铺的 5 个 if 是 5 分,嵌套的 if 则每层累加。圈复杂度只数“有几个判定”,不关心判定怎么嵌套。A、C、D 都不是认知复杂度的定义,它是为“人脑阅读负担”设计的指标。

Q5. 以下哪些符号在 Halstead 指标中属于“操作数”?

多选题 · hard

  • A.变量名,如 userCount、orderList 等← 正确答案
  • B.常量字面量,如 42、'hello'、True← 正确答案
  • C.if、for、while 等关键字符号
  • D.自定义函数名,如 calculateTotal 等← 正确答案

答案:ABD。Halstead 把代码符号分为两类:操作数是“被处理的数据”——变量、常量、函数名都算;运算符是“执行的动作”——关键字、算术符号、比较符号都算。A、B、D 是操作数,C 是运算符。注意函数名虽然是“调用动作”,但它作为整体符号在统计中被视为操作数。

Q6. 某函数圈复杂度为 18,团队在 CI 中设置了阈值 10 的质量门禁,会发生什么?

单选题 · mid

  • A.构建直接跳过复杂度检查这个环节
  • B.门禁判定失败并阻断该函数的合并← 正确答案
  • C.门禁自动把阈值上调到 20 再放行
  • D.编译通过但运行时性能显著变差

答案:B。质量门禁的逻辑是“超标即拦截”:18 > 10,流水线失败,合并请求被阻断,开发者需要先拆分重构或申请特批。门禁不会自动放宽阈值,也不会影响编译与运行时性能——复杂度是静态指标,与运行性能无直接关系。

Q7. 重构优先级排序最合理的量化组合是什么?

单选题 · mid

  • A.圈复杂度与函数代码行数的比值
  • B.圈复杂度与模块修改频率的乘积← 正确答案
  • C.认知复杂度与注释覆盖率的求和
  • D.Halstead 词汇量与团队成员数的积

答案:B。风险指数 = 圈复杂度 × 修改频率,它同时衡量“改起来多危险”和“多久会碰一次”,两者相乘得到未来踩雷的期望值。行数比、注释率、团队规模都不是公认的风险度量。这题考察的是“风险 = 危险程度 × 暴露频率”的工程直觉。

Q8. 技术债比率 = 修复成本 / 开发成本,这个指标主要用于什么?

单选题 · mid

  • A.盘点历史代码里复杂结构的件数
  • B.估算存量代码维护成本占开发投入的比例← 正确答案
  • C.检测运行时内存泄漏的发生概率
  • D.计算自动化测试的执行总时长

答案:B。技术债比率的含义是“把现有问题全部修好所需时间”与“写这些代码花的时间”之比,比如 10% 意味着每写 10 小时代码欠下约 1 小时修债时间。A 只是件数不是成本比;C、D 与静态复杂度分析无关。

Q9. 函数 A 圈复杂度为 12,函数 B 圈复杂度为 9,单独看哪个更可能需要重构?

单选题 · easy

  • A.函数 A,因为它的路径更多、测试更难做← 正确答案
  • B.函数 B,因为复杂度小于 10 更容易维护
  • C.两者都不需要做任何代码方面的优化
  • D.无法比较,圈复杂度根本没有衡量意义

答案:A。圈复杂度 12 超过了常用阈值 10,属于“中等偏高风险”,路径更多、覆盖测试更难,重构优先级更高;9 在阈值以内,相对健康。D 说“没有衡量意义”过于绝对。注意这只是单看复杂度,实际排优先级还应结合修改频率。

Q10. 为什么说“复杂度低于阈值”不等于“代码质量高”?

多选题 · hard

  • A.复杂度只衡量控制流与信息量的维度← 正确答案
  • B.低复杂度无法反映重复代码等坏味道← 正确答案
  • C.低复杂度不能保证命名清晰与职责单一← 正确答案
  • D.低复杂度必然导致运行时性能越来越差

答案:ABC。复杂度是“探照灯”不是“全景图”,它只照亮控制流分叉和信息量,照不到重复代码、糟糕命名、过长参数列表、高耦合等质量问题。因此低复杂度只是质量的必要条件。D 的说法无依据——复杂度与运行性能没有正比关系,低复杂度不会“必然”让性能变差。