作者:京东零售 杨学刚

背景介绍

不管小型公司还是大型互联网公司,很多项目债台高筑,新功能开发困难。其中一个很大的原因就是代码复杂,可读性差。Sonar 开发团队曾上纲上线的戏称开发人员的 7 宗罪,其中很关键的一条就是 “复杂度”。那复杂度有没有一个明确的衡量标准,我们又如何去解决代码的圈复杂度呢?今天我在这里和大家聊一下。

圈复杂度的计算方法

我们先来看一下圈复杂度与代码质量以及测试和维护成本之间的一个关系。

我们可以看到当圈复杂度,在 1-10 之间的时候,代码是清晰,结构化的。可测试性比较高,维护成本也比较低。随着圈复杂度的升高,代码的状况开始恶化,当大于 30 的时候,代码已经逐步变为不可读,维护成本非常高。

点边计算法

那圈复杂度是如何计算的呢,常用的第一种方法叫做点边计算法,它圈复杂度的计算方式 V(G) = E − N + 2,我们用下边图来解释一下这个公式:

其中公式之中的 E 指的是控制流图中边的数量,N 指的是控制流图中的节点数量。这两个图形指的就是控制流图。那我们可以计算一下,第一个控制流图的圈复杂度是:4-4+2=2.

节点判定法

除此之外圈复杂度还有一种更为直观的计算方法,因为圈复杂度实际上体现了 “判定条件” 的数量,所以圈复杂度实际上就是等于判定节点的数量再加上 1。它的计算公式为:V (G) = P + 1 其中判定节点 (P) 指的是我们常用的分支语句。例如 if 语句、while 语句、case 语句等。

那如何来降低圈复杂度呢?

圈复杂度的常用解决方法

提炼函数

接下来我们重点介绍一些降低圈复杂的方法,我通过工作中常见的代码,来表述一下,如何去降低复杂度,如果你有更好的方法,也欢迎留言跟我交流。在我们的工作中,做业务系统的时候,通过异步消息进行数据传递,是比较常用的一种方式,在我们监听到对端系统的消息的时候,一般会做这几件事情。判断消息是否为空-->转换消息为数据传输对象 DTO-->进一步的判断对象的数据是否合法-->进行业务逻辑的处理。这几个典型的步骤,很多童鞋可能用左边图的方式进行处理。这个时候,如果每一个步骤的方法比较复杂的时候,这个总的方法会非常复杂,这个时候,我们可以通过提炼方法的方式,对高内聚的操作,提炼到一个独立的方法中,来分治复杂性。

使用卫语句

我们知道圈复杂度的一个因素就是分支语句多,我们在写业务代码的时候,常见到这样的一种代码,if-then-else 的层层嵌套。卫语句的原则是,如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时,立刻返回。下面是一个生产中的场景,如果记账请求落库成功后就进行余额的操作,如果不成功就返回失败结果。因为落库失败是不常见的,所以我们采用卫语句的方式,来减少分支语句。让代码更清晰。

合并条件

经常遇到一种情况,我们对错误的处理,需要返回给调用方,内部的错误码,为了方便快读的定位错误会非常详细,但是对外可能会泛化这种错误码,这个时候我们可以通过合并条件的方式,简化条件分支,来降低圈复杂度。下面是一个生产中的场景,如果记账失败,则对错误结果进行包装处理,并返回给调用方。这个时候我们可以将错误码合并,这里它是合并到 map 中,然后针对这组错误码统一进行了处理。

通过多态方式替代条件式

在我们开发中,如果是一个平台化的系统,很多时候,有这样的需求。例如:不同的租户、不同的业务甚至不同的订单类型都会有不同的处理流程。 这个时候最简单的方式,就是通过条件分支来进行不同的处理。但是当业务繁多的时候,处理分支会显得混乱,从而导致圈复杂度的升高,这个时候我们通过利用多态的方式,可以有效的降低复杂度。我们看一下下边这段代码,不同的订单类型,使用不同的处理流程,这里他使用了在枚举中实现多态的方式。我们发现,其实他是实现了工厂模式。

替换算法

复杂算法会导致 bug 可能性的增加及可理解性/可维护性的降低,如果函数对性能要求不高,提倡使用简单明了的算法。这里我引用了重构中的一个例子,我们可以一起看一下。这里传入一个人名的数组,如果数组中包含指定的名称,就立即返回名称。

分解条件式

在面对大块头的代码时,你可以通过提炼方法的方式,将它分解为多个方法。根据每个小块代码的用途,命名新的方法名。对于条件逻辑,将每个分支条件分解成新方法可以突出条件逻辑,并更清楚的表达每个分支的作用。比如下面的例子中,夏季的时候商品的折扣和非夏天的商品折扣,是不同的计算方法。 这个时候,我们可以把两种算法,提炼到两个不同的方法中.

移除控制标记

有时候我们会通过控制标记来对循环进行处理,我们看一下这样的一段经常使用的代码,同一个数组列表中查找罪恶的人,匹配到任意一个罪恶的人后返回。这里 found 是控制标记,我们可通过下边的方式去掉控制标记,来减少一层循环,达到削减复杂度的效果。

圈复杂度的思辨

那是不是当我们检测到圈复杂度高的时候他就一定复杂呢,下面的代码是一个生产上的例子,他通过传入的 MQ 的名字,对 MQ 进行手动的暂停。这个地方实际上是可以通过 mq 的名称,从 spring 的容器中,获取 bean 的。这里的例子主要是让大家看到,虽然,这个分支比较多,但是这种扁平化的结构可读性还是可以的。不过如果它做的不仅仅是一个暂停的操作,而是一个很复杂的操作,这个时候,可能就需要通过提炼方法的方式进行重构。如果提炼方法重构后,这个类还是过长,那就需要我们通过使用多态的特性,利用工厂模式等方式进行进一步的重构。如果一开始我们就通过应用一些复杂的设计模式进行重构,就会存在过度设计的弊端,使代码更不易于理解。

总结

首先介绍了什么是圈复杂度,然后介绍了解决圈复杂度的几种方法。

通过圈复杂度计算的两种方式我们可以看到,圈复杂度的核心是分支语句。那解决问题的核心就集中在如何去减少分支语句。

不过最后我们也看到了,实际上,只是刻板的使用圈复杂度的算法,去度量一个段代码的清晰度,有时候也是不可取的,所以我们在重构系统的时候,可以通过圈复杂度的工具,进行复杂度的统计,然后对复杂度高的代码,具体场景,具体分析。而不能一味的教条。

最后我们通过思维导图来梳理一下:


↙↙↙阅读原文可查看相关链接,并与作者交流