1. 概述
本文将深入探讨认知复杂度的概念及其计算方法,逐步分析导致函数认知复杂度升高的各种代码模式与结构。我们将重点研究以下元素:
- 循环
- 条件语句
- 跳转标签
- 递归
- 嵌套结构
随后,我们将讨论认知复杂度对代码可维护性的负面影响,最后介绍几种通过重构降低认知复杂度的实用技巧。
2. 圈复杂度 vs 认知复杂度
长期以来,圈复杂度是衡量代码复杂度的唯一标准。但这个指标存在明显缺陷,催生了更精准的度量标准——认知复杂度。
2.1. 圈复杂度
圈复杂度是最早的代码复杂度度量指标,由Thomas J. McCabe于1976年提出,其定义为代码中独立路径的数量。
例如,包含5个分支的switch
语句圈复杂度为5:
public String tennisScore(int pointsWon) {
switch (pointsWon) {
case 0: return "Love"; // +1
case 1: return "Fifteen"; // +1
case 2: return "Thirty"; // +1
case 3: return "Forty"; // +1
default: throw new IllegalArgumentException(); // +1
}
} // 圈复杂度 = 5
虽然圈复杂度能量化代码路径数量,但无法精确比较不同函数的复杂度。它忽略了关键因素:
- 多层嵌套
break
/continue
等跳转标签- 递归
- 复杂布尔运算
导致某些客观上更难理解的代码,圈复杂度却不高。例如countVowels
函数圈复杂度也是5:
public int countVowels(String word) {
int count = 0;
for (String c : word.split("")) { // +1
for(String v: vowels) { // +1
if(c.equalsIgnoreCase(v)) { // +1
count++;
}
}
}
if(count == 0) { // +1
return "does not contain vowels";
}
return "contains %s vowels".formatted(count); // +1
} // 圈复杂度 = 5
2.2. 认知复杂度
为此,Sonar公司提出了认知复杂度指标,核心目标是精确度量代码可理解性,其设计初衷是鼓励开发者通过重构提升代码质量和可读性。
虽然我们可以用SonarQube等静态分析工具自动计算认知复杂度,但理解其计算原理和核心原则仍很重要:
✅ 原则1:对提升可读性的结构不扣分(如方法提取、提前返回)
✅ 原则2:线性流程中断点+1分(循环、条件、try-catch等)
✅ 原则3:嵌套结构额外加分(每层嵌套+1分)
回看之前的例子:
tennisScore
函数认知复杂度仅为1(无嵌套)countVowels
函数因嵌套循环被重罚,复杂度高达7:
public String countVowels(String word) {
int count = 0;
for (String c : word.split("")) { // +1
for(String v: vowels) { // +2 (嵌套层级=1)
if(c.equalsIgnoreCase(v)) { // +3 (嵌套层级=2)
count++;
}
}
}
if(count == 0) { // +1
return "does not contain vowels";
}
return "contains %s vowels".formatted(count);
} // 认知复杂度 = 7
3. 线性流程中断点
低认知复杂度的代码应能从上到下顺畅阅读。以下结构会破坏线性流程,导致复杂度加分:
结构类型 | 示例 | 加分 |
---|---|---|
条件语句 | if , 三元运算符, switch |
+1 |
循环 | for , while , do while |
+1 |
异常处理 | try-catch |
+1 |
递归 | 方法自调用 | +1 |
跳转标签 | continue , break |
+1 |
逻辑运算符序列 | `a && b |
看这个实际案例:
public String readFile(String path) {
// if语句+1; 逻辑运算符序列("or"和"not")+2
String text = null;
if(path == null || path.trim().isEmpty() || !path.endsWith(".txt")) {
return DEFAULT_TEXT;
}
try {
text = "";
// 循环+1
for (String line: Files.readAllLines(Path.of(path))) {
// if语句+1
if(line.trim().isEmpty()) {
// 跳转标签+1
continue OUT;
}
text+= line;
}
} catch (IOException e) { // catch块+1
// if语句+1
if(e instanceof FileNotFoundException) {
log.error("could not read the file, returning the default content..", e);
} else {
throw new RuntimeException(e);
}
}
// 三元运算符+1
return text == null ? DEFAULT_TEXT : text;
}
这些流程中断点使该方法认知复杂度高达9,代码可读性堪忧。
4. 嵌套的流程中断结构
每增加一层嵌套,代码可读性就指数级下降。嵌套的if
/else
/catch
/switch
/循环/lambda表达式会额外加分:
public String readFile(String path) {
String text = null;
if(path == null || path.trim().isEmpty() || !path.endsWith(".txt")) {
return DEFAULT_TEXT;
}
try {
text = "";
// 嵌套层级=1
for (String line: Files.readAllLines(Path.of(path))) {
// 嵌套层级=2 => 复杂度+1
if(line.trim().isEmpty()) {
continue OUT;
}
text+= line;
}
// 嵌套层级=1
} catch (IOException e) {
// 嵌套层级=2 => 复杂度+1
if(e instanceof FileNotFoundException) {
log.error("could not read the file, returning the default content..", e);
} else {
throw new RuntimeException(e);
}
}
return text == null ? DEFAULT_TEXT : text;
}
最终认知复杂度达到11!但别担心,通过重构我们可以大幅降低这个数值。
5. 重构技巧
以下是几种降低认知复杂度的实用重构方法,现代IDE都能安全高效地完成这些操作。
5.1. 提取代码
提取方法/类是最有效的重构手段,能压缩代码且不扣分。例如提取路径验证逻辑:
在IntelliJ中:
- 选中目标代码
- 按
Ctrl+Alt+M
(或Ctrl+Enter
) - 输入方法名
private boolean hasInvalidPath(String path) {
return path == null || path.trim().isEmpty() || !path.endsWith(".txt");
}
5.2. 条件反转
简单粗暴但有效:反转if
条件能减少嵌套层级。原代码:
if(line.trim().isEmpty()) {
continue; // 跳转标签扣分
}
重构后:
if(!line.trim().isEmpty()) {
text += line;
}
在IntelliJ中:
- 选中
if
语句 - 按
Alt+Enter
- 选择"Invert 'if' condition"
5.3. 语言特性
善用语言特性避免流程中断:
- 用多
catch
块替代嵌套的if-else
- 用
Optional
替代null
检查 - 用模式匹配(Java 14+)简化类型判断
5.4. 提前返回
提前返回能让方法更短更清晰。原代码结尾的三元运算符:
return text == null ? DEFAULT_TEXT : text;
可改为提前返回:
if(text == null) {
return DEFAULT_TEXT;
}
return text;
配合变量作用域最小化(IntelliJ中按Alt+M
移动声明):
5.5. 声明式编程
声明式代码通常嵌套更少、复杂度更低。例如用Java Streams重构:
public String readFile(String path) {
// if语句+1; 逻辑运算+1
if(hasInvalidPath(path) || fileDoesNotExist(path)) {
return DEFAULT_TEXT;
}
try {
return Files.lines(Path.of(path))
.filter(not(line -> line.trim().isEmpty()))
.collect(Collectors.joining(""));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
认知复杂度从11骤降至2!✅
6. 总结
Sonar提出认知复杂度指标,是为了精准评估代码可读性和可维护性。本文我们:
- 掌握了认知复杂度的计算规则
- 识别了破坏代码线性流程的关键结构
- 学会了通过重构降低复杂度的实用技巧
通过IDE辅助重构,我们将一个复杂度11的函数优化到了2。记住:低认知复杂度 = 高可维护性,这是写出优雅代码的秘诀。