追求代碼質量: 監視圈複雜度

2006 年 4 月 25 日

如果複雜度與缺陷緊密相關,那麼監視代碼庫的複雜度值不是很有意義嗎?Andrew Glover 將展示如何使用簡單的代碼度量工具和基於 Java™ 的工具來監視圈複雜度 (cyclomatic complexity)。

每位開發人員對代碼質量的含義都有着自己的看法,並且大多數人對如何查找編寫欠佳的代碼也有自己的想法。甚至術語代碼味道(code smell) 也已進入大衆詞彙表,成爲描述代碼需要改進的一種方式。

圈什麼?

關於這篇文章和代碼質量主題的任何其他文章的問題,請訪問由 Andrew Glover 主持的 Improve your Java Code Quality 討論論壇。

代碼味道通常由開發人員直接判定,有趣的是,它是許多代碼註釋綜合在一起的味道。一些人聲稱公正的代碼註釋是好事情,而另一些人聲稱代碼註釋只是解釋過於複雜的代碼的一種機制。顯然,Javadocs™ 很有用,但是多少內嵌註釋才足以維護代碼?如果代碼已經編寫得足夠好,它還需要解釋自己嗎?

這告訴我們,代碼味道是一種評估代碼的機制,它具有主觀性。我相信,那些聞起來味道糟透了的代碼可能是其他人曾經編寫的最好的代碼。以下這些短語聽起來是不是很熟悉?

是的,它初看起來有點亂,但是您要看到它多麼可擴展!!

或者

它讓您感到迷惑,但顯然您不瞭解它的模式。

我們需要的是客觀評估代碼質量的方法,某種可以決定性地告訴我們正在查看的代碼是否存在風險的東西。不管您是否相信,這種東西確實存在!用來客觀評估代碼質量的機制已經出現了一段時間了,只是大多數開發人員忽略了它們。這些機制被稱爲代碼度量 (code metric)。

代碼度量的歷史

幾十年前,少數幾個非常聰明的人開始研究代碼,希望定義一個能夠與缺陷關聯的測量系統。這是一個非常有趣的主張:通過研究帶 bug 代碼中的模式,他們希望創建正式的模型,然後可以評估這些模型,在缺陷成爲缺陷之前 捕獲它們。

在這條研究之路上,其他一些非常聰明的人也決定通過研究代碼看看他們是否可以測量開發人員的生產效率。對每位開發人員的代碼行的經典度量似乎只停留在表面上:

Joe 生產的代碼要比 Bill 多,因此 Joe 生產率更高一些,值得我們花錢聘請這樣的人。此外,我注意到 Bill 經常在飲水機邊閒晃,我認爲我們應該解僱 Bill。

但是這種生產率度量在實踐中是非常令人失望的,主要是因爲它容易被濫用。一些代碼測量包括內嵌註釋,並且這種度量實際上受益於剪切粘貼式開發 (cut-and-paste style development)。

Joe 編寫了許多缺陷!其他每條缺陷也都是由他間接造成的。我們不該解僱 Bill,他的代碼實際上是免檢的。

可以預見,生產率研究被證實是非常不準確的,但在管理團隊 (management body) 廣泛使用這種生產率度量以期瞭解每個人的能力的價值之前,情況並非如此。來自開發人員社區的痛苦反應是有理由的,對於一些人而言,那種痛苦感覺從未真正走遠。

未經雕琢的鑽石

儘管存在這些失敗,但在那些複雜度與缺陷的相互關係的研究中仍然有一些美玉。大多數開發人員忘記進行代碼質量研究已有很長一段時間了,但對於那些仍正在鑽研的人而言(特別是如果您也正在爲追求代碼質量而努力鑽研),會在今天的應用中發現這些研究的價值。例如,您曾注意到一些長的方法有時難以理解嗎?是否曾無法理解嵌套很深的條件從句中的邏輯?您的避開這類代碼的本能是正確的。一些長的方法和帶有大量路徑的方法 難以理解的,有趣的是,這類方法容易導致缺陷。

我將使用一些例子展示我要表達的意思。

 




回頁首


數字的海洋

研究顯示,平均每人在其大腦中大約能夠處理 7(±2)位數字。這就是爲什麼大多數人可以很容易地記住電話號碼,但卻很難記住大於 7 位數字的信用卡號碼、發射次序和其他數字序列的原因。

此原理還可以應用於代碼的理解上。您以前大概已經看到過類似清單 1 中所示的代碼片段:


清單 1. 適用記憶數字的原理

