第 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 結論
每當遇到新的軟體開發範式提案,都應從複雜性的角度審視它:這項提案真的有助於儘量降低大型軟體系統的複雜性嗎?許多提案表面上聽起來不錯,但深入研究後就會發現,其中一些反而會增加複雜性。