1. 程式人生 > 程式設計 >一步步降低軟體複雜性

一步步降低軟體複雜性

前言

在進行軟體開發時,我們常常會追求軟體的高可維護性,高可維護性意味著當有新需求來時,系統易擴充套件;當出現bug時,開發人員易定位。而當我們說一個系統的可維護性太差時,往往指的是該系統太過複雜,導致給系統增加新功能時容易出現bug,而出現bug之後又難以定位。

那麼,軟體的複雜性又是如何定義的呢?

John Ousterhout給出的定義如下:

Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.

可見,軟體的複雜性是一個很泛的概念,任何使軟體難以理解和難以修改的東西,都屬於軟體的複雜性。為此,John Ousterhout提出了一個公式來度量一個系統的複雜性:

式中,表示系統中的模組,表示該模組的認知負擔(Cognitive Load,即一個模組難以理解的程度),表示在日常開發中在該模組花費的開發時間。

從公式上看,一個軟體的複雜性由它的各個模組的複雜性累加而成,而 模組複雜性 = 模組認知負擔 * 模組開發時間,也就是模組的複雜性即和模組本身有關,也跟在該模組上花費的開發時間有關。需要注意的是,如果一個模組非常難以理解,但是後續開發過程中幾乎沒有涉及到它,那麼它的複雜性也是很低的。

導致軟體複雜的原因

導致軟體複雜的原因可以細分出很多種來,而概括起來莫過於兩種:依賴(dependencies)隱晦(obscurity)。前者會讓修改起來很費勁而且容易出現bug,比如當修改模組1時,往往也涉及到模組2模組3... 的改動;後者會讓軟體難以理解,定位一個bug,甚至是僅僅讀懂一段程式碼都需要花費大量的時間。

軟體的複雜性往往伴隨著如下幾種症狀:

霰彈式修改(Change amplification)。當只需要修改一個功能,但又不得不對許多模組作出改動時,我們稱之為霰彈式修改。這通常是因為模組之間耦合過重,相互依賴太多導致的。 比如,有一組Web頁面,每個頁面都是一個HTML檔案,每個HTML都有一個背景

屬性。由於各個HTML的背景屬性都是分開定義的,因此如果需要把背景顏色從橙色修改為藍色時,就需要改動所有的HTML檔案。

霰彈式修改的典型例子
霰彈式修改的典型例子

認知負擔(Cognitive load)。當我們說一個模組隱晦、難以理解時,它就有過重的認知負擔,這種情況下往往需要讀者花費大量時間才能明白該模組的功能。比如,提供一個不帶任何註釋的calculate介面,它有2個int型別的入參和一個int型別的返回值。從該函式的簽名上看,呼叫者根本無法得知函式的功能是什麼,他只能通過花時間去閱讀原始碼來確定函式功能後才敢去呼叫該函式。

int calculate(int val1,int val2);
複製程式碼

不確定性(Unknown unknowns)。相比於前兩種症狀,不確定性的破壞性更大,它通常指一些在開發需求時,你必須注意的,但是又無從得知的點。它常常是因為一些隱晦的依賴導致的,會讓你在開發完一個需求之後感覺心裡很沒譜,隱約覺得自己的程式碼哪裡有問題,但又不清楚問題在哪,只能祈禱在測試階段能夠暴露而不要漏洞商用階段。

如何降低軟體的複雜性

對 “戰術程式設計” Say No!

很多程式設計師在進行特性開發或bug修復時,關注點往往是如何簡單快速讓程式跑起來,這就是典型的戰術程式設計(Tactical programming)方法,它追求的是短期的效益——節省開發時間。戰術程式設計最普遍的體現就是在編碼之前沒有進行模組設計,想到哪裡就寫到哪裡。戰術程式設計在系統前期可能會比較方便,一旦系統龐大起來、模組之間的耦合變重之後,新增或修改功能、修復bug都會變得寸步難行。隨著系統變得越來越複雜,最後不得不對系統進行重構甚至重寫。

與戰術程式設計相對的就是戰略程式設計(Strategic programming),它追求的是長期的效益——增加系統可維護性。僅僅是讓程式跑起來還不足以滿足,還需要考慮程式的可維護性,讓後續在新增或修改功能、修復bug時都能夠快速響應。因為考慮的點比較多,也就註定戰略程式設計需要花費一定的時間去進行模組設計,但相比於戰術程式設計後期導致的問題,這一點時間也是完全值得的。

戰術程式設計 VS 戰略程式設計
戰術程式設計 VS 戰略程式設計

讓模組更“深”一點!

一個模組由介面(interface)和實現(implementation)兩部分組成,如果把一個模組比喻成一個矩形,那麼介面就是矩形頂部的邊,而實現就是矩形的面積(也可以把實現看成是模組提供的功能)。當一個模組提供的功能一定時,深模組(Deep module)的特點就是矩形頂部的邊比較短,整體形狀高瘦,也即介面比較簡單;淺模組(Shallow module)的特點就是矩形頂部的邊比較長,整體形狀矮胖,也即介面比較複雜。

深模組 VS 淺模組
深模組 VS 淺模組

