[工程质量] Cognitive Complexity 认知复杂度

简述

Cognitive Complexity:翻译成中文是认知复杂度,它将一段代码被阅读和理解时的复杂程度,估算成一个具体数字。一个方法的认知复杂度基于以下三条简单规则:

  • 代码中用到一些语法糖,把多句话缩为一句:代码不会变得更复杂;
  • 出现"break"中止了线性的代码阅读理解,如出现循环、条件、try-catch、switch-case、一串的and or操作符、递归,以及jump to label:代码因此更复杂;
  • 多层嵌套结构:代码因此更复杂;

sonar官方论文翻译

1. Abstract 概要

最初普遍使用圈复杂度(Cyclomatic Complexity)来描述一段代码逻辑的“可测性与可维护性”,尽管用它来描述“可测性”很好(可测性在这里指:需要构建出完善的单元测试需要多少代价),但它的模型设计,使其难以得出一个好的“可维护性”的测量结果。
本篇白皮书将描述一个新的度量标准,弥补了“圈复杂度”的缺点,不再使用数学模型来评估。这这种新的度量,能够更准确地反映理解成本,以及维护这些代码的困难程度。
尽管“认知复杂性”与编程语言无关,它同样适用于其它语言的文件、类、过程与函数等等,但为方便起见,本篇仅使用面向对象(如Java)的术语:“类”与“方法”

2. Introduction 介绍

Thomas J. McCabe设计的圈复杂度是一个实际上的的标准,用来测量一个模块的控制流的复杂度。圈复杂度最初的目的是用来识别“难以测试和维护的软件模块”,它能算出最少的全覆盖的测试用例量,但是不能测出一个让人满意的“理解难度”。这是因为同样圈复杂度的代码,不一定会导致相同的维护难度,这会导致我们对一些模块有错误估计。
圈复杂度的理论是在1976年,于Fortran语言环境下设计的,因此如今使用它来衡量不再是那么全面了——它不包含一些现代的语言结构,如try-catch与lambda。
以及,每个方法都的最小圈复杂度都是1。这就让我们无从得知,一个给定的类如果圈复杂度很高,它是一个大的、易维护的类,还是一个小的很复杂的逻辑。
不考虑单个类(考虑整个应用),普遍认为“圈复杂度”分数与其代码总和相关。因此看单个方法时,“圈复杂性”不够有用。
为了解决这些问题,我们制定了认知复杂度(Cognitive Complexity)以解决现代语言结构,并产生在类和应用程序级别都有意义的一个度量值。 更重要的是,它不基于这一套数学模型,就可以对控制流进行评估,这个值与程序员理解这些代码片段所需的直觉,或理解难度相对应。

3. An illustration of the problem 一个例子

这里给出一个很有用的例子,可以指出圈复杂度的问题。以下两段方法有着相同的圈复杂度,但是在理解难度上差非常多:

image.png

圈复杂度理论,对上图的两个方法给出等同的复杂度,然而从直觉上显然左边的sumOfPrimes要更难以理解一些。这也是为什么认知复杂度舍弃了使用数学模型来评估一段逻辑,改用一组简单的规则,把代码的直觉理解程度转为一个数字表达。

4. Basic criteria and methodology 基本原则

认知复杂度的评估分数,是基下面三条基本规则:

  1. 忽略简写:把多句代码缩写为一句可读的代码,不改变理解难度;
  2. 对线性的代码逻辑中,出现一个打断逻辑的东西,难度+1;
  3. 当打断逻辑的是一个嵌套时,难度+1;

进一步说,复杂度得分是来源于以下几种不同的类型:(PS:这段比较抽象,后面有详解)
A. Nesting:把一段代码逻辑嵌套在另一段逻辑中;
B. Structural:被嵌套的控制流结构;
C. Fundamental:不受嵌套影响的语句;
D. Hybrid:一些控制流结构,但不包含在嵌套中;
然而不同类型在数学上没有区别,都只是对复杂度加一。在要计算的不同类别之间进行区分,可以更轻松地了解某一处是否适用嵌套的逻辑。以下各节将进一步详细说明这些规则及其背后的原理。

5. Ignore shorthand 忽略简写

在认知复杂度的制定想法中,一个指导性的原则是:激励使用者写出好的编码规范。也就是说,需要无视或低估让代码更可读的feature(不计算进复杂度)。
“方法”本身就是一个朴素的例子,把一段代码拆的把几句抽离成一个方法,用一句方法调用代替掉,“简写”它,认知复杂度不会因为这这一次方法调用增加。
同样的,认知复杂度也会忽略掉null-coalescing操作符,x?.myObject这样的操作符不增加复杂度,因为这些操作同样是把多段代码缩写为一项了。例如下面的两段代码:

image.png

