浅谈前端中的圈复杂度

avatar
前端组件库 @华为

DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)

引言

重构,是我们开发过程中不可避免需要进行的一项工作。重构代码,以适配当前模块设计之初未考虑到的多样化场景,并增加模块的可维护性、健壮性、可测试性。那么,如何明确重构的方向,以及量化重构的结果呢?

代码圈复杂度(Cyclomatic complexity,CC)可以是一个供选择的指标。

什么是圈复杂度

圈复杂度(Cyclomatic complexity,CC)也称为条件复杂度或循环复杂度,是一种软件度量,是由老托马斯·J·麦凯布(Thomas J. McCabe, Sr.)在1976年提出,用来表示程序的复杂度,其符号为VG或是M。圈复杂度即程序的源代码中线性独立路径的个数。

为何要降低模块(函数)的圈复杂度

下表为模块(函数)圈复杂度与代码状况的一个基本对照表。除了表中给出的代码状况、可测性、维护成本等指标外,圈复杂度高的模块(函数),也对应着高软件复杂度、低内聚、高风险、低可读性。我们要降低模块(函数)的圈复杂度,就是要降低其软件复杂度、增加内聚性、减少可能出现的软件缺陷个数、增强可测试性、可读性。

圈复杂度

代码状况

可测性

维护成本

1 - 10

清晰、结构化

10 - 20

复杂

20 - 30

非常复杂

>30

不可读

不可测

非常高

降低软件复杂度

麦凯布提出圈复杂度时,其原始目的之一就是希望在软件开发过程中就限制其复杂度。他建议程序设计者需计算其开发模块的复杂度,若一模块的圈复杂度超过10,需再分割为更小的模块。NIST(国家标准技术研究所)的结构化测试方法论已此作法略作调整,在一些特定情形下,模块圈复杂度上限放宽到15会比较合适。此方法论也承认有些特殊情形下,模块的复杂度需要超过上述的上限,其建议为“模块的循环复杂度需在上限范围以内,否则需提供书面数据,说明为何此模块循环复杂度有必要超过上限。”

增加模块内聚性

优秀的代码模块间总是低耦合,高内聚的。可以预期一个复杂度较高模块的内聚性会比较低,至少不会到功能内聚性的程度。一个有高复杂度及低内聚性的模块中会有许多的决策点,这类的模块多半运行超过一个明确定义的任务,因此内聚性较低。

减少可能出现的软件缺陷个数

许多研究指出模块(函数)的圈复杂度和其中的缺陷个数有相关性,许多这类研究发现圈复杂度和缺陷个数有高度的正相关:圈复杂度最高的模块及方法,其中的缺陷个数也最多。

增强模块可测试性

一个圈复杂度高的模块(函数),由下文中将描述到的计算方法来看,必然会有更多的运行分支,要对这样的模块进行如单元测试用例的编写,将会十分复杂,并且后期用例维护也是一个问题。

增强代码可读性

代码可读性是大型项目与团队协作间必须要考虑的一个因素。圈复杂度高的模块(函数),随着逻辑复杂度的增加,代码可读性也将降低,不利于成员间相互协作与后期维护。

圈复杂度如何计算

计算方法

一段程序的圈复杂度是其线性独立路径的数量。若程序中没有像IF指令或FOR循环的控制流程,因为程序中只有一个路径,其圈复杂度为1,若程序中有一个IF指令,会有二个不同路径,分别对应IF条件成立及不成立的情形,因此圈复杂度为2。

数学上,一个结构化程序的圈复杂度是利用程序的控制流图来定义,控制流图是一个有向图,图中的节点为程序的基础模块,若一个模块结束后,可能会运行另一个模块,则用箭头链接二个模块,并标示可能的运行顺序。圈复杂度M可以用下式定义:

M = E − N + 2P

其中

E 为图中边的个数

N 为图中节点的个数

P 为连接组件的个数

度量工具

CodeMetrics

一款VSCode插件,用于度量TS、JS代码圈复杂度。

ESLint

eslint也可以配置关于圈复杂度的规则,如:

rules: { 
  complexity: [ 
    'error', 
    { 
      max: 15 
    } 
  ] 
}

代表了当前每个函数最高圈复杂度为15,否则eslint将给出错误提示

conard cc

一款开源的代码圈复杂度检测工具(github:github.com/ConardLi/aw…),可以生成当前项目下代码圈复杂度报告。

如何降低模块(函数)圈复杂度

常用结构圈复杂度

要降低圈复杂度,我们就需要了解是哪些语句哪些结构导致了我们复杂度的增加,以下为常见结构圈复杂度说明。

顺序结构

顺序结构复杂度为1。

例:

function func() {
  let a = 1, b = 1, c;
  c = a * b;
}

如上代码,func函数内部为顺序结构,其控制流图如下:

