[转]降低代码的圈复杂度——复杂代码的解决之道
原文:https://www.cnblogs.com/detectiveHLH/p/14206712.html
------------------------------------------------
0. 什么是圈复杂度
可能你之前没有听说过这个词,也会好奇这是个什么东西是用来干嘛的,在维基百科上有这样的解释。
Cyclomatic complexity is a software metric used to indicate the complexity of a program. It is a quantitative measure of the number of linearly independent paths through a program's source code. It was developed by Thomas J. McCabe, Sr. in 1976.
简单翻译一下就是,圈复杂度是用来衡量代码复杂程度的,圈复杂度的概念是由这哥们Thomas J. McCabe, Sr在1976年的时候提出的概念。
1. 为什么需要圈复杂度
如果你现在的项目,代码的可读性非常差,难以维护,单个函数代码特别的长,各种if else case嵌套,看着大段大段写的糟糕的代码无从下手,甚至到了根本看不懂的地步,那么你可以考虑使用圈复杂度来衡量自己项目中代码的复杂性。
如果不刻意的加以控制,当我们的项目达到了一定的规模之后,某些较为复杂的业务逻辑就会导致有些开发写出很复杂的代码。
举个真实的复杂业务的例子,如果你使用TDD(Test-Driven Development)的方式进行开发的话,当你还没有真正开始写某个接口的实现的时候,你写的单测可能都已经达到了好几十个case,而真正的业务逻辑甚至还没有开始写
再例如,一个函数,有几百、甚至上千行的代码,除此之外各种if else while嵌套,就算是写代码的人,可能过几周忘了上下文再来看这个代码,可能也看不懂了,因为其代码的可读性太差了,你读懂都很困难,又谈什么维护性和可扩展性呢?
那我们如何在编码中,CR(Code Review)中提早的避免这种情况呢?使用圈复杂度的检测工具,检测提交的代码中的圈复杂度的情况,然后根据圈复杂度检测情况进行重构。把过长过于复杂的代码拆成更小的、职责单一且清晰的函数,或者是用设计模式来解决代码中大量的if else的嵌套逻辑。
可能有的人会认为,降低圈复杂度对我收益不怎么大,可能从短期上来看是这样的,甚至你还会因为动了其他人的代码,触发了圈复杂度的检测,从而还需要去重构别人写的代码。
但是从长期看,低圈复杂度的代码具有更佳的可读性、扩展性和可维护性。同时你的编码能力随着设计模式的实战运用也会得到相应的提升。
2. 圈复杂度度量标准
那圈复杂度,是如何衡量代码的复杂程度的?不是凭感觉,而是有着自己的一套计算规则。有两种计算方式,如下:
- 节点判定法
- 点边计算法
判定标准我整理成了一张表格,仅供参考。
圈复杂度 | 说明 |
---|---|
1 - 10 | 代码是OK的,质量还行 |
11 - 15 | 代码已经较为复杂,但也还好,可以设法对某些点重构一下 |
16 - ∞ | 代码已经非常的复杂了,可维护性很低, 维护的成本也大,此时必须要进行重构 |
当然,我个人认为不能够武断的把这个圈复杂度的标准应用于所有公司的所有情况,要按照自己的实际情况来分析。
这个完全是看自己的业务体量和实际情况来决定的。假设你的业务很简单,而且是个单体应用,功能都是很简单的CRUD,那你的圈复杂度即使想上去也没有那么容易。此时你就可以选择把圈复杂度的重构阈值设定为10.
而假设你的业务十分复杂,而且涉及到多个其他的微服务系统调用,再加上各种业务中的corner case的判断,圈复杂度上100可能都不在话下。
而这样的代码,如果不进行重构,后期随着需求的增加,会越垒越多,越来越难以维护。
2.1 节点判定法
这里只介绍最简单的一种,节点判定法,因为包括有的工具其实也是按照这个算法去算法的,其计算的公式如下。
圈复杂度 = 节点数量 + 1
节点数量代表什么呢?就是下面这些控制节点。
if、for、while、case、catch、与、非、布尔操作、三元运算符
大白话来说,就是看到上面符号,就把圈复杂度加1,那么我们来看一个例子。
我们按照上面的方法,可以得出节点数量是13,那么最终的圈复杂度就等于13 + 1 = 14
,圈复杂度是14,值得注意的是,其中的&&
也会被算作节点之一。
2.2 使用工具
对于golang我们可以使用gocognit来判定圈复杂度,你可以使用go get github.com/uudashr/gocognit/cmd/gocognit
快速的安装。然后使用gocognit $file
就可以判断了。我们可以新建文件test.go
。
package main
import (
"flag"
"log"
"os"
"sort"
)
func main() {
log.SetFlags(0)
log.SetPrefix("cognitive: ")
flag.Usage = usage
flag.Parse()
args := flag.Args()
if len(args) == 0 {
usage()
}
stats := analyze(args)
sort.Sort(byComplexity(stats))
written := writeStats(os.Stdout, stats)
if *avg {
showAverage(stats)
}
if *over > 0 && written > 0 {
os.Exit(1)
}
}
然后使用命令gocognit test.go
,来计算该代码的圈复杂度。
$ gocognit test.go
6 main main test.go:11:1
表示main包的main方法从11行开始,其计算出的圈复杂度是6。
3. 如何降低圈复杂度
这里其实有很多很多方法,然后各类方法也有很多专业的名字,但是对于初了解圈复杂度的人来说可能不是那么好理解。所以我把如何降低圈复杂度的方法总结成了一句话那就是——“尽量减少节点判定法中节点的数量”。
换成大白话来说就是,尽量少写if、else、while、case这些流程控制语句。
其实你在降低你原本代码的圈复杂度的时候,其实也算是一种重构。对于大多数的业务代码来说,代码越少,对于后续维护阅读代码的人来说就越容易理解。
简单总结下来就两个方向,一个是拆分小函数,另一个是想尽办法少些流程控制语句。
3.1 拆分小函数
拆分小函数,圈复杂度的计算范围是在一个function内的,将你的复杂的业务代码拆分成一个一个的职责单一的小函数,这样后面阅读的代码的人就可以一眼就看懂你大概在干嘛,然后具体到每一个小函数,由于它职责单一,而且代码量少,你也很容易能够看懂。除了能够降低圈复杂度,拆分小函数也能够提高代码的可读性和可维护性。
比如代码中存在很多condition的判断。
其实可以优化成我们单独拆分一个判断函数,只做condition判断这一件事情。
3.2 少写流程控制语句
这里举个特别简单的例子。
其实可以直接优化成下面这个样子。
例子就先举到这里,其实你也发现,其实就像我上面说的一样,其目的就是为了减少if等流程控制语句。其实换个思路想,复杂的逻辑判断肯定会增加我们阅读代码的理解成本,而且不便于后期的维护。所以,重构的时候可以想办法尽量去简化你的代码。
那除了这些还有没有什么更加直接一点的方法呢?例如从一开始写代码的时候就尽量去避免这个问题。