檢視內聚力 - 使用 LCOM

「高內聚,低耦合」是模組化理想的目標,但要怎麼知道自己寫的程式有接近此目標呢?是否「高內聚」,要從不同角度考量後才能得到一個可能的答案。

這篇文章會分享一個簡單的小方法,幫助大家從其中一個角度確認程式的內聚力。

定義

本篇主要講的是內聚(cohesive),內聚指的是相關程式組合成模組的程度,而內聚性(cohesion)或內聚力指的是內聚的程度。通常內聚力越高,程式強健性(robustness)、可靠度(reliability)、可重用性(reusability)都會比較高,程式也相較易懂好測。相反地,內聚力低的程式通常都不好維護,也不好測試。

而因為模組的大小,會因為檢視的角度不同而影響範圍,如 class 或 package(許多 class 集合在一起)的範圍就不同,以下會以 class 為目標來做說明。

好處跟目的知道了,接下來我們來看看,該如何知道程式有達到這個目標。

內聚力的範例

首先,先來個高內聚的範例(使用 C# 語言)。

public class Etf
{
private readonly List<Stock> stocks = new();

public Stock MaxWeight => stocks.OrderByDescending(stock => stock.Weights).First();

public void Add(Stock stock) => stocks.Add(stock);

public void Clear() => stocks.Clear();
}

這個 ETF 類別定義了一籃子的股票私有欄位 stocks,並提供了取得最大權重的股票物件屬性 MaxWeight,以及新增和清除籃子的方法 Add()Clear()

觀察這個類別所有行為,包括屬性與方法,都有一個共同的特色:全都圍繞著 stocks 這個欄位做處理。欄位與行為的關係如下圖:

flowchart LR
stocks --> MaxWeight
stocks --> B["Add()"]
stocks --> C["Clear()"]

這代表類別的程式都在處理相同的職責:處理與這籃股票相關的任務,這也正是單一職責原則想要達成的目標。

接著,我們來看下面這個反例:

public class StockContainer
{
private readonly List<Stock> _0050 = new();
private readonly List<Stock> _0056 = new();

public void Add0050(Stock stock) => _0050.Add(stock);
public void Add0056(Stock stock) => _0056.Add(stock);
}

這個類別有兩個欄位和對應的兩個方法,兩個欄位彼此之間並沒有任何關係。

flowchart LR
_0050 --> A["Add0050()"]
_0056 --> B["Add0056()"]

從上圖可以發現,這個類別處理了兩份類似,但是實際上是不同的職責,因此是有需要重構的。重構可以把兩個 List 拆分成兩個不同的類別,或是直接寫一個類別來完成這兩個欄位與方法想達成的目的。

上面的程式看起來是有問題的,但如果當加了下面這個方法,就不大一樣了:

public List<Stock> Intersect() => (List<Stock>)_0050.Intersect(_0056, new StockCompare());

欄位的使用關係圖會變成像下面這樣:

flowchart LR
_0050 --> A["Add0050()"]
_0050 --> C["Intersect()"]
_0056 --> B["Add0056()"]
_0056 --> C["Intersect()"]

現在會發現,雖然新增股票的時候,是分別針對兩個欄位去新增,而那是為了要做交集用的。這兩個欄位藉由新增的 Intersect() 方法,產生了互動與內聚力--「想把這兩個欄位放在一起」的想法,因為相同的事放在一起比較方便,不是嗎?這正是單一職責原則所講的「職責」概念。

如何觀測?

觀測方法很簡單:把欄位(其他語言可能稱之為屬性,如 Java)找出來,接著把每一個方法都檢查一次,若有檢查到某個方法用到所有的欄位,那這個類別的內聚力就會因此而提高;而有方法跟欄位是獨立於其他方法與欄位的話,則它有可能是可以抽出(extract)類別的。

比方說下面這個例子

flowchart LR
A --> X
A --> Y
B --> Y
C --> Z

A B 屬性,與 X Y 方法,具有內聚力,而 C 屬性和 Z 方法,則是可以抽出類別的。

上面的例子都很簡單,實務上可能會有更多欄位和方法摻在一起,這樣子該怎麼判斷呢?靜態分析(static analyze)有個指標叫作 Lack of Cohesion of Methods,簡稱 LCOM,中文直譯叫「缺少內聚力的方法」。通常靜態分析工具都能找得到它,如 NDepend。工具可以把方法和欄位全找出來,同時也能把關聯全都找出來。再來要了解的是,工具是怎麼算出量化指標的?官方網站有給一個公式如下:

LCOM = 1 – (sum(MF)/M*F)
  • M 指的是方法的數量(包括建構子)
  • F 指的是欄位的數量
  • MF 指的是一個方法存取到幾個欄位的數量
  • sum(MF) 指的是所有方法的數量都算出來,並加總起來

LCOM 的值會介於 0 ~ 1 之間。若所有方法都有接觸到所有欄位的話,則 LCOM 的值會是 0,代表完全內聚(totally cohesive);相反則是 1,代表完全不內聚(totally non-cohesive)。

接著我們把上面的幾個類都計算一下,結果如下:

Etf = 1 - (3/4) = 0.25 LCOM
StockContainer = 1 - (2/6) = 0.66 LCOM
StockContainer(修改後) = 1 - (4/8) = 0.5 LCOM

官方的建議是當 LCOM > 0.8 && NbFields > 10 && NbMethods > 10 的時候需要注意內聚性。但這並不是一個絕對的指標,還是會有例外情境。比方說,像 Java 常常會寫 Utils 類別,有時候硬寫一個類別提高內聚力,還不如寫個 Utils 還比較好維護。這需要視需求與情境做判斷,如同本文最一開始所說的,要搭配很多角度觀測程式,才能做出適合的選擇。

我有發現其他語言的工具結果不大一樣,公式也可能不大一樣,因此這裡的 NDepend 當參考就好。

更好的範例

開源的程式總是不會讓我們失望,來看看 Laravel Collection v9.13.0,所有方法(包括建構子),都跟屬性 $items 有關,它正是一個 LCOM = 0 的例子。

Collection 的 range() 方法沒用到 $items,因為它是產生物件的靜態方法(Simple Factory Pattern),所以不適合用本篇文章提到的方法檢視。

類似地,C# 的 List 也會有這樣的特性。

概念與理論

內聚的類型,維基百科有給一些參考,下面會講兩種內聚類型。

第一種是通信內聚(communicational cohesion):觀察兩個方法,它們同時使用相同的輸入資料,或者是回傳資料的來源是相同。

下面是一個 PHP 的範例:

class Some
{
private $items;

/**
* 資料來源是 $items
*/
public function all()
{
return $this->items;
}

/**
* 資料來源是 $items
*/
public function encode()
{
return json_encode($this->items);
}
}

方法 all()encode() 的資料來源都來自 $items,這屬於 通信內聚

第二種內聚類型是順序內聚(sequential cohesion):觀察兩個方法,一個方法是輸出資料,來源來自另一個輸入資料。

下面是一個 PHP 的範例:

class Some
{
private $items;

/**
* 輸出資料
*/
public function get($key)
{
return $this->items[$key];
}

/**
* 輸入資料
*/
public function push($item)
{
$this->items[] = $item;
}
}

方法 get($key) 的資料來自於 push($item) 放入的資料,這屬於 順序內聚

維基百科有提到這兩個內聚性是較好的狀況,本篇文章觀察內聚力的方法,正是應用了此概念。未來有機會再來寫一篇內聚力的類型,這對判斷程式是否放對位置,是有幫助的。

最後的提醒

本篇文章所討論的 LCOM 指標,只是一個簡單的觀察方法。然而實際上,程式是否有達到「高內聚,低耦合」,或是否好維護,還有很多事要考量,不然把所有程式都塞到一個方法裡,內聚力就爆表了不是嗎?

實務還是得參考其他指標或現象才能做判斷,像是循環複雜度(cyclomatic complexity)、扇入扇出(fan-in fan-out)、耦合(coupling)等指標,或是很多書提到的壞味道(bad smell)等。

參考網站

感謝 @yaochangyu@jame2408 等好友提供許多寶貴的經驗參考。