下降代碼的圈複雜度——複雜代碼的解決之道

本文代碼示例以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的全棧筆記】,查看更多相關文章

相關文章
相關標籤/搜索