程序员圈子里经常有这么个场景:接手一个老项目,翻开某个函数,里面嵌套了七八层 if,中间还夹杂着两个 for 循环和一堆 try/except,光是理清楚这段代码到底能走出多少种结果,就得花上半个小时。这种直觉上的"乱",其实是可以量化的,量化它的工具就是循环复杂度(Cyclomatic Complexity)。这篇文章会把这个概念从数学根源讲透,再落到 Python 实战上,给出真正能看懂、能照着写的代码示例。
🧭 循环复杂度是什么,怎么算出来的
循环复杂度是软件工程师 Thomas J. McCabe 在 1976 年提出的一个度量指标,用来衡量一段代码里有多少条相互独立的执行路径。这个概念的底层其实是图论,你可以把一段代码的控制流想象成一张有向图,图里的节点代表一个个顺序执行的代码块,边则代表执行流从一个块跳转到另一个块的可能路径。
McCabe 给出的正式公式长这样
M=E−N+2P
其中 E 是图中边的数量,N 是节点数量,P 是连通分量的个数。对于绝大多数单独的函数来说, P 恒等于 1,公式就简化成
M=E−N+2
这套公式听起来挺抽象,但好消息是日常写代码根本用不着真去画控制流图。McCabe 自己证明过,对于只有一个入口一个出口的结构化程序,循环复杂度就等于判断点的数量加一 。所谓判断点,指的是那些会让程序产生分支的语句------if、elif、for、while、case、except,甚至逻辑运算符 and 和 or 也算,因为它们本质上是在做隐藏的条件判断。
举个例子,一个完全没有分支的函数,复杂度是 1,因为只有一条路能走到底。加一个单条件的 if,复杂度变成 2,因为多了条件成立和不成立两条路。两个嵌套的 if,复杂度是 3。逻辑一路加下去,函数里判断点越多,复杂度数值就越高。
📊 复杂度数值意味着什么风险
McCabe 当年在给美国国土安全部做演示时,给出过一套风险分级标准,后来被广泛采用。
| 复杂度区间 | 风险等级 | 含义 |
|---|---|---|
| 1--10 | 低 | 结构简单,容易测试和维护 |
| 11--20 | 中 | 逻辑变复杂,需要仔细测试,建议考虑重构 |
| 21--50 | 高 | 测试难度大,是重构的重点候选对象 |
| 50+ | 极高 | 几乎无法充分测试,缺陷风险陡增 |
美国国家标准与技术研究院(NIST)后来把 10 作为单个函数复杂度的推荐上限,这个数字被很多团队直接写进了 CI/CD 的代码质量门槛里,超过阈值的 Pull Request 会被自动拦下来。
这里有个容易被忽略的细节,高复杂度不等于代码写得烂。状态机、协议解析器、配置校验器这类天生就要处理很多分支的场景,复杂度高是问题本身的必然要求 ,而不是代码质量差的表现。真正该被重构的,是那种"本可以更简单却被人为写复杂"的意外复杂度 ,而不是业务逻辑本身带来的本质复杂度。判断标准很简单,问自己一句,把这段逻辑拆成更小的函数,是真的降低了理解成本,还是只是把复杂度搬到了别处。
🧠 一张图直观理解控制流
拿一个简单函数举例,里面有个循环加一个条件分支,用控制流图画出来是这样的

