檢視內聚力 - 使用 LCOM
「高內聚,低耦合」是模組化理想的目標,但要怎麼知道自己寫的程式有接近此目標呢?是否「高內聚」,要從不同角度考量後才能得到一個可能的答案。
這篇文章會分享一個簡單的小方法,幫助大家從其中一個角度確認程式的內聚力。
定義
本篇主要講的是內聚(cohesive),內聚指的是相關程式組合成模組的程度,而內聚性(cohesion)或內聚力指的是內聚的程度。通常內聚力越高,程式強健性(robustness)、可靠度(reliability)、可重用性(reusability)都會比較高,程式也相較易懂好測。相反地,內聚力低的程式通常都不好維護,也不好測試。
而因為模組的大小,會因為檢視的角度不同而影響範圍,如 class 或 package(許多 class 集合在一起)的範圍就不同,以下會以 class 為目標來做說明。
好處跟目的知道了,接下來我們來看看,該如何知道程式有達到這個目標。
內聚力的範例
首先,先來個高內聚的範例(使用 C# 語言)。
public class Etf |
這個 ETF 類別定義了一籃子的股票私有欄位 stocks
,並提供了取得最大權重的股票物件屬性 MaxWeight
,以及新增和清除籃子的方法 Add()
與 Clear()
。
觀察這個類別所有行為,包括屬性與方法,都有一個共同的特色:全都圍繞著 stocks
這個欄位做處理。欄位與行為的關係如下圖:
flowchart LR stocks --> MaxWeight stocks --> B["Add()"] stocks --> C["Clear()"]
這代表類別的程式都在處理相同的職責:處理與這籃股票相關的任務,這也正是單一職責原則想要達成的目標。
接著,我們來看下面這個反例:
public class StockContainer |
這個類別有兩個欄位和對應的兩個方法,兩個欄位彼此之間並沒有任何關係。
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 |
官方的建議是當 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 |
方法 all()
和 encode()
的資料來源都來自 $items,這屬於 通信內聚。
第二種內聚類型是順序內聚(sequential cohesion):觀察兩個方法,一個方法是輸出資料,來源來自另一個輸入資料。
下面是一個 PHP 的範例:
class Some |
方法 get($key)
的資料來自於 push($item)
放入的資料,這屬於 順序內聚。
維基百科有提到這兩個內聚性是較好的狀況,本篇文章觀察內聚力的方法,正是應用了此概念。未來有機會再來寫一篇內聚力的類型,這對判斷程式是否放對位置,是有幫助的。
最後的提醒
本篇文章所討論的 LCOM 指標,只是一個簡單的觀察方法。然而實際上,程式是否有達到「高內聚,低耦合」,或是否好維護,還有很多事要考量,不然把所有程式都塞到一個方法裡,內聚力就爆表了不是嗎?
實務還是得參考其他指標或現象才能做判斷,像是循環複雜度(cyclomatic complexity)、扇入扇出(fan-in fan-out)、耦合(coupling)等指標,或是很多書提到的壞味道(bad smell)等。
參考網站
感謝 @yaochangyu 、 @jame2408 等好友提供許多寶貴的經驗參考。