模組的使用者往往只看到介面,模組越深,模組暴露給呼叫者的資訊就越少,呼叫者與該模組的耦合性也就越低。因此,把模組設計得更“深”一點,有助於降低系統的複雜性。

那麼,怎樣才能設計出一個深模組呢?

  • 更簡單的介面

    簡單的介面比簡單的實現更重要,更簡單的介面意味著模組的易用性更好,呼叫者使用起來更方便。而簡單的實現 + 複雜的介面這種形式,一方面影響了介面的易用性,另一方面則加深了呼叫者與模組的耦合。因此,在進行模組設計時,最好遵守“把簡單留給別人,把複雜留給自己”的原則。

    異常也屬於介面的一部分,在編碼過程中,應該杜絕沒經過處理,就隨意將異常往上拋的現象,這樣只會增加系統的複雜性。

  • 更通用的介面

    在設計介面時,你往往有兩種選擇:(1)設計成專用的介面;(2)設計成通用的介面。前者實現起來更方便,而且完全可以滿足當前的需求,但可擴充套件性低,屬於戰術程式設計;後者則需要花時間對系統進行抽象,但可擴充套件性高,屬於戰略程式設計。通用的介面意味著該介面適用的場景不止一個,典型的就是“ 一個介面,多個實現 ”的形式。

    有些程式設計師可能會反駁,在無法預知未來變化的情況下,通用就意味著過度設計。過度通用確實屬於過度設計,但對介面進行適度的抽象並不是,相反它可以使系統更有層次感,可維護性也更高。

  • 隱藏細節

    在進行模組設計時,還要學會區分對於呼叫者而言,哪些資訊是重要的,哪些資訊是不重要的。隱藏細節指的就是只給呼叫者暴露重要的資訊,把不重要的細節隱藏起來。隱藏細節一則使模組介面更簡單,二則使系統更易維護。

    如何判斷細節對於呼叫者是否重要?以下有幾個例子:

    1、對於Java的Map介面,重要的細節Map中每一個元素都是由<Key,Value>組成的;不重要的細節Map底層是如何儲存這些元素、如何實現執行緒安全等。

    2、對於檔案系統中的read函式,重要的細節:每次讀操作從哪個檔案讀、讀多少位元組;不重要的細節:如何切換到核心態、如何從硬碟裡讀資料等。

    3、對於多執行緒應用程式,重要的細節:如何建立一個執行緒;不重要的細節:多核CPU如何排程該執行緒。

進行分層設計!

設計良好的軟體架構都有一個特點,就是層次清晰,每一層都提供了不同的抽象,各個層次之間的依賴明確。不管是經典的Web三層架構、DDD所提倡的四層架構以及六邊形架構,抑或是所謂的Clean Architecture,都有著鮮明的層次感。

Web三層架構 DDD四層架構
六邊形架構 Clean Architecture

在進行分層設計時,需要注意的是,每一層都應該提供不同的抽象,並要儘量避免在一個模組中出現大量的Pass-Through Mehod。比如在DDD的四層架構中,領域層提供了對領域業務邏輯的抽象,應用層提供了對系統用例的抽象,介面層提供了對系統訪問介面的抽象,基礎設施層則提供對如資料庫訪問這類的基礎服務的抽象。

所謂的Pass-Through Mehod是指那些“在函式體內直接呼叫其他函式,而本身只做了極少的事情”的函式,通常其函式簽名與被其呼叫的函式簽名很類似。Pass-Through Mehod所在的模組通常都是淺模組,讓系統增加了無謂的層次和函式呼叫,會使系統更加複雜。

Pass-Through Mehod(選自《A Philosophy of Software Design》中的例子)
Pass-Through Mehod(選自《A Philosophy of Software Design》中的例子)

學會寫程式碼註釋!

註釋是降低軟體複雜性的價效比極高的一種手法,它只需要花費20%的時間,即可獲取80%的價值。它可以提高晦澀難懂的程式碼的可讀性;可以起到隱藏程式碼複雜細節的作用,比如介面註釋可以幫助開發者在沒有閱讀程式碼的情況下快速瞭解該介面的功能和用法;如果寫的好,它還可以改善系統的設計

具體如何寫好程式碼註釋,參考《教你寫好程式碼註釋》一文。

總結

軟體的複雜性是我們程式設計師在日常開發中所必須面對的東西,學會如何 “弄清楚什麼是軟體複雜性,找到導致軟體複雜的原因,並利用各種手法去戰勝軟體的複雜性” 是一門必備的能力。有句話說得很好,“程式碼質量決定生活質量”,當你把軟體的複雜性降低了,bug減少了,系統可維護性更高了,自然也就帶來了更好的生活質量。

模組設計是降低軟體複雜度最有效的手段,學會使用“戰略程式設計”的方法,並堅持下去。我們常常提倡“一次把事情做對”,但這對於模組設計而言並不適用,幾乎沒有人可以第一次就把一個模組設計成完美的模樣。二次設計是一個非常有效的手法,與其在系統腐化之後再花大量時間進行重構或重寫,還不如在第一次完成模組設計後,再花點時間進行二次設計,多問問自己:是否有更簡單的介面?是否有更通用的設計?是否有更簡潔高效的實現?

"羅馬不是一天建成的",降低軟體的複雜性也一樣,貴在堅持。