8

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

 3 years ago
source link: https://my.oschina.net/leonsh/blog/4869163
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

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

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嵌套,看着大段大段写的糟糕的代码无从下手,甚至到了根本看不懂的地步,那么你可以考虑使用圈复杂度来衡量自己项目中代码的复杂性。

如果不刻意的加以控制,当我们的项目达到了一定的规模之后,某些较为复杂的业务逻辑就会导致有些开发写出很复杂的代码。

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

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

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

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

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

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的判断。

重构前

<img src="https://tva1.sinaimg.cn/large/0081Kckwgy1gm3cbwf23ij30u00vp42j.jpg" alt="重构前" style="zoom:50%;" />

其实可以优化成我们单独拆分一个判断函数,只做condition判断这一件事情。

重构后

3.2 少写流程控制语句

这里举个特别简单的例子。

重构前

其实可以直接优化成下面这个样子。

重构后

例子就先举到这里,其实你也发现,其实就像我上面说的一样,其目的就是为了减少if等流程控制语句。其实换个思路想,复杂的逻辑判断肯定会增加我们阅读代码的理解成本,而且不便于后期的维护。所以,重构的时候可以想办法尽量去简化你的代码。

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

4. 使用go-linq

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

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

如果在go中,我们可以这么做。

go实现

略显繁琐,熟悉Java的同学可能会说,这么简单的功能为什么会写的这么复杂,于是三下五除二写下了如下的代码。

使用go-linq重构前

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

使用go-linq重构后

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

4.1 ForEach

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

首先是一个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简单场景

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

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

4.6.2 复杂场景

SelectManyByT复杂场景

4.7 Group

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的全栈笔记】,查看更多相关文章


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK