第 5 章 資訊隱藏和資訊洩露
第 4 章認為模組應該是深的。本章及隨後的幾個章節討論了建立深模組的技術。
本章概覽模組裡該藏什麼,洩露了怎麼看出來?
5.1 資訊隱藏
實作深模組最重要的技術之一是 資訊隱藏。這一思想最早由 David Parnas 在一篇經典論文 [1] 中提出。其基本思想是,每個模組應封裝一些代表設計決策的知識 [2]。這些知識包含在模組的實作中,但不出現在介面中,因此其他模組無法看到。
隱藏在模組中的資訊通常是某種機制的實作細節,例如:
- 如何在 B 樹中儲存資訊,以及如何高效地存取它。
- 如何識別檔案中每個邏輯塊相對應的物理磁碟塊。
- 如何實作 TCP 網路協議。
- 如何在多核處理器上排程執行緒。
- 如何解析 JSON 文件。
隱藏的資訊包括與該機制相關的資料結構和演算法,也可以包括較低層級的細節(例如頁面大小),或更抽象的高層級概念,例如“大多數檔案都很小”這一假設。
資訊隱藏從兩個方面降低了複雜性。首先,它簡化了模組的介面。介面以更簡單、更抽象的方式反映模組的功能,並隱藏細節,減少使用者的認知負荷。例如,使用 B 樹類的開發人員不需要考慮樹節點的理想扇出(fanout:指每個節點允許的最大子節點數),也不需要考慮如何保持樹的平衡。其次,資訊隱藏使系統更容易演進。如果一項資訊被隱藏在模組中,模組外部就不存在對它的依賴,因此與該資訊相關的設計變更只會影響這一個模組。例如,如果 TCP 協議發生變化(例如引入一種新的擁塞控制機制),就必須修改協議的實作,但使用 TCP 傳送和接收資料的高層級程式碼應當無需修改。
設計新模組時,應仔細考慮可以在該模組中隱藏哪些資訊。如果可以隱藏更多資訊,你就應該能夠簡化模組的介面,這會使模組更深。
注意:將類中的變數和方法宣告為私有,與資訊隱藏不是同一回事。私有元素有助於隱藏資訊,因為類外部無法直接存取它們。但是,私有屬性仍可以透過公有方法(如 getter 和 setter 方法)暴露出來。在這種情況下,私有屬性的性質和用法就與公有屬性一樣,對外都是可見的。
資訊隱藏的最佳形式是將資訊完全隱藏在模組中,讓模組的使用者既不必關心,也無法看到這些資訊。不過,部分隱藏也有價值。例如,如果只有一個類的少數使用者需要某項特性或資訊,而且它透過單獨的方法存取,那麼在最常見的場景中,這些資訊仍然不可見,也就可以說它們在大部分情況下是隱藏的。與將資訊暴露給類的所有使用者相比,這種方式產生的依賴更少。
5.2 資訊洩露
資訊隱藏的反面是資訊洩露。當一個設計決策反映在多個模組中時,就會發生 資訊洩露。這會在模組之間建立依賴關係:對該設計決策的任何更改,都要求修改所有涉及的模組。如果一項資訊出現在模組的介面中,那麼按定義,它就已經洩露了;因此,更簡單的介面往往能隱藏更多資訊。但是,即使資訊未出現在介面中,也可能發生洩露。假設兩個類都瞭解某種檔案格式(也許一個類讀取該格式的檔案,另一個類負責寫入),即使它們都不在介面中公開相關資訊,也都依賴這一格式:如果檔案格式改變,兩個類就都需要修改。這種後門洩露比透過介面洩露更嚴重,因為它更隱蔽。
資訊洩露是軟體設計中最重要的危險訊號之一。作為軟體設計師,學會敏銳地識別資訊洩露,是最有價值的技能之一。如果你在類之間發現資訊洩露,請自問:“我該如何重新組織這些類,讓這些特定知識只影響一個類?”如果受影響的類本身比較小,而且與洩露的資訊緊密相關,那麼將它們合併成一個類可能是合理的。另一種方法是從所有受影響的類中提取這些資訊,再建立一個新類來封裝它們。不過,只有能找到一個簡單介面,將所有這些細節隱藏起來,這種方法才有效。如果新類仍透過介面公開大部分知識,這麼做的價值就不大(你只不過用介面洩露取代了後門洩露)。
危險訊號:資訊洩露
在多個地方使用相同的知識時,就會發生資訊洩露,例如上文中兩個都依賴同一種檔案格式的類。
5.3 時間順序分解
一種我稱之為 時間順序分解 的設計風格,是導致資訊洩露的常見原因。在時間順序分解中,系統的結構對應於操作發生的時間順序。考慮一個應用程式:它讀取特定格式的檔案,修改內容,然後將檔案重新寫入。按時間順序分解,這個應用程式可能被拆成三個類:一個負責讀取檔案,一個負責修改,還有一個負責寫入新版本的檔案。讀取和寫入步驟都需要了解檔案格式,這就導致了資訊洩露。解決方案是將讀寫檔案的核心機制合併到一個類中,供應用程式在讀取和寫入階段使用。編寫程式碼時,我們通常會順著操作的執行順序思考,因此很容易陷入時間順序分解的陷阱。然而,大多數設計決策都會在應用程式生命週期中的多個不同時間點用到,所以時間順序分解經常導致資訊洩露。
執行順序固然重要,也會在應用程式中有所體現。但除非按執行順序劃分模組符合資訊隱藏的要求(例如,不同執行階段可能使用完全不同的資訊),否則不應讓執行順序決定模組結構。在設計模組時,應專注於執行每個任務所需的知識,而不是任務的執行順序。
危險訊號:時間分解
在時間順序分解中,執行順序反映在程式碼結構中:不同時間發生的操作被放在不同的方法或類中。如果不同執行點使用相同的知識,這些知識就會在多處程式碼中出現,從而導致資訊洩露。
譯者圖解執行順序不等於模組邊界
5.4 範例:HTTP 伺服器
為了說明資訊隱藏中的問題,我們可以看看軟體設計課程中,學生在實作 HTTP 協議時做出的設計決策。分析他們做得好的方面和遇到困難的地方,很有幫助。
HTTP 是 Web 瀏覽器用來與 Web 伺服器通訊的機制。當用戶單擊 Web 瀏覽器中的連結或提交表單時,瀏覽器使用 HTTP 透過網路將請求傳送到 Web 伺服器。伺服器處理完請求後,會將響應傳送回瀏覽器。該響應通常包含要顯示的新網頁。HTTP 協議指定了請求和響應的格式,兩者均以文字形式表示。圖 5.1 顯示了描述表單提交的 HTTP 請求範例。在該課程中,學生們被要求實作一個或多個類,以使 Web 伺服器可以輕鬆地接收傳入的 HTTP 請求併發送響應。
圖 5.1:HTTP 協議中的 POST 請求包含透過 TCP 套接字傳送的文字。每個請求都包含一個初始行、一個由空行終止的標頭(Header)集合以及一個可選的請求體(Body)。初始行包含請求型別(POST 用於提交表單資料),指示操作(/comments/create)和可選參數(photo_id 的值為 246)的 URL,以及傳送方使用的 HTTP 協議版本。每個標頭行由一個名稱(例如 Content-Length)及其後的值組成。對於此請求,請求體包含了其他的參數(備註和優先權)。
5.5 範例:太多的類
學生們最常犯的錯誤是將程式碼分成大量淺類,導致類之間的資訊洩露。有一個小組使用兩個不同的類來接收 HTTP 請求:第一個類從網路連線中將請求讀成字串,第二個類解析這個字串。這是時間順序分解的一個範例(“首先讀取請求,然後解析它”)。之所以會發生資訊洩露,是因為不解析訊息就無法讀取 HTTP 請求。例如,Content-Length 標頭指定了請求體的長度,只有解析標頭,才能計算請求的總長度。因此,這兩個類都需要了解 HTTP 請求的大部分結構,也都包含重複的解析程式碼。這種做法還給呼叫方帶來了額外的複雜性:接收請求時,必須按特定順序呼叫不同類中的兩個方法。
由於這些類共享大量資訊,因此最好將它們合併為一個同時處理請求讀取和解析的類。這樣便將請求格式的所有知識隔離在一個類中,提供了更好的資訊隱藏,並且還為呼叫者提供了一個更簡單的介面(只需要呼叫一個方法)。
這個範例說明了軟體設計中的一個普遍規律:通常可以透過使類稍大一些來改善資訊隱藏。這樣做有兩個好處。首先,可以將與特定功能相關的所有程式碼(例如解析 HTTP 請求)放在一起,讓一個類包含與該功能相關的所有內容。其次,可以提高介面的抽象層級。例如,與其為計算的三個步驟分別提供方法,不如用一個方法完成整個計算,這樣就能簡化介面。上一段的範例同時體現了這兩個好處:合併後的類包含了解析 HTTP 請求的所有相關程式碼,並用一個方法替代了原來兩個對外可見的方法。這個類比原來的任何一個類都更深。
當然,類的範圍也可能擴大過頭(例如把整個應用程式都放在一個類裡)。第 9 章將討論什麼情況下適合把程式碼拆分成多個較小的類。
譯者圖解還沒解析,怎麼知道讀完了?
5.6 範例:HTTP 參數處理
伺服器收到 HTTP 請求後,需要存取其中的某些資訊。例如,處理圖 5.1 中請求的程式碼可能需要知道 photo_id 參數的值。參數可以在請求的第一行中指定(圖 5.1 中的 photo_id),有時也可以在請求體中指定(圖 5.1 中的 comment 和 priority)。每個參數都有名稱和值。參數值採用一種特殊編碼,稱為 URL 編碼。例如,在圖 5.1 的備註值中,+ 表示空格,%21 表示 !。為了處理請求,伺服器需要取得某些參數解碼後的值。
在參數處理方面,大多數學生專案都做出了兩個不錯的選擇。首先,他們認識到伺服器應用程式不關心參數是在標頭行還是請求體中指定的,因此向呼叫者隱藏了這一區別,將兩個位置的參數合併在一起。其次,他們隱藏了 URL 編碼的細節:HTTP 解析器先解碼參數值,再回傳給 Web 伺服器。因此,圖 5.1 中的 comment 參數回傳的值是 What a cute baby!,而不是 What+a+cute+baby%21。在這兩種情況下,資訊隱藏都使 HTTP 模組的 API 更加簡單。
譯者注(勘誤):此處原文稱“標頭行”,實際指攜帶請求目標的“請求行”;請求行與後面的標頭欄位行是不同部分。正文保留原文用詞,參見 HTTP 訊息語法。
但是,大多數學生用來回傳參數的介面太淺,錯過了 資訊隱藏 的機會。大多數專案使用 HTTPRequest 型別的物件儲存已解析的 HTTP 請求,並在 HTTPRequest 類中只提供了一個類似下面的方法來回傳參數:
public Map<String, String> getParams() {
return this.params;
}
該方法沒有回傳單個參數,而是回傳了內部用於儲存所有參數的對映(Map)的引用。這個方法很淺,因為它公開了 HTTPRequest 類儲存參數的內部表示。更改這一表示方式就會導致介面變更,進而要求修改所有呼叫者。修改實作時,往往需要改變關鍵資料結構的表示方式(例如為了提高效能),因此應儘量避免暴露內部資料結構。這種做法還增加了呼叫者的工作:必須先呼叫 getParams,再呼叫另一個方法,從對映中獲取特定參數。最後,呼叫者必須知道,不應該修改 getParams 回傳的對映,否則會影響 HTTPRequest 的內部狀態。
這是一個用於檢索參數值的更好的介面:
public String getParameter(String name) { ... }
public int getIntParameter(String name) { ... }
getParameter 以字串形式回傳參數值,提供了比上面的 getParams 略深的介面。更重要的是,它隱藏了參數的內部表示。getIntParameter 將 HTTP 請求中以字串表示的參數值轉換為整數(例如圖 5.1 中的 photo_id 參數)。呼叫者因此不必另行執行字串到整數的轉換,也無需瞭解轉換機制。需要時,還可以為其他資料型別定義類似的方法,例如 getDoubleParameter。(如果所需參數不存在,或無法轉換為請求的型別,這些方法都會丟擲例外;上面的程式碼省略了例外宣告。)
譯者圖解回傳參數,還是洩露容器?
5.7 範例:HTTP 響應中的預設值
HTTP 專案還必須支援生成 HTTP 響應。學生在這方面最常犯的錯誤是沒有提供足夠的預設值。每個 HTTP 響應都必須指定一個 HTTP 協議版本。有一個小組要求呼叫者在建立響應物件時顯式指定版本。但是,響應版本必須與請求物件中的版本相對應,而且傳送響應時,已經需要將請求作為參數傳入(它指示響應應傳送到哪裡)。因此,由 HTTP 類自動提供響應版本更合理。呼叫者不太可能知道該指定哪個版本,而且如果呼叫者確實指定了一個值,還很可能導致 HTTP 函式庫與呼叫者之間的資訊洩露。HTTP 響應還包含一個日期標頭,用於指定傳送響應的時間,HTTP 函式庫也應該為它提供合理的預設值。
預設值體現了設計介面時應使常見情況儘可能簡單的原則,也是部分隱藏資訊的一個範例:正常情況下,呼叫者不必知道有這項採用預設值的設定。只有在需要覆蓋預設值的少數情況下,呼叫者才需要了解相應的值,並呼叫專門的方法進行修改。
只要有可能,類就應該自動“做正確的事”,而不必等呼叫者顯式要求。預設值就是一個例子。第 4.7 節的 Java I/O 範例從反面說明了這一點。檔案 I/O 的使用者普遍希望獲得緩衝功能,因此呼叫者不應還得顯式請求它,甚至不必知道它的存在。I/O 類應該自動提供緩衝,做正確的事。最好的功能,是那些你甚至不知道它們存在的功能。
危險訊號:過度暴露
如果一個常用特性的 API 迫使使用者瞭解其他很少使用的特性,這將增加不需要使用這些特性的使用者的認知負荷。
5.8 類內部的資訊隱藏
本章的資訊隱藏範例著重於類對外可見的 API,但資訊隱藏也可以應用於系統的其他層級,比如類的內部。可以嘗試讓每個私有方法都封裝一些資訊或功能,向類的其餘部分隱藏。此外,應儘量減少使用每個實例變數的位置。有些變數可能需要在整個類中廣泛使用,另一些則可能只在少數地方用到。減少使用變數的位置,就能消除類內的依賴關係,降低複雜性。
5.9 做過頭了
只有模組外部不需要某項資訊時,隱藏它才有意義。如果模組外部需要該資訊,就不能隱藏。假設模組的效能受某些設定參數影響,而不同用途需要不同的參數設定。這時,在介面中暴露這些參數就很重要,以便適當調整。作為軟體設計師,你的目標應該是儘量減少模組外部需要了解的資訊。例如,如果模組能夠自動調整設定,就比公開設定參數更好。但必須識別出模組外部確實需要的資訊,並確保將其公開。
5.10 結論
資訊隱藏和深模組密切相關。模組隱藏的資訊越多,往往就能提供越多功能,同時縮小對外介面,使模組更深。反過來,如果模組沒有隱藏多少資訊,就要麼功能不多,要麼介面複雜。無論哪種情況,模組都是淺的。
將系統分解為模組時,儘量不要受執行時操作順序的影響,否則就會走向時間順序分解,導致資訊洩露和淺模組。應當考慮完成應用程式的任務需要哪些知識,再讓每個模組封裝其中的一項或幾項。這樣就能得到整潔、簡單且模組足夠深的設計。
[1] David Parnas,“關於將系統分解為模組的標準”,ACM 通訊,1972 年 12 月。 ↩
[2] 譯者注:關於知識(knowledge)可以瞭解“最少知識原則”的設計原則。 ↩