if (entityImplVO != null) {
  List actions = entityImplVO.getEntities();
  if (actions == null) {
     actions = new ArrayList();
  }
  Iterator enItr = actions.iterator();
  while (enItr.hasNext()) {
    entityResultValueObject arVO = (entityResultValueObject) actionItr
     .next();
    Float entityResult = arVO.getActionResultID();
    if (assocPersonEventList.contains(actionResult)) {
      assocPersonFlag = true;
    }
    if (arVL.getByName(
      AppConstants.ENTITY_RESULT_DENIAL_OF_SERVICE)
         .getID().equals(entityResult)) {
      if (actionBasisId.equals(actionImplVO.getActionBasisID())) {
        assocFlag = true;
      }
    }
    if (arVL.getByName(
     AppConstants.ENTITY_RESULT_INVOL_SERVICE)
      .getID().equals(entityResult)) {
     if (!reasonId.equals(arVO.getStatusReasonID())) {
       assocFlag = true;
     }
   }
 }
}else{
  entityImplVO = oldEntityImplVO;
}

 

清單 1 展示了 9 條不同的路徑。該代碼片段實際上是一個 350 多行的方法的一部分,該方法展示了 41 條不同的路徑。設想一下,如果您被分配一項任務,要修改此方法以添加一項新功能。如果您該方法不是您編寫的,您認爲您能只做必要的更改而不會引入任何缺陷嗎?

當然,您應該編寫一個測試用例,但您會認爲該測試用例能將您的特定更改在條件從句的海洋中隔離起來嗎?

 




回頁首


測量路徑複雜度

圈複雜度 是在我前面提到的那些研究期間開創的,它可以精確地測量路徑複雜度。通過利用某一方法路由不同的路徑,這一基於整數的度量可適當地描述方法複雜度。實際上,過去幾年的各種研究已經確定:圈複雜度(或 CC)大於 10 的方法存在很大的出錯風險。因爲 CC 通過某一方法來表示路徑,這是用來確定某一方法到達 100% 的覆蓋率將需要多少測試用例的一個好方法。例如,以下代碼(您可能記得本系列的第一篇文章中使用過它)包含一個邏輯缺陷:


清單 2. PathCoverage 有一個缺陷!

public class PathCoverage {
  public String pathExample(boolean condition){
    String value = null;
    if(condition){
      value = " " + condition + " ";
    }
    return value.trim();
  }
}

 

作爲響應,我可以編寫一個測試,它將達到 100% 的行覆蓋率:


清單 3. 一個測試產生完全覆蓋!

import junit.framework.TestCase;
public class PathCoverageTest extends TestCase {
  public final void testPathExample() {
    PathCoverage clzzUnderTst = new PathCoverage();
    String value = clzzUnderTst.pathExample(true);
    assertEquals("should be true", "true", value);
  }       
}

 

接下來,我將運行一個代碼覆蓋率工具,比如 Cobertura,並將獲得如圖 1 中所示的報告:


圖 1. Cobertura 報告

哦,有點失望。代碼覆蓋率報告指示 100% 的覆蓋率,但我們知道這是一個誤導。

二對二

注意,清單 2 中的 pathExample() 方法有一個值爲 2 的 CC(一個用於默認路徑,一個用於 if 路徑)。使用 CC 作爲更精確的覆蓋率測量尺度意味着第二個測試用例是必需的。在這裏,它將是不進入 if 條件語句而採用的路徑,如清單 4 中的 testPathExampleFalse() 方法所示:


清單 4. 沿着較少採用的路徑向下

import junit.framework.TestCase;
public class PathCoverageTest extends TestCase {
  
  public final void testPathExample() {
    PathCoverage clzzUnderTst = new PathCoverage();
    String value = clzzUnderTst.pathExample(true);
    assertEquals("should be true", "true", value);
  } 
  public final void testPathExampleFalse() {
    PathCoverage clzzUnderTst = new PathCoverage();
    String value = clzzUnderTst.pathExample(false);
    assertEquals("should be false", "false", value);
  } 
}

 

正如您可以看到的,運行這個新測試用例會產生一個令人討厭的 NullPointerException。在這裏,有趣的是我們可以使用圈複雜度而不是 使用代碼覆蓋率來找出這個缺陷。代碼覆蓋率指示我們已經在一個測試用例之後完成了此操作,但 CC 卻會強迫我們編寫額外的測試用例。不算太壞,是吧?

幸運的是,這裏的測試中的方法有一個值爲 2 的 CC。設想一下該缺陷被隱藏在 CC 爲 102 的方法中的情況。祝您好運找到它!

 