边:1,点:2,连通分支:1,

圈复杂度:

M = 1 - 2 + 2 * 1 = 1

if-else-else、switch-case

每增加一个分支,复杂度增加1,&& 、|| 运算也为一个分支。

例:

function func() {
  let a = 1, b = 1, c;
  if (a = 1) {
    c = a + b;
  } else {
    c = a - b;
  }
}

边:4,点:4,连通分支:1,

圈复杂度:

M = 4 - 4 + 2 * 1 = 2

循环结构

增加一个循环结构,复杂度增加1。、

例:

function func() {
  let a = 1, b = 1, c = 2;
  for (let i = 1; i < 10; i++) {
    b += a;
  }
  c = a + b;
}

边:4,点:4,连通分支:1,

圈复杂度:

M = 4 - 4 + 2 * 1 = 2

return

从理论上来讲,return并不会增加当前模块圈复杂度,但在某些度量工具看来,一条return语句将增加整体程序的一条路径,并且如果提前返回,将增加程序的不确定性,所以在大多数计算工具中,每增加一条return语句,复杂度将加1。

常用降低模块(函数)圈复杂度方法

1. 函数提炼与拆分,单一职责(推荐)

既然是降低一个模块(函数)圈复杂度,那么对于复杂度极高的函数,首先需要进行就是功能的提炼与函数拆分,每个函数职责要单一。

例:

function add(a, b) {
  let tempA;
  if (a === 10) {
    tempA = 11;
  } else if (a === 12) {
    tempA = 12;
  }
  let tempB;
  if (b === 10) {
    tempB = 13;
  } else if (b === 12) {
    tempB = 12;
  }
  return tempB + tempA;
}

重构为:

function add(a, b) {
  return calcA(a) + calcB(b);
}

function calcA(a) {
  if (a === 10) {
    return 11;
  } else if (a === 12) {
    return 12;
  }
}

function calcB(b) {
  if (b === 10) {
    return 13;
  } else if (b === 12) {
    return 12;
  }
}

不仅降低了add函数圈复杂度,并且代码结构更加清晰,增加了可读性,同时还增加了当前代码可维护性、可测试性。

当然,过犹不及,我们的目标为提炼函数,保持函数单一职责,不能为了降低圈复杂度而进行暴力拆分。

2. 优化算法(减少不必要条件、循环分支)

从圈复杂度计算上来看,条件、循环分支均会增加模块圈复杂度。从某些程度上,复杂的条件与循环结构是可优化,减少不必要结构,从而降低圈复杂度。

例:

let a = 'a', c;
if (a === 'a') {
  c = a + 1;
} else if (a === 'b') {
  c = a + 2;
} else if (a === 'c') {
  c = a + 3;
} else if (a === 'd') {
  c = a + 4;
}
return c;

重构为:

let a = 'a', c;
let conditionMap = {
  a: 1,
  b: 2,
  c: 3,
  d: 4
}
c = a + conditionMap[a];
return c;

消除了所有条件分支,从而大幅降低了当前函数圈复杂度。

3. 表达式逻辑优化

逻辑计算也将增加圈复杂度,优化一些结构复杂的逻辑表达式,减少不必要的逻辑判断,也将一定程度上降低圈复杂度。

例:

a && b || a && c

可进行简单优化为:

a && (b || c)

从而使表达式圈复杂度降低1。

5. 减少提前return(此方法需辩证看待)

单从降低圈复杂度上来看,由于当前大多数圈复杂度计算工具将对return个数进行计算,故若要针对这些工具衡量规则进行优化,减少return语句个数也为一种方式。

例:

let a = 1, b = 1;
if (a = 1) {
  return a + b;
} else {
  return a - b;
}

重构为:

let a = 1, b = 1, c;
if (a = 1) {
  c = a + b;
} else {
  c = a - b;
}
return c;

圈复杂度将降低1。

总结

圈复杂度(Cyclomatic complexity,CC)高的代码一定不是好代码,对于我们代码好坏的衡量,圈复杂度可以作为一个参考指标;可以通过控制流图计算圈复杂度;要降低模块(函数)圈复杂度,提炼拆分函数、优化算法、优化逻辑表达式均为可以尝试的方法。

参考

循环复杂度:zh.wikipedia.org/wiki/%E5%BE…

详解圈复杂度:kaelzhang81.github.io/2017/06/18/…

前端代码质量-圈复杂度原理和实践:juejin.cn/post/684490…

加入我们

我们是DevUI团队,欢迎来这里和我们一起打造优雅高效的人机设计/研发体系。招聘邮箱:muyang2@huawei.com

文/DevUI 砰砰砰砰

往期文章推荐

手把手教你搭建自己的Angular组件库

《手把手教你使用Vue/React/Angular三大框架开发Pagination分页组件》

《手把手教你搭建一个灰度发布环境》