降低程式碼的圈複雜度——複雜程式碼的解決之道

detectiveHLH發表於2020-12-30

本文程式碼示例以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的判斷。

重構前

其實可以優化成我們單獨拆分一個判斷函式,只做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的全棧筆記】,檢視更多相關文章

相關文章