第 19 章 軟體發展趨勢
為了說明本書中討論的原則,本章考慮了過去幾十年來在軟體開發中流行的幾種趨勢和模式。對於每種趨勢,我將描述該趨勢與本書中的原則之間的關係,並使用這些原則來評估該趨勢是否提供了對抗軟體複雜性的手段。
19.1 面向物件程式設計和繼承
在過去的三四十年間,面向物件程式設計是軟體開發中最重要的新思想之一。它引入了諸如類、繼承、私有方法和例項變數之類的概念。如果謹慎使用,這些機制可以幫助產生更好的軟體設計。例如,私有方法和變數可用於確保資訊隱藏:類外部的任何程式碼都不能呼叫私有方法或訪問私有變數,所以沒有任何對它們的外部依賴。
面向物件程式設計的關鍵要素之一是繼承。繼承有兩種形式,它們對軟體複雜性有不同的影響。繼承的第一種形式是介面繼承,其中父類定義一個或多個方法的簽名,但不實現這些方法。每個子類都必須實現簽名,但是不同的子類可以以不同的方式實現相同的方法。例如,某個介面可能定義用於執行 I/O 的方法。一個子類可能對磁碟檔案實現 I/O 操作,而另一個子類可能對網路套接字實現相同的操作。
介面繼承透過將同一介面用於多種用途,從而提供了對抗複雜性的手段。它使得從解決一個問題中獲得的知識(例如如何使用 I/O 介面讀取和寫入磁碟檔案)可以用於解決其他問題(例如透過網路套接字進行通訊)。另一種思考方式是從深淺的角度:某個介面的不同實現越多,這個介面就越深。為了讓一個介面可以有很多實現,它必須擁有所有底層實現的本質特性,同時又不會涉及到任何不同實現之間的具體差異。這個概念是抽象的核心所在。
繼承的第二種形式是實現繼承。以這種形式,父類不僅定義了一個或多個方法的簽名,而且還定義了預設實現。子類可以選擇繼承方法的父類實現,也可以透過定義具有相同簽名的新方法來覆蓋它。如果沒有實現繼承,則可能需要在幾個子類中重複相同的方法實現,這將在這些子類之間建立依賴關係(需要在方法的所有副本中複製修改)。因此,實現繼承減少了隨著系統的演進而需要修改的程式碼量。換句話說,它減少了第 2 章中描述的變更放大問題。
但是,實現繼承會在父類及其每個子類之間建立依賴關係。父類中的類例項變數經常被父類和子類訪問。這導致了繼承層次中的類之間的資訊洩露,並且使我們在修改繼承層次中的一個類時很難不用考慮其他類。例如,對父類進行修改的開發人員可能需要檢查所有子類,以確保所做的修改不會破壞任何內容。同樣,如果子類覆蓋了父類中的方法,則子類的開發人員可能需要檢查父類中的實現。在最壞的情況下,程式設計師將需要完全瞭解父類下的整個類層次結構,以便對任何類進行更改。廣泛使用實現繼承的類層次結構往往具有很高的複雜性。
因此,應謹慎使用實現繼承。在使用實現繼承之前,請考慮基於組合的方法是否可以提供相同的好處。例如,可以使用小型輔助類來實現共享功能。與其從父類中繼承功能,原始類可以各自建立在輔助類的功能之上。
如果沒有實現繼承的可行替代方案,請嘗試將父類管理的狀態與子類管理的狀態分開。一種方法是讓某些例項變數完全由父類中的方法管理,子類僅以只讀方式或透過父類中的其他方法使用它們。這適用於類層次結構中的資訊隱藏,以減少依賴性。
儘管面向物件程式設計提供的機制可能有助於實現整潔的設計,但是它們本身不能保證良好的設計。例如,如果類很淺,或者具有複雜的介面,或者允許從外部訪問其內部狀態,那麼它們仍將導致很高的複雜性。
19.2 敏捷開發
敏捷開發是 20 世紀 90 年代末出現的一種軟體開發方法,是關於如何使軟體開發更加輕量、靈活和增量的一系列想法。它是在 2001 年的一次從業者會議上正式定義的。敏捷開發主要是關於軟體開發的過程(團隊組織、進度管理,單元測試的角色、與客戶的互動等),而不是軟體設計本身。但是,它與本書中的一些設計原則有關。
敏捷開發中最重要的元素之一是開發應該是增量和迭代的概念。在敏捷方法中,軟體系統是透過一系列迭代開發的,每個迭代都新增並評估了一些新的功能。每個迭代都包括設計、測試和客戶的反饋,這是類似於本書裡提倡的增量方法的。如第 1 章所述,在專案開始時就不可能對複雜的系統進行充分的具象以決定最佳的設計。最終獲得良好設計的最佳方法是增量地開發一個系統,其中每個增量都會新增一些新的抽象,並根據經驗重構現有的抽象。這就類似於敏捷的開發方法。
敏捷開發的風險之一是它可能導致戰術式的程式設計。敏捷開發傾向於將開發人員的注意力集中在功能上,而不是在抽象上,它鼓勵開發人員推遲設計決策,以便儘快產出可以工作的軟體。例如,一些敏捷的實踐者認為,您不應該太早實現通用機制;應該先實現一個最小的專用機制,然後在確定需要的時候再重構為更為通用的機制。儘管這些論點在一定程度上是合理的,但他們反對投資思維,並鼓勵採用更具戰術式的程式設計風格。這可能會導致複雜性的快速累積。
增量式開發通常是一個好主意,但是 軟體開發的增量應該是抽象而不是功能。可以推遲對特定抽象的所有想法,直到有功能需要它為止。一旦需要抽象,就要花一些時間進行簡潔的設計,遵循第 6 章的建議並使其具有通用性。
19.3 單元測試
過去,開發人員很少編寫測試。就算有測試一般也是由一個獨立的 QA 團隊編寫的。然而,敏捷開發的原則之一是測試應該與開發緊密整合,程式設計師應該為他們自己的程式碼編寫測試。這種做法現在已經很普遍了。測試通常分為兩類:單元測試和系統測試。單元測試是開發人員最常編寫的測試。它們很小,而且重點突出:每個測試通常驗證單個方法中的一小段程式碼。單元測試可以獨立執行,而不需要為系統設定生產環境。單元測試通常與測試覆蓋工具一起執行,以確保應用程式中的每一行程式碼都經過了測試。每當開發人員編寫新程式碼或修改現有程式碼時,他們都要負責更新單元測試以保持適當的測試覆蓋率。
第二種測試包括系統測試(有時稱為整合測試),這些測試可確保應用程式的不同部分能正常協同工作。它們通常涉及在生產等同環境中執行整個應用程式。系統測試更有可能由獨立的 QA 或測試小組編寫。
測試,尤其是單元測試,在軟體設計中起著重要作用,因為它們有助於重構。沒有一個好的測試套件,對系統進行重大的結構更改是很危險的。沒有簡單的方法可以找到程式碼缺陷,因此在部署新程式碼之前,很可能將無法檢測到這些缺陷,這時再去查詢和修復它們的成本要高得多。結果,在沒有良好測試套件的系統中,開發人員往往會避免進行重構。他們儘量將每個新功能或缺陷修復的程式碼變更數量降至最低,這意味著複雜性會累積,而設計錯誤也得不到糾正。如果有一套很好的測試,開發人員可以在重構時更有信心,因為測試套件將發現大多數新引入的程式碼缺陷。這鼓勵開發人員對系統進行結構改進,從而獲得更好的設計。單元測試特別有價值:與系統測試相比,它們提供更高的程式碼覆蓋率,因此它們更有可能發現任何程式碼缺陷。
例如,在開發 Tcl 指令碼語言期間,我們決定透過將 Tcl 的直譯器替換為位元組碼編譯器來提高效能。這是一個巨大的變化,幾乎影響了核心 Tcl 引擎的每個部分。幸運的是,Tcl 有一個出色的單元測試套件,我們在新的位元組碼引擎上運行了該套件。現有測試在發現新引擎中的錯誤方面是如此有效,以至於在位元組碼編譯器的 alpha 版本釋出之後僅出現了一個缺陷。
19.4 測試驅動開發
測試驅動開發是一種軟體開發方法,程式設計師可以在編寫程式碼之前先編寫單元測試。建立一個新的類時,開發人員首先根據其預期行為為該類編寫單元測試。因為該類還沒有程式碼,沒有一個測試能透過。然後,開發人員一次處理一個測試,編寫足夠的程式碼以使該測試透過。所有測試通過後,這個類的功能就完成了。
儘管我是單元測試的堅決擁護者,但我並不熱衷測試驅動開發。測試驅動開發的問題在於,它將注意力集中在讓特定功能正常工作,而不是尋找最佳設計。 這是純粹的戰術式程式設計,有其所有的弊端。測試驅動開發過於增量:在任何時間點,都在忙於完成一個功能並讓測試透過。沒有明顯的時間來做設計,因此很容易搞得一團糟。
如第 19.2 節所述,增量開發的單元應該是抽象,而不是功能。一旦發現了對某個抽象的需求,就不要零散地去建立它,而應該一次性的完成其設計(或至少能提供一組合理且全面的核心功能)。這樣更有可能產生整潔的設計,能使各個部分很好地契合在一起。
有一個地方先編寫測試是有意義的,那就是修復程式碼缺陷的時候。在修復一個缺陷之前,請編寫一個會由於該缺陷而失敗的單元測試,然後修復該缺陷並確保相應的單元測試可以透過。這是確保您已真正修復該缺陷的最佳方法。如果您在編寫測試之前就已修復了該缺陷,則新的單元測試有可能實際上並不會觸發該缺陷,在這種情況下,它也無法告訴您是否真的修復了該問題。
19.5 設計模式
設計模式是解決特定型別問題(例如迭代器或觀察者)的常用方法。設計模式的概念在 Gamma、Helm、Johnson 和 Vlissides 合著的《設計模式:可複用的面向物件軟體的基礎》一書中提及而普及,現在設計模式已廣泛用於面向物件的軟體開發中。
設計模式代表了另一種做設計的選擇:與其從頭設計新機制,不如應用一種眾所周知的設計模式。在大多數情況下,這是很好的:設計模式的出現是因為它們解決了常見的問題,並且因為它們被普遍認為提供整潔的解決方案。如果設計模式在特定情況下運作良好,那麼您可能很難想出另一種更好的方法。
設計模式的最大風險是過度使用。不是每個問題都可以用現有的設計模式來解決。當自定義的方法更加簡潔時,請勿嘗試將問題強加到設計模式中。使用設計模式並不能自動改善軟體系統,只有在設計模式合適的情況下才會如此。與軟體設計中的許多想法一樣,設計模式是良好的並不一定意味著使用更多的設計模式也一定會更好。
19.6 Getters 和 Setters
在 Java 程式設計社群中,Getter 和 Setter 方法是一種流行的設計模式。Getter 和 Setter 與一個類的例項變數相關聯。它們具有類似 getFoo 和 setFoo 的名稱,其中 Foo 是變數的名稱。Getter 方法返回變數的當前值,Setter 方法修改該值。
由於例項變數可以是公有的,不一定必須使用 Getter 和 Setter 方法。Getter 和 Setter 的作用是它們允許在獲取和設定時執行額外的功能,例如在變數更改時更新相關的值、將變化通知到監聽器或者對值實施約束。即使最初不需要這些功能,以後也可以在不更改介面的情況下新增它們。
雖然在你必須公開例項變數的情況下,使用 Getter 和 Setter 方法是有意義的,但最好不要先決定需要公開例項變數。公開的例項變數意味著類的實現的一部分在外部是可見的,這違反了資訊隱藏的思想,並增加了類介面的複雜性。Getter 和 Setter 是淺方法(通常只有一行),因此它們在不提供太多功能的情況下使類的介面變得混亂。最好避免使用 Getter 和 Setter(或任何公開的實現層面的資料)。
建立一個設計模式的風險之一是一旦開發人員認為該模式是好的,就會試圖儘可能多地使用它。這導致了 Java 中的 Getter 和 Setter 的過度使用。
19.7 結論
每當您遇到有關軟體開發正規化的新提案時,就必須從複雜性的角度對其進行考察:該提案確實有助於最大程度地降低大型軟體系統的複雜性嗎?許多提案表面上聽起來不錯,但是如果您深入研究,您會發現其中一些會使複雜性惡化,而不是更好。