軟件設計的哲學:第四章 深度封裝模塊



管理軟件復雜性最重要的技術之一是系統設計,這樣開發人員在任何時候都只需要面對總體復雜性的一小部分。這種方法稱為模塊化設計,本章介紹其基本原理。

4.1 模塊化設計

在模塊化設計中,軟件系統被分解成一系列相對獨立的模塊。模塊可以采用多種形式,例如類、子系統或服務。在理想的情況下,每個模塊都完全獨立於其他模塊:開發人員可以在任何模塊中工作,而不需要了解任何其他模塊。在這個世界上,一個系統的復雜性就是其最壞模塊的復雜性。

不幸的是,這個理想是無法實現的。模塊必須通過調用彼此的函數或方法來協同工作。因此,模塊之間必須相互了解。模塊之間會有依賴關系:如果一個模塊改變了,其他模塊可能需要改變來匹配。 例如,方法的參數在方法和調用該方法的任何代碼之間創建依賴關系。如果所需的參數發生更改,則必須修改方法的所有調用以符合新簽名。依賴關系可以采取許多其他形式,而且可能非常微妙。模塊化設計的目標是最小化模塊之間的依賴關系。

為了管理依賴關系,我們將每個模塊分為兩部分:接口和實現。其中接口包含了在不同模塊中工作的開發人員為了使用給定模塊必須知道的所有內容。通常,接口描述模塊做什么,而不是如何做。實現由實現接口承諾的代碼組成。在特定模塊中工作的開發人員必須了解該模塊的接口和實現,以及給定模塊調用的任何其他模塊的接口。開發人員不應該需要了解模塊的實現而不是他或她所工作的模塊。

考慮一個實現平衡樹的模塊。模塊可能包含復雜的代碼,用於確保樹保持平衡。但是,這種復雜性對模塊的用戶是不可見的。用戶可以看到一個相對簡單的接口,用於調用在樹中插入、刪除和獲取節點的操作。要調用插入操作,調用方只需提供新節點的鍵和值;遍歷樹和分割節點的機制在接口中不可見。

對於本書而言,模塊是具有接口和實現的任何代碼單元。面向對象編程語言中的每個類都是一個模塊。類中的方法,或者非面向對象語言中的函數,也可以被看作模塊:每個模塊都有一個接口和一個實現,可以對它們應用模塊化設計技術。更高層次的子系統和服務也是模塊;它們的接口可能采用不同的形式,比如內核調用或HTTP請求。本書中關於模塊設計的討論主要集中在類的設計上,但是技術和概念也適用於其他類型的模塊。

最好的模塊是那些接口比實現簡單得多的模塊。 這樣的模塊有 兩個優點:首先,簡單的接口最小化了模塊對系統其余部分的影響。其次,如果一個模塊以不改變其接口的方式進行了修改,那么其他模塊都不會受到修改的影響。 如果一個模塊的接口比它的實現簡單得多,那么模塊的許多方面都可以在不影響其他模塊的情況下進行更改。

4.2什么是接口?

模塊的接口包含兩種信息:正式的和非正式的。接口的形式化部分在代碼中明確指定,其中一些可以由編程語言檢查其正確性。例如,方法的正式接口是其簽名,其中包括參數的名稱和類型、返回值的類型以及方法拋出的異常信息。大多數編程語言都確保方法的每次調用都提供正確的參數數量和類型,以匹配其簽名。類的正式接口由其所有公共方法的簽名,以及任何公共變量的名稱和類型組成。

每個接口還包括非正式元素。它們沒有以編程語言可以理解或執行的方式指定。接口的非正式部分包括其高級行為,例如函數刪除由其參數之一命名的文件。如果在類的使用上有約束(可能一個方法必須在另一個方法之前調用),這些也是類接口的一部分。通常,如果開發人員需要了解特定的信息才能使用模塊,那么這些信息就是模塊接口的一部分。接口的非正式方面只能用注釋來描述,而且編程語言不能確保描述是完整的或准確的。對於大多數接口來說,非正式方面比正式方面更大、更復雜。

一個明確指定的接口的好處之一是,它准確地指出了開發人員為了使用相關模塊而需要知道的內容。這有助於消除2.2節中描述的“未知的未知”問題。

4.3 抽象

抽象這個術語與模塊化設計的思想密切相關。抽象是一個實體的簡化視圖,它忽略了不重要的細節。抽象是有用的,因為它使我們更容易思考和操作復雜的事物。

在模塊化編程中,每個模塊都提供了接口的抽象形式。該接口提供了模塊功能的簡化視圖;從模塊抽象的角度來看,實現的細節並不重要,因此在接口中省略了它們。

在抽象概念的定義中,“不重要”這個詞是至關重要的。抽象中省略的不重要的細節越多越好。 然而,細節只有在不重要的情況下才能從抽象中省略。抽象可能在兩方面出錯。首先,它可以包含一些並不重要的細節,當這種情況發生時,它使抽象變得比必要的更復雜,這增加了使用抽象的開發人員的認知負擔。 第二個錯誤是抽象忽略了真正重要的細節。 這導致了模糊性:只關注抽象的開發人員將無法獲得正確使用抽象所需的所有信息。忽略重要細節的抽象是錯誤的抽象:它可能看起來很簡單,但實際上並不簡單。設計抽象的關鍵是理解什么是重要的,並尋找最小化重要信息量的設計。