数一下这张图,9 条边,8 个节点,1 个连通分量,代入公式 M=9−8+2×1=3,跟直接数判断点(循环 + 条件判断共两个)加一得到的结果完全吻合。这也是为什么"数判断点加一"的土办法在实际工作里够用了,正式公式更多是用来做理论推导和证明的。
🐍 Python 里怎么写出复杂度低、职责清晰的代码
理论讲完了,回到写代码这件事上。降低循环复杂度不是目的本身,真正的目的是让代码好测试、好维护、别人接手能看懂。下面用一个具体场景,演示从"能跑但很乱"到"清晰有序"的重构过程。
一个复杂度爆表的反面教材
假设要写一个函数,根据用户的信用分、历史记录和账户类型,判断贷款申请该批准、拒绝还是转人工审核。很多人第一反应是这么写
python
def evaluate_loan(score, history, account_type, income, debt):
if score < 300:
if history == "poor":
return "reject"
else:
if account_type == "premium":
return "manual_review"
else:
return "reject"
else:
if score < 600:
if history == "poor":
if debt / income > 0.5:
return "reject"
else:
return "manual_review"
else:
return "manual_review"
else:
if history == "excellent":
return "approve"
else:
if account_type == "premium":
return "approve"
else:
return "manual_review"
这个函数光是判断点就有 8 个(数一下 if 和 else if 的数量),循环复杂度大概是 9,已经踩到 McCabe 划定的中等风险区间了。更麻烦的是,这种深层嵌套结构让人追踪逻辑非常费劲 ,改一行代码之前得先在脑子里模拟整棵判断树,稍不注意就会引入 bug。用 radon 这类工具跑一下(radon cc filename.py -s)就能直接量化出这个分数,不用靠肉眼估计。
用卫语句和函数拆分重构
重构思路其实很朴素,业界总结出的常见手法是卫语句(guard clause)提前返回 和提取方法(extract method) 这两板斧。核心逻辑是把嵌套的判断"拍平",让每个函数只负责一件事。
python
def evaluate_loan(score, history, account_type, income, debt):
"""根据信用分、历史记录和账户类型评估贷款申请。"""
if _is_low_score_reject(score, history, account_type):
return "reject"
if score >= 600:
return _evaluate_high_score(history, account_type)
if score >= 300:
return _evaluate_mid_score(history, income, debt)
return "manual_review"
def _is_low_score_reject(score, history, account_type):
"""低分段直接拒绝的情形单独抽出来判断。"""
if score >= 300:
return False
if history == "poor":
return True
return account_type != "premium"
def _evaluate_mid_score(history, income, debt):
"""中等信用分段的评估逻辑。"""
if history != "poor":
return "manual_review"
debt_ratio = debt / income
return "reject" if debt_ratio > 0.5 else "manual_review"
def _evaluate_high_score(history, account_type):
"""高信用分段的评估逻辑。"""
if history == "excellent":
return "approve"
if account_type == "premium":
return "approve"
return "manual_review"
拆完之后,主函数 evaluate_loan 的复杂度直接降到 4,每个辅助函数的复杂度都不超过 3。更重要的是每个函数名都清楚地表达了它在做什么,低分拒绝判断 、中分段评估 、高分段评估,各管一段,互不干扰。想给某个分段加新规则,直接进对应的小函数改就行,完全不用担心牵一发动全身。
几条实用的重构原则
结合业界常见的实践方法,降低复杂度、提升代码可读性主要靠这几招
- 卫语句提前返回,把边界情况和异常情况在函数开头就处理掉、直接返回,避免主逻辑被层层嵌套包裹
- 提取方法,把一段有独立语义的逻辑拆成单独函数,函数名本身就是最好的注释
- 用字典或枚举替代长串 if-elif ,比如把状态映射关系写成字典查表,而不是几十个
elif堆一起 - 善用早期 continue/break,循环体内提前跳过不满足条件的情况,减少缩进层级
- 组合条件抽成有意义的布尔变量 ,比如把
debt / income > 0.5赋值给is_high_debt_ratio,既降低了复杂度计算里逻辑运算符带来的分支数,又让代码读起来像人话
至于什么时候不用死磕复杂度指标,前面提过,状态机、协议解析、多字段校验这类场景本身就是分支密集型的,硬拆成一堆碎片函数只会让逻辑变得支离破碎、难以追溯,这时候更合理的做法是保留合理的复杂度,同时把测试用例写全,并在代码里留个注释说明为什么这里选择不拆。
🔧 顺手一提,工具帮你自动算分
手动数判断点在函数不大的时候没问题,但项目大了谁也不可能一个个数过去。Python 生态里最常用的工具是 Radon ,它会解析代码的抽象语法树(AST),自动算出每个函数、每个类的循环复杂度,还能顺带给出可维护性指数这类衍生指标。命令行跑一句 radon cc yourfile.py -s -a 就能拿到全项目的复杂度报告,配合 CI 流水线设个阈值,超标的代码提交时就会被自动拦截,这也是不少团队实际在用的做法。
💡 写在最后
循环复杂度这个指标诞生快五十年了,到今天依然是评估代码质量最常用的量化手段之一,原因很简单,它把"这段代码是不是太乱了"这种主观感受变成了一个客观数字,团队沟通和代码审查都有了共同的标尺。但数字终归只是工具,真正决定代码好不好维护的,是判断点背后的业务逻辑有没有被合理组织。卫语句、方法提取、用查表代替长串条件判断,这些手法看似简单,坚持用下去,就能让接手代码的下一个人(很可能是三个月后的自己)少踩很多坑。
参考资料
Wikipedia. Cyclomatic complexity . en.wikipedia.org/wiki/Cyclom...
GeeksforGeeks. Cyclomatic Complexity . www.geeksforgeeks.org/dsa/cycloma...
SciTools. Understanding McCabe Cyclomatic Complexity . support.scitools.com/support/sol...
Tanner, M. Sourcegraph Blog. Cyclomatic complexity: What it is and how to reduce it . sourcegraph.com/blog/cyclom...
Augment Code. How to Reduce Cyclomatic Complexity . www.augmentcode.com/learn/how-t...
Novak, B. Medium. Reducing Cyclomatic Complexity in your code . medium.com/@brooknovak...
PyPI. Radon . pypi.org/project/rad...
Radon Documentation. Introduction to Code Metrics . radon.readthedocs.io/en/latest/i...