回頁首


圖表上的 CC

Java 開發人員可使用一些開放源碼工具來報告圈複雜度。其中一個這樣的工具是 JavaNCSS,它通過檢查 Java 源文件來確定方法和類的長度。此外,此工具還收集代碼庫中每個方法的圈複雜度。通過利用 Ant 任務或 Maven 插件配置 JavaNCSS,可以生成一個列出以下內容的 XML 報告:

  • 每個包中的類、方法、非註釋代碼行和各種註釋樣式的總數。
  • 每個類中非註釋代碼行、方法、內部類和 Javadoc 註釋的總數。
  • 代碼庫中每個方法的非註釋代碼行的總數和圈複雜度。

該工具附帶了少量樣式表,可以使用它們來生成總結數據的 HTML 報告。例如,圖 2 闡述了 Maven 生成的報告:


圖 2. Maven 生成的 JavaNCSS 報告

此報告中帶有 Top 30 functions containing the most NCSS 標籤的部分詳細描述了代碼庫中最長的方法,順便提一句,該方法幾乎總是 與包含最大圈複雜度的方法相關聯。例如,該報告列出了 DBInsertQueue 類的 updatePCensus() 方法,因爲此方法的非註釋行總數爲 283,圈複雜度(標記爲 CCN)爲 114。

正如上面所演示的,圈複雜度是代碼複雜度的一個好的指示器;此外,它還是用於開發人員測試的一個極好的衡量器。一個好的經驗法則是創建數量與將被測試代碼的圈複雜度值相等的測試用例。在圖 2 中所見的 updatePCensus() 方法中,將需要 114 個測試用例來達到完全覆蓋。

 




回頁首


分而治之

在面對指示高圈複雜度值的報告時,第一個行動是檢驗所有相應測試的存在。如果存在一些測試,測試的數量是多少?除了極少數代碼庫以外,幾乎所有代碼庫實際上都有 114 個測試用例用於 updatePCensus() 方法(實際上,爲一個方法編寫如此多的測試用例可能會花費很長時間)。但即使是很小的一點進步,它也是減少方法中存在缺陷風險的一個偉大開始。

如果沒有任何相關的測試用例,顯然需要測試該方法。您首先想到的可能是:到重構的時間了,但這樣做將打破第一個重構規則,即將編寫一個測試用例。先編寫測試用例會降低重構中的風險。減少圈複雜度的最有效方式是隔離代碼部分,將它們放入新的方法中。這會降低複雜度,使方法更容易管理(因此更容易測試)。當然,隨後應該測試那些更小的方法。

在持續集成環境中,隨時間變化 評估方法的複雜度是有可能的。如果是第一次運行報告,那麼您可以監視方法的複雜度值或任何相關的成長度(growth)。如果在 CC 中看到一個成長度,那麼您可以採取適當的動作。

如果某一方法的 CC 值在不斷增長,那麼您有兩個響應選擇:

  • 確保相關測試的健康情況仍然表現爲減少風險。
  • 評估重構方法減少任何長期維護問題的可能性。

還要注意的是,JavaNCSS 不是惟一用於 Java 平臺促進複雜度報告的工具。PMD 是另一個分析 Java 源文件的開源項目,它有一系列的規則,其中之一就是報告圈複雜度。CheckStyle 是另一個具有類似的圈複雜度規則的開放源碼項目。PMD 和 CheckStyle 都有 Ant 任務和 Maven 插件(請參閱 參考資料,從那裏獲得關於至此爲止討論的所有工具的更多信息。)

 




回頁首


使用複雜度度量

因爲圈複雜度是如此好的一個代碼複雜度指示器,所以測試驅動的開發 (test-driven development) 和低 CC 值之間存在着緊密相關的聯繫。在編寫測試時(注意,我沒有暗示是第一次),開發人員通常傾向於編寫不太複雜的代碼,因爲複雜的代碼難以測試。如果您發現自己難以編寫某一代碼,那麼這是一種警示,表示正在測試的代碼可能很複雜。在這些情況下,TDD 的簡短的 “代碼、測試、代碼、測試” 循環將導致重構,而這將繼續驅使非複雜代碼的開發。

所以,在使用遺留代碼庫的情況下,測量圈複雜度特別有價值。此外,它有助於分佈式開發團隊監視 CC 值,甚至對具有各種技術級別的大型團隊也是如此。確定代碼庫中類方法的 CC 並連續監視這些值將使您的團隊在複雜問題出現時 搶先處理它們。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章