以文件系統為例。文件系統提供的抽象忽略了許多細節,比如選擇存儲設備上哪些塊用於給定文件中的數據的機制。這些細節對於文件系統的用戶並不重要(只要系統提供足夠的性能)。但是,文件系統實現的某些細節對用戶來說很重要。大多數文件系統將數據緩存在主存中,為了提高性能,它們可能會延遲向存儲設備寫入新數據。有些應用程序(如數據庫)需要確切地知道何時將數據寫入存儲器,這樣它們就可以確保在系統崩潰后數據仍將被保留。因此,將數據刷新到輔助存儲的規則必須在文件系統的接口中可見。

我們不僅依賴抽象來管理編程中的復雜性,而且在我們的日常生活中也無處不在。微波爐包含復雜的電子元件,可以將交流電轉換成微波輻射,並將這種輻射散布在整個烹飪腔內。幸運的是,用戶看到的是一個更簡單的抽象,由幾個控制微波時間和強度的按鈕組成。汽車提供了一個簡單的抽象概念,使我們能夠在不了解電機、電池電源管理、防抱死剎車、巡航控制等機制的情況下驅動汽車。

4.4 深度模塊

最好的模塊是那些功能強大但接口簡單的模塊。我使用術語deep來描述這些模塊。為了可視化深度的概念,假設每個模塊由一個矩形表示,如圖4.1所示。每個矩形的面積與模塊實現的功能成比例。矩形的上邊緣表示模塊的接口;邊緣的長度表示接口的復雜性。最好的模塊是深度封裝的:它們在一個簡單的接口背后隱藏了很多功能。深度模塊是一個很好的抽象,因為用戶只能看到它內部復雜性的一小部分。

圖4.1:深和淺模塊。最好的模塊是深度的:它們允許通過一個簡單的接口訪問大量的功能。 淺層模塊具有相對復雜的接口,但是沒有太多的功能:它沒有隱藏太多的復雜性。

模塊深度是考慮成本與收益的一種方式。模塊提供的好處是它的功能,模塊的成本(就系統復雜性而言)是它的接口。模塊的接口表示模塊對系統其余部分施加的復雜性:接口越小、越簡單,它所引入的復雜性就越低。 最好的模塊是那些收益最大、成本最低的模塊。接口是好的,但更多或更大的接口不一定更好!

Unix操作系統及其后代(如Linux)提供的文件I/O機制是一個漂亮的深度接口示例。I/O只有5個基本的系統調用,簽名比較簡單:

int open(const char* path, int flags, mode_t permissions);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t lseek(int fd, off_t offset, int referencePosition);
int close(int fd);

open系統調用采用/a/b/c這樣的層次文件名,並返回一個整數文件描述符,用於引用打開的文件。open的其他參數提供可選的信息,比如文件是否被打開用於讀寫,如果沒有現有文件,是否應該創建新文件,如果創建了新文件,是否應該創建該文件的訪問權限。讀寫系統調用在應用程序的內存和文件的緩沖區之間傳輸信息;關閉對文件的訪問。大多數文件是按順序訪問的,所以這是默認的;但是,可以通過調用lseek系統調用來更改當前訪問位置來實現隨機訪問。

Unix I/O接口的現代實現需要數十萬行代碼,這些代碼解決了以下復雜問題:

  • 如何在磁盤上表示文件以允許有效訪問?
  • 如何存儲目錄,如何處理層次路徑名以查找它們所引用的文件?
  • 如何實施權限,使一個用戶不能修改或刪除另一個用戶的文件?
  • 如何實現文件訪問?例如,如何在中斷處理程序和后台代碼之間划分功能,以及這兩個元素如何安全通信?
  • 當存在對多個文件的並發訪問時,使用什么調度策略?
  • 如何將最近訪問的文件數據緩存在內存中以減少磁盤訪問的次數?
  • 如何將各種不同的輔助存儲設備(如磁盤和閃存驅動器)合並到單個文件系統中?

所有這些問題以及更多的問題都由Unix文件系統實現來處理;它們對於調用系統調用的程序員是不可見的。多年來,Unix I/O接口的實現已經發生了根本的變化,但是五個基本的內核調用並沒有改變。

深度模塊的另一個例子是Go或Java等語言中的垃圾收集器。該模塊完全沒有接口;它在幕后無形地回收未使用的內存。向系統中添加垃圾收集實際上會縮小整個接口,因為它消除了釋放對象的接口。垃圾收集器的實現相當復雜,但是這種復雜性對使用該語言的程序員來說是隱藏的。

Unix I/O和垃圾收集器等深度模塊提供了強大的抽象,因為它們易於使用,但它們隱藏了重要的實現復雜性。

4.5淺模塊

