天天看點

軟體設計的哲學:第四章 深度封裝子產品

通過将子產品的接口與其實作分離,我們可以向系統的其他部分隐藏實作的複雜性。子產品的使用者隻需要了解其接口提供的抽象。在設計類和其他子產品時,最重要的問題是使它們更深入,這樣它們就有了公共用例的簡單接口,同時還提供了重要的功能。這最大化了隐藏的複雜性。

目錄

  • 4.1 子產品化設計
  • 4.2什麼是接口?
  • 4.3 抽象
  • 4.4 深度子產品
  • 4.5淺子產品
  • 4.6 類拆分
  • 4.7示例:Java和Unix I/O
  • 4.8 結論

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

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

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

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

考慮一個實作平衡樹的子產品。子產品可能包含複雜的代碼,用于確定樹保持平衡。但是,這種複雜性對子產品的使用者是不可見的。使用者可以看到一個相對簡單的接口,用于調用在樹中插入、删除和擷取節點的操作。要調用插入操作,調用方隻需提供新節點的鍵和值;周遊樹和分割節點的機制在接口中不可見。

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

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

子產品的接口包含兩種資訊:正式的和非正式的。接口的形式化部分在代碼中明确指定,其中一些可以由程式設計語言檢查其正确性。例如,方法的正式接口是其簽名,其中包括參數的名稱和類型、傳回值的類型以及方法抛出的異常資訊。大多數程式設計語言都確定方法的每次調用都提供正确的參數數量和類型,以比對其簽名。類的正式接口由其所有公共方法的簽名,以及任何公共變量的名稱和類型組成。

每個接口還包括非正式元素。它們沒有以程式設計語言可以了解或執行的方式指定。接口的非正式部分包括其進階行為,例如函數删除由其參數之一命名的檔案。如果在類的使用上有限制(可能一個方法必須在另一個方法之前調用),這些也是類接口的一部分。通常,如果開發人員需要了解特定的資訊才能使用子產品,那麼這些資訊就是子產品接口的一部分。接口的非正式方面隻能用注釋來描述,而且程式設計語言不能確定描述是完整的或準确的。對于大多數接口來說,非正式方面比正式方面更大、更複雜。

一個明确指定的接口的好處之一是,它準确地指出了開發人員為了使用相關子產品而需要知道的内容。這有助于消除2.2節中描述的“未知的未知”問題。

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

在子產品化程式設計中,每個子產品都提供了接口的抽象形式。該接口提供了子產品功能的簡化視圖;從子產品抽象的角度來看,實作的細節并不重要,是以在接口中省略了它們。

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

以檔案系統為例。檔案系統提供的抽象忽略了許多細節,比如選擇儲存設備上哪些塊用于給定檔案中的資料的機制。這些細節對于檔案系統的使用者并不重要(隻要系統提供足夠的性能)。但是,檔案系統實作的某些細節對使用者來說很重要。大多數檔案系統将資料緩存在主存中,為了提高性能,它們可能會延遲向儲存設備寫入新資料。有些應用程式(如資料庫)需要确切地知道何時将資料寫入存儲器,這樣它們就可以確定在系統崩潰後資料仍将被保留。是以,将資料重新整理到輔助存儲的規則必須在檔案系統的接口中可見。

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

最好的子產品是那些功能強大但接口簡單的子產品。我使用術語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和垃圾收集器等深度子產品提供了強大的抽象,因為它們易于使用,但它們隐藏了重要的實作複雜性。

另一方面,與它提供的功能相比,淺層子產品的接口相對複雜。例如,實作連結清單的類是淺層次的。操作一個連結清單并不需要太多代碼(插入或删除一個元素隻需要幾行代碼),是以連結清單抽象并沒有隐藏很多細節。連結清單接口的複雜性幾乎與其實作的複雜性一樣大。淺層類有時是不可避免的,但是它們在管理複雜性方面沒有提供太多幫助。

下面是一個淺層方法的極端例子,取自軟體設計類中的一個項目:

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

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

危險信号:淺層子產品

淺層子產品的接口相對于它提供的功能來說是複雜的。淺層子產品在與複雜性的鬥争中幫助不大,因為它們提供的好處(不需要了解它們内部如何工作)被學習和使用它們的接口的成本所抵消。小子產品往往是淺層的。

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

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

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系統調用進行随機通路仍然相對容易,但是隻進行順序通路的開發人員不需要知道這種機制。如果一個接口有很多特性,但是大多數開發人員隻需要知道其中的幾個,那麼這個接口的有效複雜度就是常用特性的複雜度。

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

關注公衆号:架構未來 ,我們一起學習成長

軟體設計的哲學:第四章 深度封裝子產品

繼續閱讀