談談軟件的可維護性問題

前言
       很多包括自己在內的開發人員都會經常去借用(我們不用剽竊這個詞了!呵呵)開源代碼進行二次開發;或者在前輩的遺留代碼下,繼續修修補補。這種經歷往往並不像看起來那麼簡單——有時看懂,進而修改別人的少許代碼,都會覺得老虎天——無從下手,究其原因主要是代碼晦澀,關係複雜,難以隔離影響等。

    而這時我們或者抱怨前人代碼寫的愚蠢,垃圾;或者又會自慚自己編碼水平太次。其實這種困境的起源除了自己笨以外,更多是因爲代碼的可維護性不夠。

       由於前不久和朋友齊永升註釋《代碼質量》一書時曾關注過代碼的可維護性,而近期又在工作中不斷遇到軟件需求變更而帶來的代碼修改問題,所以這裏就我自己對代碼維護性進行一點總結,希望能引起大家注意,以便在以後開發中能養成好習慣。

 

軟件維護性概念
所謂軟件的可維護性其實說簡單了就是軟件代碼的可被修改的容易程度。如前言所說,代碼反覆修改的情況不可避免,這種軟件的不斷演化過程——具體就是修正錯誤;適應新環境;滿足新需求——雖然貌似將軟件的功能變的越發強大,但是事實上這些改變總是或多或少的有悖於當初的設計初衷,因此勢必慢慢的蠶食軟件的基礎架構和代碼質量——造成的結果是讓代碼越來越難看懂,健壯性越來越脆弱,修改一個bug的代價越來越大。

鑑於這個矛盾,Martin Fowler提出的(refactor)代碼重構主要就是從代碼編寫角度出發,提高代碼的維護性,以便能更好適應軟件演化。那麼接下來的一個問題是:軟件的可維護性有無標準的評測方法?學院派早都就此問題給出四個定義——:可分析性;可改變性;穩定性;易測性 。此刻先別去追究這個幾個形而上學的術語 —— 後面我會就各點進一步展開,談談自己的看法。再次之前,我們先來看看定量評價可維護性的方法 (其實本文重點,不在於此,你完全可跳過以下兩節)。

過程語言的可維護指數
首先來來談談面向過程語言的可維護性計算:這裏有一個更貌似深奧的可維護性指數:

Maintainability Index (MI) = MAX(0,(171 - 5.2 * ln(Halstead Volume) - 0.23 * (Cyclomatic Complexity) - 16.2 * ln(Lines of Code))*100 / 171)

它看似一個對維護性定量分析的精確的數學公式。其實不然。這個公式無論是係數或者是運算項都是來自於經驗規則(你千萬別想着去推導它)

1.         Halstead 是測量代碼計算複雜度, 具體如果一個程序有N個操作數和運算符,n個不同的的操作數和運算符,那麼halstead = N * Log2(n) ;  總之程序中的運算符和操作數越少越利於提高MI。

2.         Cyclomatic Complexity 是代碼的邏輯複雜度,程序的每個可能的執行分支(if, while,for 等)都爲該指標貢獻1個點。 該值的建議範圍<10 ,最多不超過20。

