Cognitive Complexity ,即认知复杂度,是来自于 Sonar 官方的一个概念。认知复杂度主要是以可测量的方式,将代码估算成一个数字,用以衡量代码的理解难度的。它基于一下三条准则:

  • 鼓励在代码中使用一些语法糖,将多句代码合并成一句。
  • 各种控制语句、操作符、递归、jump to label 等会增加代码的阅读成本。
  • 多层嵌套结构会使代码更加难以理解。

复杂度得分的来源

我们从上述原则中抽象出如下几个不同的类型作为得分的来源:

A. Nesting:把一段代码逻辑嵌套在另一段逻辑中; B. Structural:被嵌套的控制流结构; C. Fundamental:不受嵌套影响的语句; D. Hybrid:一些控制流结构,但不包含在嵌套中;

具体的内容解释有点抽象,我们将结合后续代码进行理解。

复杂度的具体评估

忽略语法糖带来的简写

在认知复杂度的制定想法中,一个指导性的原则是:激励使用者写出好的编码规范。也就是说,需要无视或低估让代码更可读的 feature(不计算进复杂度)。

举个例子:

let myObj = null;
if (a !== null) {
    myObj = a.myObj;
}

其认知复杂度将会 +1 。

let myObj = a?.myObj;

认知复杂度不变。

二者的代码是等效的,但后者可能要理解一下,不过,一旦理解了这种语法(null-coalescing),后者的代码就变得直观了起来,同时代码也变得简洁。出于这样的原因,计算认知复杂度时就会忽略掉 null-coalescing 操作。

打断线性代码流程导致复杂度增加

在认知复杂度的制定想法中,另一项指导原则是:结构控制会打断一条线性的流从头到尾走完,使代码的维护者需要花更大功夫来理解代码。在认定了这会导致额外负担的前提下,认知复杂度评估了以下几种会增加复杂度的 Structural 类型:

  • 循环:forwhiledowhile ……
  • 条件:三元运算符,if#if#ifdef……

还有一下 Hybrid 类型:else ifelifelse ……

这些 Hybrid 类型不计入 Nesting 类型里面,因为在 if 的时候已经计算了。

除了这些与圈复杂度类似的计算方式,还会额外计算:

Catches

一个catch表达了控制流的一个分支,就像if一样。因此每个catch语句都会增加Structural类的认知复杂度,仅加1分,无论它catch住多少种异常。(在我们的计算中try\finally被直接忽略掉)

Switches

一个switch语句,和它附带的全部case绑在一起记为一个Structural类,来增加复杂度

在圈复杂度下,一个switch语句被视为一系列的if-else链,因此每个case都会增加复杂度,因为会使得控制流分支增多。

但以代码维护的视角来看,一个switch:将单个变量与一组显式的值比较,要比if-else链易于理解,因为if-else链可以用任意条件做比较。就是说if-else if链必须仔细的逐个阅读条件,而switch通常是可以一目了然的。

一系列的逻辑操作

认知复杂度不对每一个逻辑运算符计分,而是考虑对连续的一组逻辑操作加分。比如一下代码是不加分的:

a && b
a && b && c && d
a || b
a || b || c || d

理解后一行并不比前一行困难,但对于下面两行,理解难度有质的区别:

a && b && c && d
a || b && c || d

boolean 操作表达式混合使用会打断人之前的思维,使得理解更为困难,因此对此类操作没出现一个,认知复杂度都会不断递增。例如:

if (a              // +1 for if          
    && b && c      // +1
    || d || e      // +1
    && f)          // +1
if (a              // +1 for if
   &&              // +1
   !(b && c))      // +1

递归

与圈复杂度不同,认知复杂度对每一个递归调用,都增加一点Fundamental类复杂计分,不论是直接还是间接的。有两个这样做的动机:

  • 递归表达了一种“元循环”,并且循环会增加认知复杂度;
  • 认知复杂度希望能用于估计一个方法,其控制流难以理解的程度,而即使是一些有经验的程序员,都觉得递归难以理解;