另一方面,與它提供的功能相比,淺層模塊的接口相對復雜。例如,實現鏈表的類是淺層次的。操作一個鏈表並不需要太多代碼(插入或刪除一個元素只需要幾行代碼),所以鏈表抽象並沒有隱藏很多細節。鏈表接口的復雜性幾乎與其實現的復雜性一樣大。淺層類有時是不可避免的,但是它們在管理復雜性方面沒有提供太多幫助。
下面是一個淺層方法的極端例子,取自軟件設計類中的一個項目:

private void addNullValueForAttribute (String attribute) {       
    data.put(attribute, null);
}

從管理復雜性的角度來看,這種方法使事情變得更糟,而不是更好。該方法不提供任何抽象,因為它的所有功能都是通過接口可見的。例如,調用者可能需要知道屬性將存儲在數據變量中。考慮接口並不比考慮完整的實現簡單。如果方法被正確地文檔化,文檔將會比方法的代碼長。調用該方法所需的擊鍵甚至比調用者直接操作數據變量所需的擊鍵還要多。該方法增加了復雜性(以開發人員可以學習的新接口的形式),但是沒有提供補償性的好處。

危險信號:淺層模塊
淺層模塊的接口相對於它提供的功能來說是復雜的。淺層模塊在與復雜性的斗爭中幫助不大,因為它們提供的好處(不需要了解它們內部如何工作)被學習和使用它們的接口的成本所抵消。小模塊往往是淺層的。

4.6 類拆分

不幸的是,深度課程的價值在今天沒有得到廣泛的重視。編程的傳統智慧是類應該是小的,而不是深的。學生們經常被教導,在班級設計中最重要的事情是把大班級分成小班級。對於方法也經常給出相同的建議:“任何超過N行的方法都應該划分為多個方法”(N可以低至10)。這種方法會產生大量的淺層類和方法,從而增加了整個系統的復雜性。

“類應該小”方法的極端是一種我稱之為類拆分的綜合征,它源於“類是好的,所以更多的類更好”的錯誤觀點。在遭受類拆分困擾的系統中,鼓勵開發人員最小化每個新類中的功能數量:如果您想要更多的功能,那么就引入更多的類。類拆分可能會產生單獨簡單的類,但是它增加了整個系統的復雜性。小類不會提供太多的功能,所以必須有很多類,每個類都有自己的接口。這些接口的積累在系統級造成了巨大的復雜性。由於每個類都需要樣板文件,所以小類也會導致冗長的編程風格。

4.7示例:Java和Unix I/O

Java類庫是當今classitis最常見的例子之一。Java語言不需要太多的小類,但是classitis文化似乎已經在Java編程社區中扎根了。例如,要打開一個文件以便從中讀取序列化的對象,您必須創建三個不同的對象:

FileInputStream fileStream = new FileInputStream(fileName);
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);

FileInputStream對象只提供基本的I/O:它不能執行緩沖的I/O,也不能讀寫序列化的對象。BufferedInputStream對象將緩沖添加到FileInputStream中,而ObjectInputStream添加了讀取和寫入序列化對象的能力。上面代碼中的頭兩個對象,fileStream和bufferedStream,在文件打開后就不會使用;所有未來的操作都使用objectStream。

必須通過創建一個單獨的buffer愛丁堡流對象來顯式地請求緩沖,這尤其令人惱火(而且容易出錯);如果開發人員忘記創建這個對象,就不會有緩沖,I/O也會很慢。也許Java開發人員會爭辯說,並不是每個人都想對文件I/O使用緩沖,所以不應該將其內置到基本機制中。他們可能會爭論說最好將緩沖分開,這樣人們就可以選擇是否使用它。提供選擇是好的,但是接口的設計應該使普通情況盡可能簡單(參見第6頁的公式)。對於那些不需要緩沖的少數情況,庫可以提供一種機制來禁用它。任何禁用緩沖的機制都應該在接口中清楚地分開(例如,通過為ileInputStream提供不同的構造函數,或者通過禁用或替換緩沖機制的方法),以便大多數開發人員甚至不需要知道它的存在。

相反,Unix系統調用的設計人員簡化了常見的情況。例如,他們認識到順序I/O是最常見的,因此他們將其作為默認行為。使用lseek系統調用進行隨機訪問仍然相對容易,但是只進行順序訪問的開發人員不需要知道這種機制。如果一個接口有很多特性,但是大多數開發人員只需要知道其中的幾個,那么這個接口的有效復雜度就是常用特性的復雜度。

4.8 結論

通過將模塊的接口與其實現分離,我們可以向系統的其他部分隱藏實現的復雜性。模塊的用戶只需要理解其接口提供的抽象。在設計類和其他模塊時,最重要的問題是使它們更深入,這樣它們就有了公共用例的簡單接口,同時還提供了重要的功能。這最大化了隱藏的復雜性。

存在這樣的語言,主要是在研究領域,在那里一個方法或功能的整體行為可以用規范語言來正式描述。可以自動檢查規范,以確保它與實現匹配。一個有趣的問題是,這樣的正式規范是否可以取代接口的非正式部分。我目前的觀點是,對於開發人員來說,用英語描述的接口可能比用正式規范語言編寫的接口更直觀、更容易理解。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM