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中:

  1. 选中目标代码
  2. Ctrl+Alt+M(或Ctrl+Enter
  3. 输入方法名

intellij extract method

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中:

  1. 选中if语句
  2. Alt+Enter
  3. 选择"Invert 'if' condition"

inverting if

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移动声明):

move declaration closer

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提出认知复杂度指标,是为了精准评估代码可读性和可维护性。本文我们:

  1. 掌握了认知复杂度的计算规则
  2. 识别了破坏代码线性流程的关键结构
  3. 学会了通过重构降低复杂度的实用技巧

通过IDE辅助重构,我们将一个复杂度11的函数优化到了2。记住:低认知复杂度 = 高可维护性,这是写出优雅代码的秘诀。


原始标题:Cognitive Complexity and Its Effect on the Code | Baeldung