Jumps to labels

goto 增加了认知复杂度,因为 do、break、continue 等标签和其他多级跳转(例如break 或 continue)已经在某些语言中找到了确切的位置。但是,由于提早返回通常可以使代码更清晰,因此没有其他跳跃或提早退出引起增量。

嵌套打断思路造成的复杂度增加

一种非常直观的感受,即相比连续嵌套的五个结构,线性连续的五个 if 或 for 会好理解的多,因此,认知复杂度在计算时会将其视为一个 Nesting 类型的增加。特别地,每增加一个嵌套,就会增加一个 Nesting 。我们直接看例子:

try {
    if (condition1) {                     // +1
        for (int i = 0; i < 10; i++) {    // +2 (nesting=1)
            while (condition2) {}         // +3 (nesting=2)
        }
    }
} catch (e) {                             // +1
    if (condition2) {}                    // +2 (nesting=1)
}                                         // total 9

认知复杂度的意义

认知复杂度制定的主要目标,是为方法计算出一个得分,准确地反应出此方法的相对理解难度。它的次要目标,是解决现代语言结构的问题,并产生在方法级别以上也有价值的指标。编写和维护代码是一个人为过程,它们的输出必须遵守数学模型,但它们本身不适合数学模型。 这就是为什么数学模型不足以评估其所需的工作量的原因。

认知复杂性不同于使用数学模型评估软件可维护性的实践。 它从圈复杂度设定的先例开始,但是使用人工判断来评估应如何对结构进行计数,并决定应向模型整体添加哪些内容。 结果,它得出的方法复杂性得分比以前的模型更能吸引程序员,因为它们是对可理解性的更公平的相对评估。 此外,由于认知复杂性不收取任何方法的“入门成本”,因此它不仅在方法级别,而且在类和服务级别,都产生了更加准确的评估结果。

表驱动编程法

数据比程序逻辑更易驾驭。尽可能把设计的复杂度从代码转移至数据是个好实践。——《 Unix 编程艺术》

最后讲一下表驱动编程法,这是一个很好的降低认知复杂度的方法,但第一眼往往不太直观。

我们举个例子:

const { id, name, status, address } = res.body;
let update = {};
if (id !== undefined && id !== null) {
    update.id = id;
}
if (name !== undefined && name !== null) {
    update.name = name;
}
if (status !== undefined && status !== null) {
    update.status = status;
}
if (address !== undefined && address !== null) {
    update.address = address;
}

我们发现我们的几处 if 结构类似,这里隐隐提示我们这里存在着优化的可能,在这里我们尝试用表驱动编程法去优化该段代码:

let update = {};
for (const field of ['id', 'name', 'status', 'address']) {
    const t = res.body[field];
    if (t !== undefined && t !== null) {
        update[field] = t;
    }
}

代码就短很多了。

后一段代码不直观的原因在于它更抽象了,它将数据与逻辑抽离,但我们因此得到了好处。因为前一段代码的逻辑冗余,使得每多加一个字段的校验,就得多写一段 if 去处理,如果需要校验的字段足够多,我们就根本无法从这些代码里直观得到代码逻辑的相似之处,最终因为坚持直观而丧失了直观。后一段的优点在于,这里的逻辑复杂度并没有增加,复杂的是数据,而且,比起逻辑, 人更擅长处理数据

总结

总结一下,我们为什么要用认知复杂度,因为我们要有一个可衡量的指标去量化我们的直观,但这种直观是不可量化的,因此认知复杂度并不能完全替代我们对代码难度的理解,但可以指导我们优化我们代码。

大部分抄了这里的东西,附了一些自己的理解,这篇基本上是对 Sonar 论文的简单翻译: https://www.jianshu.com/p/cd6da0a2fbcf

论文原文,我的一些内容从里面翻译: https://www.sonarsource.com/docs/CognitiveComplexity.pdf