下降代码的圈复杂度——复杂代码的解决之道

本文代码示例以Go语言为例git

欢迎微信关注「SH的全栈笔记github

0. 什么是圈复杂度

可能你以前没有据说过这个词,也会好奇这是个什么东西是用来干吗的,在维基百科上有这样的解释。golang

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.web

简单翻译一下就是,圈复杂度是用来衡量代码复杂程度的,圈复杂度的概念是由这哥们Thomas J. McCabe, Sr在1976年的时候提出的概念。算法

1. 为何须要圈复杂度

若是你如今的项目,代码的可读性很是差,难以维护,单个函数代码特别的长,各类if else case嵌套,看着大段大段写的糟糕的代码无从下手,甚至到了根本看不懂的地步,那么你能够考虑使用圈复杂度来衡量本身项目中代码的复杂性。编程

若是不刻意的加以控制,当咱们的项目达到了必定的规模以后,某些较为复杂的业务逻辑就会致使有些开发写出很复杂的代码。设计模式

举个真实的复杂业务的例子,若是你使用TDDTest-Driven Development)的方式进行开发的话,当你尚未真正开始写某个接口的实现的时候,你写的单测可能都已经达到了好几十个case,而真正的业务逻辑甚至尚未开始写数组

再例如,一个函数,有几百、甚至上千行的代码,除此以外各类if else while嵌套,就算是写代码的人,可能过几周忘了上下文再来看这个代码,可能也看不懂了,由于其代码的可读性太差了,你读懂都很困难,又谈什么维护性和可扩展性呢?微信

那咱们如何在编码中,CR(Code Review)中提前的避免这种状况呢?使用圈复杂度的检测工具,检测提交的代码中的圈复杂度的状况,而后根据圈复杂度检测状况进行重构。把过长过于复杂的代码拆成更小的、职责单一且清晰的函数,或者是用设计模式来解决代码中大量的if else的嵌套逻辑。koa

可能有的人会认为,下降圈复杂度对我收益不怎么大,可能从短时间上来看是这样的,甚至你还会由于动了其余人的代码,触发了圈复杂度的检测,从而还须要去重构别人写的代码。

可是从长期看,低圈复杂度的代码具备更佳的可读性、扩展性和可维护性。同时你的编码能力随着设计模式的实战运用也会获得相应的提高。

2. 圈复杂度度量标准

那圈复杂度,是如何衡量代码的复杂程度的?不是凭感受,而是有着本身的一套计算规则。有两种计算方式,以下:

  1. 节点断定法
  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等流程控制语句。其实换个思路想,复杂的逻辑判断确定会增长咱们阅读代码的理解成本,并且不便于后期的维护。因此,重构的时候能够想办法尽可能去简化你的代码。

那除了这些还有没有什么更加直接一点的方法呢?例如从一开始写代码的时候就尽可能去避免这个问题。

4. 使用go-linq

咱们先不用急着去了解go-linq是什么,咱们先来看一个经典的业务场景问题。

从一个对象列表中获取一个ID列表

若是在go中,咱们能够这么作。

go实现

略显繁琐,熟悉Java的同窗可能会说,这么简单的功能为何会写的这么复杂,因而三下五除二写下了以下的代码。

使用linq重构前

上图中使用了Java8的新特性Stream,而Go语言目前还没法达到这样的效果。因而就该轮到go-linq出场了,使用go-linq以后的代码就变成了以下的模样。

使用go-linq重构后

怎么样,是否是看到Java 8 Stream的影子,重构以后的代码咱们暂且不去比较行数,从语意上看,一样的清晰直观,这就是go-linq,咱们用了一个例子来为你们介绍了它的定义,接下来简单介绍几种常见的用法,这些都是官网上给的例子。

4.1 ForEach

与Java 8中的foreach是相似的,就是对集合的一个遍历。

image-20201229093033157

首先是一个From,这表明了输入,梦开始的地方,能够和Java 8中的stream划等号。