左侧版本的代码需要花了一小些时间来理解,而如果你理解null-coalescing的语法,右侧版本立即能够立即看明白。出于这样的原因,计算认知复杂度时会忽略掉null-coalescing操作。

6. Increment for breaks in the linear flow 打断线性代码流程导致的复杂

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

  • 循环: for, while, do while, ...
  • 条件: 三元运算符, if, #if, #ifdef...
    另外,以下这种会计处Hybrid类复杂度:
  • else if, elif, else, ...
    但不计入Nesting类复杂度,因为这个量在计算之前的if语句时已经计过了。
    这些增加复杂度,其实和圈复杂度的计算方式非常像,但是额外的,认知复杂度还会计算:

6.1 Catches

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

6.2 Switches

一个switch语句,和它附带的全部case绑在一起记为一个Structural类,来增加复杂度。
在圈复杂度下,一个switch语句被视为一系列的if-else链,因此每个case都会增加复杂度,因为会使得控制流分支增多。
但以代码维护的视角来看,一个switch:将单个变量与一组显式的值比较,要比if-else链易于理解,因为if-else链可以用任意条件做比较。就是说if-else if链必须仔细的逐个阅读条件,而switch通常是可以一目了然的。

6.3 Sequences of logical operators 一系列的逻辑操作

出于类似的原因,认知复杂度不对每一个逻辑运算符计分,而是考虑对连续的一组逻辑操作加分。例如下面几个操作:
a && b
a && b && c && d
a || b
a || b || c || d
理解后一行的操作,不会比理解前一行的操作更难。但是对于下面两行,理解难度有质的区别:
a && b && c && d
a || b && c || d
因为boolean操作表达式混合使用时会更难理解,因此对这类操作每出现一个,认知复杂度都会不断递增。例如:


image.png

尽管认知复杂度相对于循环复杂度,为类似的运算符提供了“折扣”,但它可以为所有的布尔运算符都有所增加。(例如那些变量赋值,方法调用和返回语句)

6.4 Recursion 递归

与圈复杂度不同,认知复杂度对每一个递归调用,都增加一点Fundamental类复杂计分,不论是直接还是间接的。有两个这样做的动机:
一、递归表达了一种“元循环”,并且循环会增加认知复杂度;
二、认知复杂度希望能用于估计一个方法,其控制流难以理解的程度,而即使是一些有经验的程序员,都觉得递归难以理解;

6.5 Jumps to labels

goto, break与continue到某处label,会增加Fundamental类复杂程度。但是在代码过程中提前return,可以使代码更清晰,所以其它类型的continue\break\return都不会导致复杂程度增加。

7. Increment for nested flow-break structures 嵌套打断思路造成的复杂

直觉看起来很明显,由连续嵌套的五个结构比,线性连续的五个if\for结构要好理解得多(不考虑执行路径上的第一个部分有几句代码)。因为这样的嵌套会增加理解代码的成本,所以认知复杂度在计算时会将其视为一个Nesting类复杂度增加。
特别地,每一次有一个导致了Structural类或Hybrid类复杂的结构体,嵌套了另一个结构时,每一层嵌套都要再加一次Nesting类复杂度。例如下面的例子,这个方法本身和try这两项就不会计入Nesting类的复杂,因为它们即不是Structure类也不是Hybrid类的复杂结构:


image.png

然而,对于if\for\while\catch这些结构,全部被视为Structural类和Nesting类的复杂。
此外,虽然最外层的方法被忽略了,并且lambda、#ifdef等类似功能也都不会视为Structral类的增量,但是它们会计入嵌套的层级数:


image.png

8. The implications 含义

认知复杂度制定的主要目标,是为方法计算出一个得分,准确地反应出此方法的相对理解难度。它的次要目标,是解决现代语言结构的问题,并产生在方法级别以上也有价值的指标。 可以证明,解决现代语言结构的目标已经实现。 其他两个目标在下面进行了检查。

8.1 Intuitively ‘right’ complexity scores 直觉上对的复杂分

在本篇开头的时候讨论了两个圈复杂度相同的方法(但它们有着完全不同的理解难度),现在回过头来检查一下这两个方法的认知复杂度吧:


image.png

认知复杂度算法,给这两个方法完全不同的得分,这个得分结果更接近它们的相对理解成本。

8.2 Metrics that are valuable above the method level 方法级别之上也有用的指标

更进一步的,因为认知复杂度不会因为方法这个结构增加,复杂度的总和开始有用了起来。现在你可以看出两个类:一个有大量的getter()\setter()方法,另一个类仅有一个极其复杂的控制流,可以简单的通过比较二者的认知复杂度就行了。认知复杂度可以成为衡量一个类或应用的相对复杂度的工具。

9. Conclusion 结论

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

示例

image.png

本文引用

sonar官网 白皮书
code climate官网 条目解释