第 7 章 不同的層級,不同的抽象
軟體系統由不同層級組成,高層級使用低層級提供的功能。在設計良好的系統中,每一層都提供與其上下層不同的抽象。如果跟蹤一個透過方法呼叫在各層之間傳遞的操作,就會發現抽象隨著每次方法呼叫而變化。例如:
本章概覽相鄰兩層的介面幾乎一樣,有什麼問題?
- 在檔案系統中,最上面的層級實作了檔案抽象。檔案由可變長度的位元組陣列組成,可以透過讀寫可變長度的位元組範圍來更新該檔案。檔案系統的下一層級在記憶體中實作了固定大小的磁碟塊的快取。呼叫者可以假定經常使用的塊將保留在記憶體中,以便可以快速存取它們。最底部的層級由裝置驅動程式組成,它們在輔助儲存裝置和記憶體之間移動資料塊。
- 在 TCP 等網路傳輸協議中,最上層提供的抽象是在兩臺機器之間可靠地傳輸位元組流。這一層建立在更低的層級之上,後者以盡力而為的方式,在機器之間傳輸大小有上限的資料包:大多數資料包能成功送達,但有些可能丟失或亂序到達。
如果系統中的相鄰層級具有相似的抽象,這就是一個危險訊號,提示類的劃分可能存在問題。本章將討論這種情況出現的場景、帶來的問題,以及如何透過重構消除問題。
7.1 透傳方法
相鄰層級的抽象相似時,問題通常表現為透傳方法。這類方法除了呼叫另一個簽名相似或相同的方法,幾乎不做其他事情。例如,一個實作圖形介面文字編輯器的學生專案中,有一個類幾乎完全由透傳方法組成。下面是其中的部分程式碼:
public class TextDocument ... {
private TextArea textArea;
private TextDocumentListener listener;
...
public Character getLastTypedCharacter() {
return textArea.getLastTypedCharacter();
}
public int getCursorOffset() {
return textArea.getCursorOffset();
}
public void insertString(String textToInsert, int offset) {
textArea.insertString(textToInsert, offset);
}
public void willInsertString(String stringToInsert, int offset) {
if (listener != null) {
listener.willInsertString(this, stringToInsert, offset);
}
}
...
}
該類的 15 個公有方法中,有 13 個是透傳方法。
危險訊號:透傳方法
透傳方法除了將參數傳遞給另一個方法外,不執行任何操作;後者通常與它具有相同的 API。這通常表示相關的類之間沒有明確的職責劃分。
透傳方法使類變得更淺:它們增加了類的介面複雜性,進而增加系統的複雜性,卻沒有增加系統的整體功能。在上述四個方法中,只有最後一個做了一點額外工作,而且微乎其微:檢查一個變數是否有效。透傳方法還會在類之間建立依賴關係:如果 TextArea 的 insertString 方法更改了簽名,就必須相應地修改 TextDocument 中的 insertString 方法。
透傳方法表明類之間的職責劃分不清。在上面的範例中,TextDocument 類提供了 insertString 方法,但插入文字的功能完全在 TextArea 中實作。這通常不是好的設計:某項功能的介面和實作應該位於同一個類中。當你看到從一個類透傳到另一個類的方法時,問問自己:這兩個類分別負責哪些功能和抽象?你很可能會發現它們的職責存在重疊。
解決方案是重構這些類,讓每個類都承擔不同且連貫的職責。圖 7.1 展示了幾種方法。一種方法如圖 7.1(b)所示:讓高層級類的呼叫者直接存取低層級類,使高層級類不再負責這項功能。另一種方法是在類之間重新分配功能,如圖 7.1(c)所示。最後,如果這些類的職責無法分開,最好的解決方案可能是將它們合併,如圖 7.1(d)所示。
圖 7.1:透傳方法。在(a)中,類 C1 包含三個透傳方法,它們只呼叫 C2 中具有相同簽名的方法(每個符號代表一種方法簽名)。要消除透傳方法,可以像(b)那樣讓 C1 的呼叫方直接呼叫 C2;也可以像(c)那樣在 C1 和 C2 之間重新分配功能,避免兩個類之間的呼叫;還可以像(d)那樣將兩個類合併。
在上面的範例中,職責交織的三個類是 TextDocument、TextArea 和 TextDocumentListener。學生透過在類之間移動方法,將三個類調整為兩個類,消除了透傳方法,也讓剩下兩個類的職責更加明確。
7.2 什麼時候可以有重複的介面?
方法具有相同簽名,並不一定是壞事。重要的是,每個新方法都應提供有價值的功能。透傳方法的問題在於,它們沒有提供任何新功能。
分發器(Dispatcher)就是一個有用的例子:它會呼叫另一個具有相同簽名的方法。分發器根據收到的參數,從幾個方法中選擇一個進行呼叫,並將大部分或全部參數傳給選中的方法。分發器的簽名通常與被呼叫的方法相同,但它仍提供了有用的功能:決定由哪個方法來執行任務。
例如,Web 伺服器收到 Web 瀏覽器發來的 HTTP 請求時,會呼叫分發器檢查請求中的 URL,選擇相應的方法來處理請求。某些 URL 只需回傳磁碟上的檔案內容;其他請求則可能需要呼叫用 PHP 或 JavaScript 等語言編寫的程式。分發過程可能非常複雜,通常依據一組規則來匹配傳入的 URL。
只要每個方法都提供了有用且獨特的功能,多個方法具有相同簽名就是可以接受的。分發器呼叫的方法就是如此。另一個例子是同一介面的多種實作,例如作業系統中的磁碟驅動程式。不同驅動程式支援不同型別的磁碟,卻提供相同的介面。多個方法實作同一介面,可以降低認知負荷:使用過其中一個方法後,再使用其他方法就更容易,因為無需學習新介面。這些方法通常位於同一層級,也不會相互呼叫。
譯者圖解簽名相同,就一定是透傳嗎?
7.3 裝飾器
裝飾器設計模式(也稱為“包裝器”)會鼓勵跨層級重複 API。裝飾器物件接收一個現有物件並擴充其功能,提供與底層物件相似或相同的 API,並透過自己的方法呼叫底層物件的方法。在第 4 章的 Java I/O 範例中,BufferedInputStream 類就是一個裝飾器:給定一個 InputStream 物件,它提供相同的 API,同時加入緩衝功能。例如,呼叫它的 read 方法讀取單個字元時,它會呼叫底層 InputStream 的 read 方法讀取更大的塊,並儲存多讀的字元,供後續 read 呼叫使用。視窗系統中也有類似例子:Window 類實作簡單的不可滾動視窗,ScrollableWindow 類則透過新增水平和垂直捲軸來裝飾視窗類。
譯者注(勘誤):本例的
InputStream和BufferedInputStream操作的是位元組,不是字元;原文這裡將讀取和緩衝的單位稱為“字元”,正文照錄。參見 Java API 文件。
使用裝飾器是為了將類的專用擴充與更通用的核心功能分開。但裝飾器類往往很淺:為了少量新功能,引入了大量樣板程式碼,而且通常包含許多透傳方法。為每個小功能都建立一個新類,很容易造成裝飾器模式的濫用,導致淺類激增,就像 Java I/O 範例那樣。
建立裝飾器類之前,請考慮以下替代方法:
- 能否直接把新功能加入基礎類,而不建立裝飾器類?如果新功能比較通用,或者在邏輯上與基礎類相關,或者大多數使用基礎類的場景也會用到它,這樣做就有意義。例如,幾乎每個建立 Java
InputStream的人都會建立一個BufferedInputStream,而緩衝本來就是 I/O 的自然組成部分,因此應該合併這些類。 - 如果新功能只針對某個特定用例,將它放入實作該用例的程式碼,而不單獨建立類,是否更合理?
- 你可以將新功能與現有的裝飾器合併,而不是建立新的裝飾器嗎?這將產生一個更深的裝飾器類,而不是多個淺的裝飾器類。
- 最後,問問自己,新功能是否真的需要包裝現有功能,能否將它實作為一個獨立於基礎類的類?在視窗範例中,捲軸也許可以與主視窗分開實作,而不必包裝視窗的所有現有功能。
包裝器有時也有意義。例如,系統使用一個無法修改介面的外部類,而應用程式要求另一種介面。這時可以用包裝器類轉換介面。不過,這種情況並不常見,通常還有比包裝器類更好的選擇。
7.4 介面與實作
“不同的層級,不同的抽象”規則還適用於類的介面與實作:內部的表示形式,通常應與介面提供的抽象不同。如果兩者的抽象相似,這個類可能就不夠深。例如,在第 6 章的文字編輯器專案中,大多數團隊都按行儲存文字,每行單獨儲存。一些團隊還提供 getLine 和 putLine 等方法,圍繞行設計文字類的 API。這樣一來,文字類就很淺,也不便使用。高層級使用者介面經常需要在行中插入文字(例如使用者鍵入內容時),或刪除跨行的文字範圍。面對文字類行導向的 API,呼叫者只能自行拆分和拼接行,才能實作這些操作。這些程式碼並不簡單,卻在使用者介面實作中四處重複。
文字類提供字元導向的介面時,使用起來就容易得多。例如,insert 方法可以在任意位置插入任意字串(可能包含換行符),delete 方法則可以刪除任意兩個位置之間的文字。內部仍然按行儲存,但拆分和拼接行的複雜性被封裝在文字類中。這使類更深,也簡化了使用它的高層級程式碼。這樣,文字 API 與行導向的儲存機制有了顯著差異,而這個差異正體現了類提供的價值。
譯者圖解按行儲存,不必按行暴露
7.5 透傳變數
跨層級 API 重複的另一種形式是透傳變數,即經過一長串方法向下傳遞的變數。圖 7.2(a)展示了一個數據中心服務的例子:命令列參數指定用於安全通訊的證書。只有呼叫函式庫方法開啟套接字的底層方法 m3 需要這一資訊,但它卻必須經過 main 到 m3 路徑上的所有方法向下傳遞。因此,cert 變量出現在每個中間方法的簽名中。
透傳變數會增加複雜性,因為所有中間方法都必須知道它們的存在,即使根本用不到這些變數。此外,每增加一個變數(例如系統最初不支援證書,後來才新增支援),都可能需要修改大量介面和方法,才能讓變數沿所有相關路徑傳遞。
消除透傳變數並不容易。一種辦法是檢查最頂層和最底層的方法是否已經共享某個物件。在圖 7.2 的資料中心服務範例中,也許已有一個物件儲存其他網路通訊資訊,而且 main 和 m3 都能存取。這樣,main 就可以把證書資訊存入該物件,而不必經過通往 m3 的所有中間方法傳遞證書(見圖 7.2(b))。不過,這個物件本身也可能是一個透傳變數(否則 m3 如何存取它?)。
另一種辦法是把資訊存入全域變數,如圖 7.2(c)所示。這樣就無需在方法之間傳遞資訊,但全域變數幾乎總會帶來其他問題。例如,它使同一行程無法建立同一系統的兩個獨立實例,因為它們對全域變數的存取會發生衝突。生產環境中也許很少需要多個實例,但測試時通常很有用。
我最常用的方案是引入上下文(Context)物件,如圖 7.2(d)所示。上下文儲存應用程式的所有全域狀態,也就是原本只能用透傳變數或全域變數儲存的狀態。大多數應用程式的全域狀態包含多個變數,例如設定選項、共享子系統和效能計數器。每個系統實例有一個上下文物件。因此,同一行程中可以共存多個系統實例,每個實例都有自己的上下文。
圖 7.2:處理透傳變數的幾種技術。在(a)中,證書經過方法 m1 和 m2 傳遞,儘管它們並不使用證書。在(b)中,main 和 m3 可以存取同一個物件,因此可以將變數存入該物件,而不必傳給 m1 和 m2。在(c)中,證書儲存為全域變數。在(d)中,證書與其他系統級資訊(如超時值和效能計數器)一起儲存在上下文物件中;凡是方法需要存取上下文的物件,都儲存一份對上下文的引用。
不幸的是,許多地方都可能需要上下文,因此它本身也可能成為透傳變數。為了讓更少的方法需要了解上下文,可以讓系統中的大多數主要物件儲存上下文引用。在圖 7.2(d)中,包含 m3 的類將上下文引用儲存為實例變數。建立新物件時,負責建立的方法從自身所屬物件中取得上下文引用,再傳給新物件的建構函式。這樣,上下文隨處可用,卻只需在建構函式中作為顯式參數出現。
上下文物件統一管理系統的全域資訊,無需使用透傳變數。需要新增新變數時,只需將它加入上下文物件;除上下文的建構函式和解構函式外,現有程式碼都不受影響。全域狀態集中儲存在一處,也就更容易識別和管理。上下文還便於測試:測試程式碼可以修改上下文欄位,改變應用程式的全域設定。如果使用透傳變數,做出這些更改就會困難得多。
上下文遠非理想方案。儲存在其中的變數仍具有全域變數的大多數缺點。例如,某個變數為什麼存在、在哪裡使用,可能並不容易看出來。如果缺乏管理,上下文就會堆積大量雜亂資料,在整個系統中形成隱蔽的依賴關係。上下文也可能帶來執行緒安全問題,避免這類問題的最佳辦法是讓上下文中的變數不可變。遺憾的是,我還沒有找到更好的方案。
譯者圖解不透傳之後,兩實例會串值嗎?
7.6 結論
每向系統新增一個設計元素,例如介面、參數、函式、類或定義,都會增加複雜性,因為開發人員必須瞭解它。要在降低複雜性方面產生淨收益,這個元素就必須消除一些沒有它時會存在的複雜性。否則,實作系統時最好不要引入這個元素。例如,類可以透過封裝功能降低複雜性,讓使用者不必瞭解具體實作。
“不同的層級,不同的抽象”規則正是這一思想的應用:如果不同層級提供相同的抽象,例如透傳方法或裝飾器,它們帶來的收益就很可能不足以抵消新增設計元素的成本。同樣,透傳參數要求沿途的多個方法都知道它們的存在,增加了複雜性,卻沒有提供額外功能。