而后能够看到有ForEachForEachTForEachIndexedForEachIndexedT。前者是只遍历元素,后者则将其下标也一块儿打印了出来。跟Go中的Range是同样的,跟Java 8的ForEach也相似,可是Java 8的ForEach没有下标,之因此go-ling有,是由于它本身记录了一个index,ForEachIndexed源码以下。

ForEachIndexed源码

其中二者的区别是啥呢?我认识是你对你要遍历的元素的类型是否敏感,其实大多数状况应该都是敏感的。若是你使用了带T的,那么在遍历的时候go-ling会将interface转成你在函数中所定义的类型,例如fruit string

不然的话,就须要咱们本身去手动的将interface转换成对应的类型,因此后续的全部的例子我都会直接使用ForEachT这种类型的函数。

4.2 Where

能够理解为SQL中的where条件,也能够理解为Java 8中的filter,按照某些条件对集合进行过滤。

where用法

上面的Where筛选出了字符串长度大于6的元素,能够看到其中有个ToSlice,就是将筛选后的结果输出到指定的slice中。

4.3 Distinct

与你所了解到的MySQL中的Distinct,又或者是Java 8中的Distinct是同样的做用,去重

4.3.1 简单场景
distinct去重
4.3.2 复杂场景

固然,实际的开发中,这种只有一个整形数组的状况是不多的,大部分须要判断的对象都是一个struct数组。因此咱们再来看一个稍微复杂一点的例子。

复杂对象的distinct

上面的代码是对一个products的slice,根据product的Code字段来进行去重。

4.4 Except

对两个集合作差集。

4.4.1 简单场景
except简单场景
4.4.2 复杂场景
except-复杂场景

4.5 Intersect

对两个集合求交集

4.5.1 简单场景
intersect简单场景
4.5.2 复杂场景
intersect复杂场景

4.6 Select

从功能上来看,SelectForEach是差很少的,区别以下。

Select 返回了一个Query对象

ForEach 没有返回值

在这里你不用去关心Query对象究竟是什么,就跟Java8中的map、filter等等控制函数都会返回Stream同样,经过返回Query,来达到代码中流式编程的目的。

4.6.1 简单场景
select简单场景
select简单场景

其中SelectT就是遍历了一个集合,而后作了一些运算,将运算以后的结果输出到了新的slice中。

SelectMany为集合中的每个元素都返回一个Query,跟Java 8中的flatMap相似,flatMap则是为每一个元素建立一个Stream。简单来讲就是把一个二维数组给它拍平成一维数组。

4.6.2 复杂场景
selectManyByT-复杂场景

4.7 Group

image-20201229122918527

Group根据指定的元素对结合进行分组,Group`的源码以下。

group源码

Key就是咱们分组的时候用key,Group就是分组以后获得的对应key的元素列表。

好了,因为篇幅的缘由,关于go-linq的使用就先介绍到这里,感兴趣的能够去go-linq官网查看所有的用法。

5. 关于go-linq的使用

首先我认为使用go-linq不只仅是为了“逃脱”检测工具对圈复杂度的检查,而是真正的经过重构本身的代码,让其变的可读性更佳。

举个例子,在某些复杂场景下,使用go-linq反而会让你的代码更加的难以理解。代码是须要给你和后续维护的同窗看的,不要盲目的去追求低圈复杂度的代码,而疯狂的使用go-linq。

我我的其实只倾向于使用go-linq对集合的一些操做,其余的复杂状况,好的代码,加上适当的注释,才是不给其余人(包括你本身)挖坑的行为。并且并非说全部的if else都是烂代码,若是必要的if else可以大大增长代码的可读性,何乐而不为?(这里固然说的不是那种满屏各类if else前套的代码)

好了以上就是本篇博客的所有内容了,若是你以为这篇文章对你有帮助,还麻烦点个赞关个注分个享留个言

欢迎微信搜索关注【SH的全栈笔记】,查看更多相关文章

相关文章
相关标签/搜索