认知复杂度——代码质量初探
文章目录
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 类型:
- 循环:
for
,while
,do
,while
…… - 条件:三元运算符,
if
,#if
,#ifdef
……
还有一下 Hybrid 类型:else if
,elif
,else
……
这些 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 。
文章作者 bigshans
上次更新 2021-05-06