3.         Lines of code 是代碼行數。

    注意,需要說明的是每個模塊,每個文件都可計算維護性指數,甚至每個函數都可計算,而程序的MI則是其所有的平均值。

    如果你想測試一下你代碼的MI,這樣的工具開源的倒是有一些(http://www.chris-lott.org/resources/cmetrics/)。這裏給大家推薦一個在Linux下的工具:pmccabe(apt-get install pmccabe即可安裝)可用於計算Cyclomatic Complexity,line of code等 。可惜目前計算halstead的開源工具我還沒有發現,不過好像vs 2008的新特性裏支持代碼複雜度計算,其中的各種指標還比較詳細,建議大家可試用。

    這個來自經驗的MI的值越高越好,說明維護性越強。但是畢竟是經驗規則,就如同我們有很多經驗說如何買股票能掙錢,或者如何打仗能打勝,但是卻絕對沒有誰說一定這麼作就能掙錢或者打勝仗。所以MI更合理的使用場合的是作爲代碼重構時的參考,便於發現那部分代碼可能需要重點考慮。而且最好的評價方式是進行縱向比較,也就是說將你重構前的代碼MI和重構後的比較,從而判斷重構的積極意義。

面向對象的可維護指標
    對於面嚮對象語言而言,可維護性的指標則更發散,更復雜一些。畢竟面向對象代碼的基礎單元是對象,而對象的封裝,繼承,多態這些特性無不影響代碼的可維護性。下面介紹幾個面嚮對象語言使用的通用指標。

1.         WMC (weighted methods per class) : 基於類的加權方法,說直接點就是給定類中的方法數目,方法越多則該類WMC越大——其實對於過程語言這點也可借用,類相當於過程語言中的文件(我們一般都稱其是模塊),所以說文件中的函數越多,則維護性越難—— 我曾經參與過一個操作系統開發項目,其編程規範要求每一個函數一個文件。雖然有些極端,但也不失爲一種維護策略。

2.         DIT ( Depth of Inheritance Tree) :繼承樹深度度量,繼承是面嚮對象語言的優點之一,可提高代碼重用性。可是層層繼承也勢必帶來風險——牽一髮而動全身,父類的改動對子類影不可避免,因此過深的繼承關係對代碼維護性會產生不良影響。

3.         NOC (Number of children) : 子女數,一個類的子女數應該適當,而不能太多。太多時應再重新劃分子類——這裏要強調一點接口或者抽象類是我們推薦的方式;而採用繼承則需要擴充父類方法,也就勢必增加了模塊的耦合性,削弱了靈活性。

4.         CBO (Coupling Between Object Classes)  類之間的耦合係數。我們都知道避免耦合性的開發原則——耦合會降低代碼重用,增加單元測試難度和代碼維護性。耦合係數其實就是給定類依賴於其他類的數目——所謂代碼依賴有方法調用中,屬性訪問,繼承,參數,返回值,以及異常等。CBO我認爲是一個很重要的度量,一定要儘可能降低耦合性。

5.         RFC (Response for a class)  類響應度量,指當類的對象接受到一個消息時,執行的方法總數。顯然該值越大,則越難理解,調式和測試。

6.         LCOM (lack of cohesion in Methods) 欠內聚量度,統計完全沒有共享屬性的方法個數。簡單的計算方法就是用沒有共享屬性的方法個數,減去所有有共享屬性方法個數,結果爲負數則按0計算。 內聚值越高說明方法之間的關聯性不強,因此可以繼續差分子類,如果LCOM很低則說明類封裝的很內聚——這點可以說是強內聚的標準定義。

    關於如何使用這些度量沒有標準法則, NASA有一個建議值:WMC>100,CBO>5,RFC>100,RFC>5*NOM ,NOM>40 (NOM是類中方法個數),一旦有2個以上指標達到上述閥值,則需引起重視。

    最後,就耦合問題——也就是依賴問題,我們重點說一下:  無論是過程語言程序,或者是面向對象程序。模塊之間(類,或者是文件)或者包之間(對過程語言沒有包的概念,但我們更喜歡將某個功能集合放在一個文件架下,如linux內核各種子目錄,可以將其類比成包)的關係具有兩種關係:依賴別人,或者被別人依賴 。比如工具包(math,pthread,rt,std容器等)一般都不依賴別人,而且設計抽象(後面我們會進一步解釋抽象),這些包屬於穩定包,不能經常改動,因爲細微的改動則會影響依賴其的所有組件。而處於接近用戶的模塊或者包則多依賴於穩定包,它們不被其他模塊或者包依賴,這些包可以被經常被改動,而影響不會擴散。

另外還需要特別注意,儘量不要循環依賴或者相互依賴,即你依賴我,我依賴你。這種相互依賴,危害很大,任何一個包改變都會引起連鎖反應。因此依賴應該是單向的——可以參見層次設計,如VFS等。 另外從模塊接口設計角度講,引用外部方法時,可藉助回調算子(callback函數)模式,將外部方法作爲變量傳入本函數作用域,再以使用。這樣做降低了模塊代碼間的賴性,有利於閱讀,修改和調試。

 

 

我們依照學院派的思路劃分——在邏輯上有其可取性——來依次展開講述軟件維護性的幾個評價維度。

軟件的可分析性
首先是軟件的可分析性——它包括可讀性,可理解性和可追溯性,這是軟件開發首先應該遵從的要求,該要求不高,只是需要養成好習慣。

可讀性這裏指的比較狹義,它強調的是編碼風格:如格式,命名,對齊,註釋等。 而可理解性和可追溯性則是在軟件設計層面的要求。 可理解性強調代碼編寫的應遵循的約定俗成的模式,莫要將代碼寫的太個性化,太特立獨行;可追溯性強調代碼各部分依賴性的情況,依賴越少,隔離性越強,則越容易追溯。

代碼的可讀性
先來談談可讀性,格式問題其實就是一些“美學上”的約定罷了。總結如下:

1         表達式的格式化要點:二元操作符號都應以空格和前後相隔,不需要空格的是括號、標識符和一元操作符。比如 n = (time.tv_sec – diff) % (3600 * 24) 。另外使用括號劃分功能和限制運算順序仍是正道——不但容易讓人讀懂,也防止了運算符優先級混淆。

2         表達式(statement)格式化要點:控制流關鍵字(if,while)等,和後面判斷表達式單元以空格隔開

3         命名規範要點:自己不要發明命名規範了——C程序員就去參考Linux內核代碼的命名規範,或者GNU的規範,java的遵循java規範。

4         註釋方法:格式要求去參考Doxygen的要求;註釋內容等見下一節

5         對齊要點:代碼的對其(包括換行,縮進)對於代碼可讀性至關重要,相關說明很多,這裏不再贅述。給大家一個輔助工具幫助調整代碼對齊——indent命令來對原代碼做優化。如indent -npro -kr -i8 -ts8 -sob -l80 -ss –ncs(參數說明:-npro或--ignore-profile  不要讀取indent的配置文件.indent.pro;-kr  指定使用Kernighan&Ritchie的格式;-i8  --indent-level 設置縮排的格數爲8;-ts8 設置tab的長度;-sob或--swallow-optional-blank-lines刪除多餘的空白行;-l80 代碼超過80換行;-ss或--space-special-semicolon若for區段只有一行時,在分號前加上空格;-ncs或--no-space-after-casts  不要在cast之後空一格)

 

代碼的可理解性
       代碼的可理解性顧名思義,是說代碼設計中一個重要思想——簡單就是美! 不要復化你的代碼。爲此你需要注意如下點:

1 表達式、函數、方法不能過大:易讀的代碼函數一般都在10-20行左右,不要將多個事情放在一個函數裏完成,時刻要注意一個函數只完成一件事情,這種設計無論是從代碼重用性(越是功能單一的代碼越容易重用),或者可讀性上都有很大的好處,對於單元測試也大有益處。當然操作硬件初始化的函數有可能很大,或者複雜的邏輯函數也可能較大,因此不強求要將函數代碼變短,但是寫成小函數是一個基本的編程取向。如,Linux內核代碼建議:函數最大不要超過300行——超過了認爲幾乎不可讀,內部局部變量不要超過10 ——過多也造成函數不可讀。

2 關於控制語句的書寫:控制語句中的狀態判斷(if XX,while XX),和相應的處理語句都應該力圖簡單明瞭。如果狀態判斷表達式樣很複雜,則應該單獨抽象成函數或宏,以便理解;同樣要是處理邏輯很複雜,則同理應該抽象成函數完成。

3 函數需要寫的易於識別:什麼意思呢,我們寫函數應該按照約定俗成的方式來寫,比如鏈表的寫法,下一個個元素使用next指針,使用for循環遍歷等都是大家約定的方式 。這種方式應該遵從,無論是變量命名也好,或者是控制就結構也好,莫要特立獨行,讓人誤解。

4 降低程序之間的耦合性(耦合概念前面已經將了)。代碼耦合可進一步細分下面種類:

Ø         數據耦合——是說一個函數將數據傳遞給另一個函數接續處理,這種耦合對代碼理解性無傷大雅。

Ø         數據結構耦合——是說將一個結構大的數據結構傳遞給一個函數,而這個函數 只需要該結構中的一部分。比如,有些程序爲了避免使用全局變量,因此所有的數據都通過參數傳遞,就會有這種情況——由於數據結構耦合允許被調用程序讀取或者修改不屬於其操作的數據,因此這種行爲是危險,應當避免的。

Ø         控制耦合——是說一個函數傳遞給另外一個函數的參數會影響被調用函數的執行控制流程,其主要問題在於輸入參數不同,執行代碼代碼就不同,因此代碼很難理解。 解決方法是將函數進一步拆分。

Ø         臨時耦合——指和函數調用順序相關的邏輯設計,應該遵循約定的調用方式和命名方式,如採用 1 .construct /open /acquire 2. use 3. destruct .finalize cloes ,dispose 等。

Ø         公共耦合——指兩個函數使用了同一個全局變量。全局變量的使用是可理解性的大敵,因爲對於程序中的全局變量,我們都需要遍歷所有代碼才能確定該如何操作該變量。如果全局變量多了,且在代碼中出現處頻繁,則將是一個難以容忍的任務 。所以儘量減少全局變量,至少讓全局變量不要出自己模塊——使用staitic等方式將其隱藏自己模塊中。

Ø         外部耦合——指的是兩個模塊隱式的共享設備接口,通訊協議,或者數據結構。這種情況在協同開發中很是常見,大家必須事先約定好之間的信息接口,纔可進行各自獨立開發。這個溝通過程可往往是最耗成本的環節,我們儘量在設計中減少這種耦合吧。

Ø         內容耦合:就是一個模塊修改了另一個模塊的內部數據,或者一個模塊依賴於另一個模塊的內部數據。這種耦合最嚴重,可以說這種設計是失敗的,即便你的代碼全部是你自己用,也是搬起石頭砸自己腳。

5 註釋:代碼註釋對可理解性很重要,一個有用的註釋應該傳遞準確信息,比如函數定義,應該指明該函數作用、入口參數、返回值、前提條件、後續結果。另外對於流程式的程序,應該有里程碑式的標記註釋,以表明程序的結構和功能,這種註釋多出現在程序初始化流程中;另外對於數據聲明的註釋則應該緊鄰數據生命,以方便定位、查詢。

另外有幾個特殊註釋,需要提醒大家:

TODO: 表示需要實現,但目前還未實現的功能

XXX: 勉強可以工作,但是性能差等原因

FIXME:代碼是錯誤的,不能工作,需要修復

 

             

代碼的可追溯性
首先是位置問題,也就是說變量的使用和其定義的位置應該儘量靠近,不要都放到函數入口後定義變量——雖然這樣看似很整潔。因爲我們看到一個變量被用時,自然會去尋找其定義,因此最好靠近放置。(我們的搜索順序必然是代碼塊內,同意方法或者函數裏,同一類或者文件中,同一個包或者目錄裏的其他文件,其他項目中)

另外要儘量減少代碼模糊性。比如前面說的內容耦合,任何一行代碼改動都可能引起其他代碼的錯誤。爲此應當儘量採用私有屬性等方式,防止不必要的暴露。還需要注意多態機制其實也是造成模糊的一個方面,因爲多態讓我們很難知道到底在使用那個方法。

 

軟件的可改變性
軟件的可改變性是說對軟件作出修改的容易程度。主要從兩點分析:1 找到修改點的難度;2 修改是否會對軟件的其他部分造成影響。總之一個是識別性,一個是軟件的隔離性問題。

識別性
就識別方式而言,我們要摸是自上而下認識代碼,或者是自下而上認識代碼。自上而下的方法,需要我們對整個系統架構足夠熟悉,才能找到需要修改的子系統,然後繼續尋找到具體的修改位置。這種方式對於復系統代價很大;相反自下而上的方式則需要直接深入到具體代碼片中,當然這種方式需要依靠啓發思維和直覺來幫忙(想想,如果一個新人來到公司,讓其去解決系統bug——假設這個系統維護性很差——首先定位問題這個環節,就可能讓其在初期陷入手忙腳亂,狼狽不堪的境地)。

提高軟件的可識別性的方法有:

1 直觀的命名 :直觀的名稱有助我們識別他們。比如Linux的系統調用都以sys_前綴,那麼修改莫個系統調用則直接可通過搜索函數定位到。

2 良好的註釋 :註釋中有時需要記載引用的規範(尤其硬件初始化的註釋,往往需要知名手冊的頁碼,行數等),算法(重要算法需要用僞代碼註釋),那麼通過該搜索註釋,可幫助找到對應修改點。

3 慎用多態。多態的優點是提高代碼重用性,但同時帶來的缺點是很難定位負責給定功能的代碼片。因此不能濫用抽象,比如只有一個子類的類或者模板相關性很強,只能針對少數類型時,則慎用抽象。

可分離性
下來說說分離性問題。代碼分離性是說基於需求變更,代碼修改的擴展性。對於分離性的保證需要前瞻性設計——將此後的變更儘量集中在系統的不穩定單元——即那些需要依靠其他單元的單元,而自身又很少被其他單元依靠。具體的方法有:提高抽象設計、增強內聚、減少重複代碼和硬編碼。其中最重要的方法就是——高度抽象+強內聚。

先說抽象,抽象最常用的是接口抽象,比如VFS系統和實際存儲引擎之間的結構抽象,則很好的隔離了具體數據持續化過程,和上層的控制邏輯。在抽象接口不變的情況下,存儲引擎的開發,維護都是獨立的,並不影響VFS的工作邏輯;而對於增加註入用戶訪問限制等邏輯,修改VFS層的控制流即可。這兩者互相不干擾,各自隔離。——這種接口抽象對於framework程序、庫程序等尤其重要,一旦接口定義好了,變化部分就處於實現了,而接口本身休要輕易改變。

再說內聚,所謂內聚就是減少單元之間的耦合。原則是“只和你最親密的朋友交談”,不要試圖去通過一個對象,訪問另一個對象的方法,那樣就以意味着你和你朋友交談了。另外可借設計抹額是減少程序耦合性,比builder、factorey method、chain of responsibility、interator 、mediator、memento、strategy、template、method、visitor等模式;對於內聚設計還有一個原則上文已經提及,就是一個方法只有一個邏輯,不要再裏面做兩件事。

避免重複代碼,就是儘量不要在程序中重複實現同樣邏輯的代碼片;避免使用硬編碼,就是使用宏等代替硬編碼。這兩點都容易理解,這裏不再贅述。

軟件的穩定性
   軟件的穩定性是說在我們的代碼演化過程中,修改局部代碼所引起的連鎖反應不應引起過多的不良後果。最主要的手段有封裝數據結構和數據隱藏、分離組件和服務、以及數據抽象和進行類型檢查。

封裝和數據隱藏
數據封裝的目的在於讓數據或者方法的作用範圍儘可能收斂,具體做法是:

1 變量聲明進限於其作用域中(在C總我夢經常在某個條件下的{}內聲明和使用變量)。2 聲明類成員並給予最小的可見性(類方法的訪問控制符號可幫助我們,權限越小,使用的範圍越窄,對他的修改可能引起的問題則越少,因此優先選擇priviate)。

3 相關類儘量封裝到一個模塊內。java使用package,C++使用namespace。C沒有專門的概念,但可以看成文件,那麼模塊內並非所有的類,方法都需要和外界交互,只需要給予那些提供外部接口的內一外部可見的權限,而將其他封裝起來,減少外部可見接口,提高穩定性。

使用組件和分離進程
在開發複雜系統時,儘可能的將組件或者服務單獨實現,以便隔離相對獨立自系統。這點很是重要,化整爲零的策略不但似的系統功能解耦合,而且整個系統越發容易開發,調試,測試,因而也越發穩定。比如Berkelery DB其鎖機制等都是獨立的子系統,完全獨立於整個系統;另外Linux的打印服務程序也被分開成幾個獨立的自任務(進程)——有提供用戶界面的程序lpr,有提交打印神情的lp,有查看打印隊列的lpr程序,這種隔離必然提高了整個服務程序的穩定性,因爲減少了突發或者惡意交互,提高了系統容錯性。錯誤的定位和隔離都大大優於單模塊程序。

數據抽象
數據抽象和前述的封裝相得益彰。數據抽象的本質就是提取數據結構的本質特徵,創建出個數據類型,並創建接口方法來操作這些類型,向外提供給定功能,而外界不用關心這些 數據類型的具體組成。

我們常常在項目中看到的utils 或者common目錄下的很多工具類(如容器等)都屬於這種抽象。比如glib庫中抽象出的指針數組,或者鏈表,hash等容器則是典型的數據抽象——它已經被抽象成可以容納任何類型的對象的數據組織結構了;將抽象再提升一個層次,如果我們的項目需要實現自己的一個map容器,則可以提供一個統一的map容器方法(添加,刪除,查詢)等,而內部的實現則或者可用glib的容器,也可採用boost的容器。由於高度抽象了map接口,最後的使用者則不需要關心具體實現,就好比你使用的虛擬機在linux宿主上,或者在windows宿主上跑,你根本不用關心。

類型檢查
 

穩定性最後一點談類型檢查,程序的類型檢查的任務交給了編譯器,我們不要繞開它。這種編譯期檢查往往是發現我們錯誤的好幫手,莫要繞過它。不過,粗暴的程序員常在碰到類型不匹配警告時,採用強制轉換來消除警告——而強制轉換中,由通用數據類型(void*)轉換成特定類型時,需要謹慎,一定要確保數據類型的正確性,否則很難發現,即使到了運行期,也很難跟蹤。

另一種檢查是編譯期間的斷言:主要用於覈實編譯環境,比如檢查,編譯器,操作系統

#ifdef MACH

[]

#elif defined(__NetBSD__)

[]

else

#error OS unsupported

#endif

或者監察程序配置是否正確

#if(FAST_NFS_PING *MAX_ALLOWED_PING)>=ALLOWED_MOUNT_TIME

#error : sannity check failed in ...

最後一部檢查是運行期間斷言,這種應該出現在調試版本中,在運行期對程序條件作出判斷,彌補編譯期斷言的不足。(運行期需要關閉NDEBUG標識的情況下才有效,因此在調試期可方便使用)。

 

除了上述各點外,對於軟件維護性而言還有易測性等要求。不過由於大家基本已經對軟件的是的重要性和基本方法早已取得了共識,切資料豐富、實踐充分,因此我在這裏就不談它了。

小結
總之,當前軟件規模化開發以後,編寫代碼時刻要提醒自己——我寫的代碼是要給別人讀,要被別人修改的,而他人對代碼必然沒有你自己熟悉,因此要將代碼寫的易於讀懂,便於修改。

不在羅嗦了,本文全當拋磚引玉,大家共勉吧。

 

本文來自CSDN博客,轉載請標明出處:http://blog.csdn.net/kanghua/archive/2008/12/30/3649209.aspx

發佈了36 篇原創文章 · 獲贊 5 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章