一种测量可理解性的新方法
*By G. Ann Campbell, SonarSource SA*
2021年4月5日,版本 1.5
摘要
循环复杂度最初是作为模块控制流的“可测试性和可维护性”的度量而制定的。虽然它擅长测量“可测试性”,但其潜在的数学模型在测量“可维护性”的时候并不能令人满意。这篇白皮书描述了一种新的度量方法,它打破了使用数学模型来评估代码,弥补了循环复杂度的缺点,是一种能更准确地反映理解的相对难度的度量方法,因此也能够反映维护方法、类和应用程序的相对难度。
关于术语的说明
虽然认知复杂度是一个与语言无关的度量标准,所以它同样适用于文件和类,以及方法、过程、函数等等,但为了方便起见,统一使用面向对象的术语“类”和“方法”。
简介
Thomas J. McCabe 的循环复杂度长期以来一直是测量方法控制流复杂性的实际标准。它最初的目的是“识别难以测试或维护的软件模块”[1],但是当它准确地计算出完全覆盖一个方法所需的最小测试用例数量时,它并不是一个令人满意的可理解性度量。这是因为具有相同循环复杂度的方法并不一定会给维护人员带来相同的维护难度,这导致了一种测量结果不准的感觉。
与此同时,循环复杂度也不全面。它是在 1976 年的 Fortran 环境中制定的,不包括像try/catch和lambdas这样的现代语言结构。
最后,因为每个方法的最小循环复杂度分数都是 1,所以不可能知道任何给定的具有高聚合循环复杂的度类是大型的、易于维护的域类,还是具有复杂控制流的小型类。在类级别之外,人们普遍认为应用程序的循环复杂度分数与它们的代码行总数相关。换句话说,循环复杂度在方法级以上就没什么用了。
作为对这些问题的补救措施,制定了认知复杂度来度量现代语言的代码结构,在类和应用程序级别给出有意义的值。更重要的是,它脱离了基于数学模型来评估代码的方式,从而可以产生与程序员对理解这些流所需的心力成本相对应的评估结果。
问题说明
在讨论认知复杂度的时侯,最好先举一个它所要解决的问题的例子。以下两个方法具有相同的循环复杂度,但在可理解性方面却截然不同。
int sumOfPrimes(int max) { // +1
int total = 0;
OUT: for (int i = 1; i <= max; ++i) { // +1
for (int j = 2; j < i; ++j) { // +1
if (i % j == 0) { // +1
continue OUT;
} }
total += i;
}
return total;
} // 循环复杂度 4
String getWords(int number) { // +1
switch (number) {
case 1: // +1
return "one";
case 2: // +1
return "a couple";
case 3: // +1
return "a few";
default:
return "lots";
}
} // 循环复杂度 4
循环复杂度的数学模型赋予了这两个方法相同的权重,但是明显感觉 sumOfPrime 的控制流比 getWords 的控制流更难理解。这就是为什么认知复杂度放弃使用数学模型来评估控制流,而采用一套简单的规则来将程序员的直觉转化为数字的原因。
基本规则
根据三个基本规则评估认知复杂度分数:
- 忽略允许将多个语句简写成一个语句的结构。
- 在代码的线性流中每中断一次就增加一。
- 当流的中断结构是嵌套的时进行增量。
此外,复杂度分数由四种不同类型的增量组成:
- 嵌套 - 评估在彼此内部嵌套控制流结构。
- 结构 - 评估受嵌套增量影响并增加嵌套计数的控制流结构。
- 基本 - 评估不受嵌套增量约束的语句。
- 混合 - 评估不受嵌套增量影响但确实增加嵌套计数的控制流结构。
虽然增量的类型在数学上没有区别 - 每个增量都会在最终得分上加一 - 区分被计算的特征类别可以更容易地理解嵌套增量在哪里适用和不适用。
这些规则及其背后的原则将在以下部分中进一步详细说明。
忽略简写
制定认知复杂度的指导原则是它应该激励良好的编码实践。 也就是说,它应该忽略或减少关注语言中使代码可读性更强的特性。
方法结构本身就是一个很好的例子。将代码分解成方法允许您将多个语句压缩成一个方法的调用,即“简写”它。因此,认知复杂度不会随着方法数量增加而增加。
认知复杂度还忽略了在许多语言中都存在的空合并操作符,这也是因为它们允许将多行代码简化为一行代码。例如,下面两个示例代码做了相同的事情:
MyObj myObj = null;
if (a != null) {
myObj = a.myObj;
}
MyObj myObj = a?.myObj;
第一个代码的含义需要一些时间来理解,而一旦您理解了空合并语法(C#),第二个代码就立即清楚了。因此,认知复杂度忽略了空合并操作符。
线性流的中断增量
认知复杂度的另一个指导原则是,破坏代码正常的从上到下、从左到右线性流的结构,需要维护者付出更多的心力来理解代码。为了体现所花费的这部分额外的心力,认知复杂度评估结构增量:
- 循环结构:
for, while, do while, ... - 条件:
三元表达式, if, #if, #ifdef, ...
评估以下混合增量:
else if, elif, else, …
不会为这些结构评估嵌套增量,因为在读取if时已经支付了心力成本。
对于习惯循环复杂度的人来说,这些增量目标似乎很熟悉。此外,下面这些也会导致认知复杂度增加:
catch
catch表示控制流中的一种分支,就像if一样。因此,每一个catch子句都会导致认知复杂度的结构增量。注意,不管捕获了多少种异常类型,catch都只会给认知复杂度分数增加一分。try和finally块被完全忽略。
switche
一个switch和它的所有case结合在一起会产生一个单一的结构增量。
在循环复杂度下,switch被视为if-else if链的模拟。也就是说,switch中的每一个case都会引起一个增量,因为它会在控制流的数学模型中引起一个分支。
但是从维护者的角度来看,一个switch - 将单个变量与一组明确命名的文字值进行比较 - 比if-else if链更容易理解,因为后者可以使用任意数量的变量和值进行任意数量的比较。
简而言之,if-else if链必须仔细阅读,而switch往往可以一目了然。
逻辑运算符序列
出于类似的原因,认知复杂度不会因每个二元逻辑运算符而增加。 相反,它评估每个二元逻辑运算符序列的基本增量。 例如,考虑以下一组:
a && b
a && b && c && d
a || b
a || b || c || d
理解每一对中的第二行并不比理解第一行难多少。而理解以下两句话的难度却有明显的不同:
a && b && c && d
a || b && c || d
由于布尔表达式在使用混合运算符时变得更难理解,因此认知复杂度会随着类似运算符的每个新序列而增加。 例如:
if (a // +1 for `if`
&& b && c // +1
|| d || e // +1
&& f) // +1
if (a // +1 for `if`
&& // +1
!(b && c)) // +1
与循环复杂度相比,虽然认知复杂度对类似的操作符有“优待”,但它对所有二进制布尔操作符序列(如变量赋值、方法调用和返回语句)都是递增的。
递归
与循环复杂度不同,认知复杂度为递归循环中的每个方法增加了一个基本增量,无论是直接的还是间接的。这个决定有两个动机。首先,递归代表了一种“元循环”,而认知复杂度会随着循环而增加。第二,认知复杂度是关于评估理解方法控制流程的相对难度,甚至一些经验丰富的程序员也发现递归很难理解。
跳转到标签
goto增加了认知复杂度的基本增量,就像break或continue一个标签和其他多级跳跃,如break或continue到一些语言中的数字一样。但是,因为提前return通常会使代码更清晰,所以其他跳转或提前退出不会导致增量。
嵌套流的中断结构增量
从直觉上看,五个if和for结构的线性序列似乎比连续嵌套的五个相同结构更容易理解,无论每个序列的执行路径有多少。因为这样的嵌套增加了理解代码的心力需求,所以认知复杂度评估了它的嵌套增量。
具体来说,每当导致结构性或混合性增量的结构嵌套在另一个这样的结构中时,都会为每一层嵌套添加一个嵌套增量。例如,在下面的例子中,方法本身和try都没有嵌套递增,因为这两种结构都不会导致结构递增或混合递增:
void myMethod () {
try {
if (condition1) { // +1
for (int i = 0; i < 10; i++) { // +2(嵌套 = 1)
while (condition2) { … } // +3(嵌套 = 2)
}
}
} catch (ExcepType1 | ExcepType2 e) { // +1
if (condition2) { … } // +2(嵌套 = 1)
}
} // 认知复杂度 9
但是,if、for、while和catch结构都受结构增量和嵌套增量的影响。
此外,尽管顶层方法被忽略,并且 lambdas、嵌套方法和类似的特性没有结构增量,但当嵌套在其他类似方法的结构中时,这些方法会增加嵌套级别:
void myMethod2 () {
Runnable r = () -> { // +0(但是嵌套级别现在是 1)
if (condition1) { … } // +2(嵌套 = 1)
};
} // 认知复杂度 2
#if DEBUG // +1 for if
void myMethod2 () { // +0(嵌套级别仍然为 0)
Runnable r = () -> { // +0(但是嵌套级别现在是 1)
if (condition1) { … } // +3(嵌套 = 2)
};
} // 认知复杂度 4
#endif
目标
制定认知复杂度的主要目标是计算方法得分,更准确地反映方法的相对可理解性,次要目标是解决现代语言结构和使用高于方法级别的度量标准产生有价值的指标。显然,解决现代语言结构的目标已经实现。下面讨论另外两个目标。
直观“正确”的复杂度得分
本讨论从一对具有相等循环复杂度但可理解性明显不同的方法开始。现在是时候重新检查这些方法并计算它们的认知复杂度分数了:
int sumOfPrimes(int max) {
int total = 0;
OUT: for (int i = 1; i <= max; ++i) { // +1
for (int j = 2; j < i; ++j) { // +2
if (i % j == 0) { // +3
continue OUT; // +1
} }
total += i;
}
return total;
} // 认知复杂度 7
String getWords(int number) {
switch (number) { // +1
case 1:
return "one";
case 2:
return "a couple";
case 3:
return “a few”;
default:
return "lots";
}
} // 认知复杂度 1
对于这两个方法,认知复杂度算法给出了明显不同的分数,这些分数更能反映它们的相对可理解性。
度量标准高于方法级别
此外,因为认知复杂度并不会因为方法结构增加而增加,聚合的数字变得有用。现在,您可以通过简单地比较域类的度量值来区分具有大量简单的 getter 和 setter 域类和包含复杂控制流的域类。因此,认知复杂度可以成为衡量类和应用的相对可理解性的工具。
结论
编写和维护代码的过程是人工过程。它们的输出必须符合数学模型,但它们本身并不符合数学模型。这就是为什么数学模型不足以评估理解它们所需要的心力成本。
认知复杂度打破了使用数学模型来评估软件可维护性的方式。它从循环复杂度设定的先例开始,使用人类的判断来评估应该如何计算结构,并决定作为一个整体应该向模型中添加什么。因此,它产生了方法复杂度分数,这对程序员来说是比以前的模型更公平的可理解性评估。此外,因为认知复杂度不收取任何方法的“入门成本”,它产生的相对公平的评估不仅在方法层面,而且在类和应用层面。
参考文献
[1]Thomas J. McCabe,“复杂性测度”,IEEE软件工程汇刊,SE-2卷,第4期,1976年12月。
附录 A:补偿用法
认知复杂度是一种与语言无关的测量方法,但不同的语言提供不同的特性是不可忽视的。例如,COBOL 中没有else if结构,JavaScript 缺少类结构。但是这些缺陷并不妨碍开发人员需要这些结构,或者试图用手头的工具构建类似的东西。在这种情况下,严格应用认知复杂度规则会导致不成比例的高分。
出于这个原因,为了不出现惩罚一种语言而不惩罚另一种语言的情况,可以对语言缺陷进行例外,比如有的语言中缺少大多数现代语言中普遍使用和支持的结构,如 COBOL 缺少else if。
另一方面,当一种语言引入一种创新特性时,比如 Java 7 能够同时捕获多个异常类型,其他语言缺乏这种创新不应该被认为是缺陷,因此不应该存在异常。
这意味着,如果一次捕获多个异常类型成为一种常见的语言特性,那么可以为不提供该功能的语言中的“额外”catch子句添加异常。这种可能性并没有被排除,但是在评估是否要在未来添加这种例外时,保守主义应该是错误的。也就是说,新的例外应该慢慢出现。
另一方面,如果未来版本的 COBOL 标准添加了“else if”结构,则倾向于尽快删除 COBOL 的“else … if”例外(将在下面描述)。
迄今已查明三个例外情况:
COBOL:缺少 else if
对于缺乏else if结构的 COBOL,if作为else子句中的唯一语句不会导致嵌套惩罚。此外,对else本身没有增量。也就是说,紧跟着if的else语句被当作else if语句处理,尽管在语法上并不是这样。
例如:
IF condition1 // +1 结构,+0 嵌套
...
ELSE
IF condition2 // +1 结构,+0 嵌套
...
ELSE
IF condition3 // +1 结构,+0 嵌套
statement1
IF condition4 // +1 结构,+1 嵌套
...
END-IF
END-IF
ENDIF
ENDIF.
JavaScript:缺少类结构
尽管 ECMAScript 6 规范最近向 JavaScript 添加了一些类,但该特性尚未被广泛采用。事实上,许多流行的框架都要求继续使用现有的习惯用法:使用外部函数作为替代来创建一种名称空间或伪类。为了不惩罚 JavaScript 用户,当这些外部函数纯粹用作声明性机制时,即当它们只包含顶级声明时,它们将被忽略。
然而,在函数的顶层(即没有嵌套在子函数中)的语句服从结构递增,表明了一些非纯声明性用法。因此,这些功能应该得到标准的处理。
例如:
function(...) { // 声明;忽略
var foo;
bar.myFun = function(…) { // 嵌套 = 0
if(condition) { // +1
...
}
}
} // 总复杂度 = 1
function(...) { // 非声明;不忽略
var foo;
if (condition) { // +1;高层结构增量
...
}
bar.myFun = function(…) { // 嵌套 = 1
if(condition) { // +2
...
}
}
} // 总复杂度 = 3
Python:装饰器
Python 的装饰器习惯用法允许在不修改函数本身的情况下向现有函数添加额外的行为。通过在装饰器中使用提供附加行为的嵌套函数来完成这一添加。为了不惩罚 Python 编码人员使用其语言的通用特性,添加了一个例外。但是,已经尝试狭义地定义例外。特别地,为了符合例外条件,一个函数可以只包含一个嵌套函数和一个返回语句。
例如:
def a_decorator(a, b):
def inner(func): # 嵌套 = 0
if condition: # +1
print(b)
func()
return inner # 总共 = 1
def not_a_decorator(a, b):
my_var = a*b
def inner(func): # 嵌套 = 1
if condition: # +1 结构,+1 嵌套
print(b)
func()
return inner # 总共 = 2
def decorator_generator(a):
def generator(func):
def decorator(func): # 嵌套 = 0
if condition: # +1
print(b)
return func()
return decorator
return generator # 总共 = 1
附录 B:说明
本节的目的是简明地列举增加认知复杂度的结构和情况,除了附录 A 中列出的例外情况。这是一个全面的清单,而不是语言上的穷尽。也就是说,如果一种语言对某个关键字有非典型的拼写,比如用elif表示else if,那么这里的省略并不是有意将其从规范中省略。
B1. 增量
以下每一项都有一个增量:
if,else if,else, 三元运算符号switchfor,foreachwhile,do whilecatchgoto LABEL,break LABEL,continue LABEL,break NUMBER,continue NUMBER- 二进制逻辑运算符的序列
- 递归循环中的每个方法
B2. 嵌套级别
以下结构增加了嵌套级别:
if,else if,else, 三元运算符号switchfor,foreachwhile,do whilecatch- 嵌套方法和类似方法的结构,如 lambdas
B3. 嵌套增量
以下结构的嵌套增量与其嵌套级别(B2)相对应:
if, 三元运算符号switchfor,foreachwhile,do whilecatch
附录 C:示例
来自 SonarJava 分析器中的org.sonar.java.resolve.JavaSymbol.java:
@Nullable
private MethodJavaSymbol overriddenSymbolFrom(ClassJavaType classType) {
if (classType.isUnknown()) { // +1
return Symbols.unknownMethodSymbol;
}
boolean unknownFound = false;
List<JavaSymbol> symbols = classType.getSymbol().members().lookup(name);
for (JavaSymbol overrideSymbol : symbols) { // +1
if (overrideSymbol.isKind(JavaSymbol.MTH) // +2(嵌套 = 1)
&& !overrideSymbol.isStatic()) { // +1
MethodJavaSymbol methodJavaSymbol = (MethodJavaSymbol)overrideSymbol;
if (canOverride(methodJavaSymbol)) { // +3(嵌套 = 2)
Boolean overriding = checkOverridingParameters(methodJavaSymbol, classType);
if (overriding == null) { // +4(嵌套 = 3)
if (!unknownFound) { // +5(嵌套 = 4)
unknownFound = true;
}
} else if (overriding) { // +1
return methodJavaSymbol;
}
}
}
}
if (unknownFound) { // +1
return Symbols.unknownMethodSymbol;
}
return null;
} // 总复杂度 = 19
来自 sonar-persistit 的com.persistit.TimelyResource.java:
private void addVersion(final Entry entry, final Transaction txn) throws PersistitInterruptedException, RollbackException {
final TransactionIndex ti = _persistit.getTransactionIndex();
while (true) { // +1
try {
synchronized (this) {
if (frst != null) { // +2(嵌套 = 1)
if (frst.getVersion() > entry.getVersion()) { // +3(嵌套 = 2)
throw new RollbackException();
}
if (txn.isActive()) { // +3(嵌套 = 2)
for // +4(嵌套 = 3)
(Entry e = frst; e != null; e = e.getPrevious()) {
final long version = e.getVersion();
final long depends = ti.wwDependency(version, txn.getTransactionStatus(), 0);
if (depends == TIMED_OUT) { // +5(嵌套 = 4)
throw new WWRetryException(version); }
if (depends != 0 // +5(嵌套 = 4)
&& depends != ABORTED) { // +1
throw new RollbackException();
}
}
}
}
entry.setPrevious(frst);
frst = entry;
break;
}
}catch (final WWRetryException re) { // +2(嵌套 = 1)
try {
final long depends = _persistit.getTransactionIndex()
.wwDependency(re.getVersionHandle(), txn.getTransactionStatus(),
SharedResource.DEFAULT_MAX_WAIT_TIME);
if (depends != 0 // +3(嵌套 = 2)
&& depends != ABORTED) { // +1
throw new RollbackException();
}
} catch (final InterruptedException ie) { // +3(嵌套 = 2)
throw new PersistitInterruptedException(ie);
}
} catch (final InterruptedException ie) { // +2(嵌套 = 1)
throw new PersistitInterruptedException(ie);
}
}
} // 总复杂度 = 35
来自 SonarQube 的org.sonar.api.utils.WildcardPattern.java:
private static String toRegexp(String antPattern, String directorySeparator) {
final String escapedDirectorySeparator = '\' + directorySeparator;
final StringBuilder sb = new StringBuilder(antPattern.length());
sb.append('^');
int i = antPattern.startsWith("/") || // +1
antPattern.startsWith("\") ? 1 : 0; // +1
while (i < antPattern.length()) { // +1
final char ch = antPattern.charAt(i);
if (SPECIAL_CHARS.indexOf(ch) != -1) { // +2(嵌套 = 1)
sb.append('\').append(ch);
} else if (ch == '*') { // +1
if (i + 1 < antPattern.length() // +3(嵌套 = 2)
&& antPattern.charAt(i + 1) == '*') { // +1
if (i + 2 < antPattern.length() // +4(嵌套 = 3)
&& isSlash(antPattern.charAt(i + 2))) { // +1
sb.append("(?:.*").append(escapedDirectorySeparator).append("|)");
i += 2;
} else { // +1
sb.append(".*");
i += 1;
}
} else { // +1
sb.append("[^").append(escapedDirectorySeparator).append("]*?");
}
} else if (ch == '?') { // +1
sb.append("[^").append(escapedDirectorySeparator).append("]");
} else if (isSlash(ch)) { // +1
sb.append(escapedDirectorySeparator);
} else { // +1
sb.append(ch);
}
i++;
}
sb.append('$');
return sb.toString();
} // 总复杂度 = 20
来自 YUI 的model.js:
save: function (options, callback) {
var self = this;
if (typeof options === 'function') { // +1
callback = options;
options = {};
}
options || (options = {}); // +1
self._validate(self.toJSON(), function (err) {
if (err) { // +2(嵌套 = 1)
callback && callback.call(null, err); // +1
return;
}
self.sync(self.isNew() ? 'create' : 'update', // +2(嵌套 = 1)
options, function (err, response) {
var facade = {
options : options,
response: response
}, parsed;
if (err) { // +3(嵌套 = 2)
facade.error = err;
facade.src = 'save';
self.fire(EVT_ERROR, facade);
} else { // +1
if (!self._saveEvent) { // +4(嵌套 = 3)
self._saveEvent = self.publish(EVT_SAVE, {
preventable: false
});
}
if (response) { // +4(嵌套 = 3)
parsed = facade.parsed = self._parse(response);
self.setAttrs(parsed, options);
}
self.changed = {};
self.fire(EVT_SAVE, facade);
}
callback && callback.apply(null, arguments); // +1
});
});
return self;
} // 总复杂度 = 20
更新日志
版本 1.1
2017年2月6日
- 更新递归部分以包含间接递归。
- 添加“Hybrid”增量类型,并使用它来澄清对
else和else if的处理;它们不受嵌套增量的影响,但会增加嵌套级别。 - 阐明认知复杂度只涉及二元布尔运算符。
- 正确的
getWords方法具有 4 的循环复杂度。 - 增加附录 A:补偿用法。
- 更新版权。
- 增加更新日志。
版本 1.2
2017年4月19日
- 文本调整和更正,例如使用“可理解性”而不是“可维护性”。
- 解释为什么混合增量是在
else if和else上评估的,而不是结构增量。 - 增加附录 B:说明。
- 增减附录 C:示例。
版本 1.3
2018年3月15日
- 扩展附录A,增加 Python 装饰器的补偿用法。
- 更新版权。
版本 1.4
2018年9月10
- 阐明什么类型的方法嵌套增加嵌套级别。
版本 1.5
2021年4月5
- 明确所有对
break和continue的多层次使用都要有一个基本增量。 - 新的封面和页脚,加上错别字更正和其他次要的视觉和可读性的更新。
- 更新版权。
版本 1.6
2021年7月9日
- 删除作者的标题。