軟件設計的哲學: 第九章 合併仍是分解

軟件設計中最基本的問題之一是:給定兩部分功能,它們應該在同一個地方一塊兒實現,仍是應該分開實現? 這個問題適用於系統中的全部級別,好比函數、方法、類和服務。 例如,緩衝應該包含在提供面向流的文件I/O的類中,仍是應該包含在單獨的類中?HTTP請求的解析應該徹底在一個方法中實現,仍是應該在多個方法(甚至多個類)中進行?本章討論了作出這些決定時須要考慮的因素。這些因素中的一些已經在前幾章中討論過,可是爲了完整起見,這裏將從新討論它們。緩存

在決定是合併仍是分離時,目標是下降整個系統的複雜性並改進其模塊化。實現這一目標的最佳方法彷佛是將系統劃分爲大量的小組件:組件越小,每一個單獨的組件可能就越簡單。 然而,細分的行爲產生了額外的複雜性,這在細分以前是不存在的:服務器

  • 一些複雜性僅僅來自組件的數量:組件越多,就越難以跟蹤它們,也就越難以在大型集合中找到所需的組件。細分一般會致使更多的接口,並且每一個新接口都會增長複雜性。
  • 細分可能致使管理組件的額外代碼。例如,在細分以前使用單個對象的一段代碼如今可能必須管理多個對象。
  • 細分產生分離:細分後的組件將比細分前更加分離。例如,在細分以前在單個類中的方法可能在細分以後在不一樣的類中,也可能在不一樣的文件中。分離使得開發人員很難同時看到組件,甚至很難意識到它們的存在。若是組件是真正獨立的,那麼分離是好的:它容許開發人員一次只關注一個組件,而不會被其餘組件分散注意力。另外一方面,若是組件之間存在依賴關係,則分離是很差的:開發人員最終將在組件之間來回切換。更糟糕的是,他們可能沒有意識到依賴關係,這可能會致使bug。
  • 細分可能致使重複:在細分以前存在於單個實例中的代碼可能須要存在於每一個細分的組件中。

若是代碼片斷緊密相關,那麼將它們組合在一塊兒是最有益的。若是這些部分是不相關的,那麼最好分開。 這裏有一些跡象代表,兩段代碼是相關的:微信

  • 他們分享信息;例如,這兩段代碼可能取決於特定類型文檔的語法。
  • 它們一塊兒使用:任何使用其中一段代碼的人均可能使用另外一段代碼。這種形式的關係只有在雙向的狀況下才有吸引力。做爲一個反例,磁盤塊緩存幾乎老是涉及到一個散列表,可是散列表能夠在許多不涉及塊緩存的狀況下使用;所以,這些模塊應該是獨立的。
  • 它們在概念上是重疊的,由於有一個簡單的更高級別的類別,其中包括這兩段代碼。例如,搜索子字符串和大小寫轉換都屬於字符串操做的範疇;流量控制和可靠交付都屬於網絡通訊的範疇。
  • 若是不看另外一段代碼,就很難理解其中一段代碼。

本章的其他部分將使用更具體的規則和示例來講明什麼時候將代碼片斷放在一塊兒是有意義的,以及什麼時候將它們分開是有意義的。網絡

9.1 若是共享信息,則將信息集合在一塊兒

第5.4節在實現HTTP服務器的項目上下文中介紹了這一原則。在第一個實現中,該項目使用不一樣類中的兩個不一樣方法來讀入和解析HTTP請求。第一個方法讀取來自網絡套接字的傳入請求的文本,並將其放在字符串對象中。第二個方法解析字符串以提取請求的各個組件。分解,最終的兩個方法都有至關知識的HTTP請求的格式:第一種方法只是想讀請求,解析它,但它不能識別的最後請求不作的大部分工做的解析(例如,它解析頭線以識別包含總體請求的標題長度)。因爲這種共享信息,最好在同一個位置讀取和解析請求;當這兩個類合併爲一個類時,代碼變得更短更簡單。數據結構

9.2 若是能夠簡化接口,就一塊兒使用

當兩個或多個模塊組合成一個模塊時,能夠爲新模塊定義一個比原來的接口更簡單或更容易使用的接口。這種狀況常常發生在原始模塊實現問題解決方案的一部分時。在前一節的HTTP服務器示例中,原始方法須要一個接口來從第一個方法返回HTTP請求字符串並將其傳遞給第二個方法。當這些方法組合在一塊兒時,這些接口就被消除了。編程語言

此外,當兩個或多個類的功能組合在一塊兒時,可能會自動執行某些功能,所以大多數用戶不須要知道它們。Java I/O庫說明了這一機會。若是將FileInputStream和BufferedInputStream類組合在一塊兒,而且默認提供了緩衝,那麼絕大多數用戶甚至都不須要知道緩衝的存在。組合的FileInputStream類可能提供禁用或替換默認緩衝機制的方法,可是大多數用戶不須要了解這些方法。編輯器

9.3 消除重複

若是您發現重複出現相同的代碼模式,請嘗試從新組織代碼以消除重複。一種方法是將重複的代碼分解成一個單獨的方法,並將重複的代碼片斷替換爲對該方法的調用。 若是重複的代碼段很長,而且替換方法有一個簡單的簽名,那麼這種方法是最有效的。若是代碼段只有一兩行,那麼用方法調用替換它可能沒有什麼好處。若是代碼段以複雜的方式與它的環境交互(例如經過訪問許多局部變量),那麼替換方法可能須要複雜的簽名(例如許多引用傳遞參數),這將下降它的值。模塊化

消除重複的另外一種方法是重構代碼,使有問題的代碼片斷只須要在一個地方執行。 假設您正在編寫一個方法,該方法須要在幾個不一樣的點上返回錯誤,而且在返回以前須要在這些點上執行相同的清理操做(參見圖9.1中的示例)。若是編程語言支持goto,您能夠將清理代碼移動到方法的末尾,而後轉到須要錯誤返回的每一個點,如圖9.2所示。Goto語句一般被認爲是一個糟糕的想法,若是不加選擇地使用它們,可能會致使沒法破譯的代碼,可是在這種狀況下它們是有用的,由於它們能夠用來逃避嵌套的代碼。函數

9.4 通用代碼和專用代碼分開

若是一個模塊包含一個能夠用於多個不一樣目的的機制,那麼它應該只提供一個通用機制。它不該該包含專門用於特定用途的機制的代碼,也不該該包含其餘通用機制。與通用機制相關聯的專用代碼一般應該放在不一樣的模塊中(一般是與特定用途相關聯的模塊)。第6章中的GUI編輯器討論說明了這一原則:最佳設計是文本類提供通用的文本操做,而用戶界面的特定操做(如刪除選擇)在用戶界面模塊中實現。這種方法消除了早期設計中出現的信息泄漏和額外的接口,在早期設計中,專門的用戶界面操做是在text類中實現的。

危險信號:重複
若是同一段代碼(或幾乎相同的代碼)反覆出現,這是一個危險信號,說明您沒有找到正確的抽象。

圖9.1:此代碼處理不一樣類型的入站網絡數據包;對於每種類型,若是信息包過短而不適合該類型,則記錄一條消息。在這個版本的代碼中,日誌語句被複制到幾個不一樣的包類型中。

圖9.2:對圖9.1中的代碼進行重組,使日誌語句只有一個副本。

通常來講,系統的低層每每是通用的,而上層則是專用的。例如,應用程序的最頂層由徹底特定於該應用程序的特性組成。將專用代碼從通用代碼中分離出來的方法是將專用代碼向上拉到更高的層中,而將較低的層保留爲通用代碼。

當你遇到一個類,包括通用和專用功能相同的抽象,看看類能夠分爲兩個類,一個包含通用功能,其餘之上提供專用功能。

9.5 示例:插入光標和選擇

下一節將經過三個示例來講明上面討論的原則。在兩個例子中,最好的方法是分離相關的代碼片斷;在第三個例子中,最好將它們鏈接在一塊兒。

第一個例子由第6章的GUI編輯器項目中的插入遊標和選擇組成。編輯器顯示一條閃爍的豎線,指示用戶鍵入的文本將出如今文檔中的何處。它還顯示了一個高亮顯示的字符範圍,稱爲選擇,用於複製或刪除文本。插入光標老是可見的,但有時可能沒有選擇文本。若是選擇項存在,則插入光標始終定位在選擇項的一端。

選擇和插入遊標在某些方面是相關的。例如,光標老是停留在一個選擇,和光標選擇每每是一塊兒操做:點擊並拖動鼠標設置他們兩人,和文本插入第一個刪除選中的文本,若是有任何,而後在光標位置插入新的文本。所以,使用單個對象來管理選擇和遊標彷佛是合理的,一個項目團隊採用了這種方法。該對象在文件中存儲了兩個位置,以及布爾值,布爾值指示哪一端是遊標,以及選擇是否存在。

然而,組合的對象是尷尬的。它沒有爲高級代碼提供任何好處,由於高級代碼仍然須要知道選擇和遊標是不一樣的實體,而且須要分別操做它們(在文本插入期間,它首先調用組合對象上的一個方法來刪除所選的文本;而後,它調用另外一個方法來檢索光標位置,以便插入新文本)。組合對象實際上比單獨的對象更復雜。它避免將遊標位置存儲爲單獨的實體,而是必須存儲一個布爾值,指示選擇的哪一端是遊標。爲了檢索光標位置,組合對象必須首先測試布爾值,而後選擇適當的選擇結束。

危險信號:特殊和通常的混合物

當通用機制還包含專門用於該機制特定用途的代碼時,就會出現此警告。這使得機制更加複雜,並在機制和特定用例之間產生信息泄漏:將來對用例的修改可能也須要對底層機制進行更改。

本例中,選擇和遊標之間的關係不夠緊密,沒法將它們組合在一塊兒。當修改代碼以將選擇和遊標分隔開時,使用和實現都變得更簡單了。與必須從中提取選擇和遊標信息的組合對象相比,分離對象提供了更簡單的接口。遊標實現也變得更簡單了,由於遊標位置是直接表示的,而不是經過選擇和布爾值間接表示的。事實上,在修訂版本中,選擇和遊標都沒有使用特殊的類。相反,引入了一個新的Position類來表示文件中的一個位置(行號和行中的字符)。選擇用兩個位置表示,遊標用一個位置表示。這些職位在項目中還有其餘用途。這個示例還演示了較低級但更通用的接口的好處,這在第6章中討論過。

9.6示例:日誌記錄的單獨類

第二個例子涉及到學生項目中的錯誤日誌記錄。一個類包含以下代碼序列:

try {
      rpcConn = connectionPool.getConnection(dest);
} catch (IOException e) {
      NetworkErrorLogger.logRpcOpenError(req, dest, e);
      return null;
}

不是在錯誤被檢測到的地方記錄錯誤,而是調用一個特殊的錯誤日誌類中的一個單獨的方法。錯誤日誌類是在同一個源文件的末尾定義的:

private static class NetworkErrorLogger {
     /**
      *  Output information relevant to an error that occurs when trying
      *  to open a connection to send an RPC.
      *
      *  @param req 
                The RPC request that would have been sent through the connection
      *  @param dest
      *       The destination of the RPC
      *  @param e
      *       The caught error
      */
     public static void logRpcOpenError(RpcRequest req, AddrPortTuple dest, Exception e) {
         logger.log(Level.WARNING, "Cannot send message: " + req + ". \n" + "Unable to find or open connection to " + dest + " :" + e);
      }
...

}

NetworkErrorLogger類包含幾個方法,如logRpcSendError和logRpcReceiveError,每一個方法都記錄不一樣類型的錯誤。

這種分離增長了複雜性,但沒有帶來任何好處。日誌記錄方法很簡單:大多數都是由一行代碼組成的,可是它們須要大量的文檔。每一個方法只在一個地方調用。日誌記錄方法高度依賴於它們的調用:讀取調用的人極可能會切換到日誌記錄方法,以確保記錄了正確的信息;相似地,閱讀日誌記錄方法的人可能會轉到調用站點以瞭解方法的用途。

在本例中,最好消除日誌記錄方法,並將日誌語句放置在檢測到錯誤的位置。這將使代碼更易於閱讀,並消除日誌方法所需的接口。

9.7示例:編輯器撤銷機制

在6.2部分的GUI編輯器項目中,其中一個需求是支持多級撤銷/重作,不只是對文本自己的更改,還包括對選擇、插入遊標和視圖的更改。例如,若是用戶選擇某個文本,刪除它,滾動到文件中的另外一個位置,而後調用undo,編輯器必須將其狀態恢復到刪除以前的狀態。這包括恢復被刪除的文本,再次選擇它,並使選擇的文本在窗口中可見。

一些學生項目將整個撤銷機制做爲text類的一部分實現。text類維護了一個全部可撤銷更改的列表。當文本被更改時,它會自動向這個列表添加條目。對於選擇、插入遊標和視圖的更改,用戶界面代碼調用text類中的其餘方法,而後這些方法將這些更改的條目添加到撤消列表中。當用戶請求撤消或重作時,用戶界面代碼調用text類中的一個方法,而後由該方法處理撤消列表中的條目。對於與文本相關的條目,它更新了文本類的內部結構;對於與其餘內容(如選擇)相關的條目,文本類將調用回用戶界面代碼以執行撤消或重作。

這種方法致使文本類中出現一組使人尷尬的特性。撤銷/重作的核心是一種通用機制,用於管理已執行的操做列表,並在撤消和重作操做期間逐步執行這些操做。核心位於text類中,與特殊用途的處理程序一塊兒,這些處理程序爲特定的事情(好比文本和選擇)實現撤銷和重作。用於選擇和遊標的特殊用途的撤消處理程序與文本類中的任何其餘內容無關;它們致使文本類和用戶界面之間的信息泄漏,以及每一個模塊中來回傳遞撤消信息的額外方法。若是未來向系統中添加了一種新的可撤消實體,則須要對text類進行更改,包括特定於該實體的新方法。此外,通用撤銷核心與類中的通用文本工具幾乎沒有什麼關係。

這些問題能夠經過提取撤銷/重作機制的通用核心並將其放在一個單獨的類中來解決:

public class History {
        public interface Action {
               public void redo();
                       public void undo();
        }

        History() {...}

        void addAction(Action action) {...}

        void addFence() {...}

        void undo() {...}

        void redo() {...}
}

在本設計中,History類管理實現接口History. action的對象集合。每個歷史。Action描述單個操做,例如文本插入或光標位置的更改,並提供能夠撤消或重作操做的方法。History類不知道操做中存儲的信息,也不知道它們如何實現撤銷和重作方法。History維護一個歷史列表,該列表描述了在應用程序的生命週期中執行的全部操做,它提供了undo和redo方法,這些方法在響應用戶請求的undos和redos時來回遍歷列表,調用History. actions中的undo和redo方法。

歷史。操做是特殊用途的對象:每一個操做都理解一種特定的可撤消操做。它們在History類以外的模塊中實現,這些模塊理解特定類型的可撤銷操做。text類能夠實現UndoableInsert和UndoableDelete對象來描述文本插入和刪除。每當插入文本時,text類都會建立一個新的UndoableInsert對象來描述插入並調用歷史記錄。addAction將其添加到歷史記錄列表。編輯器的用戶界面代碼可能建立UndoableSelection和UndoableCursor對象,它們描述對選擇和插入遊標的更改。

History類還容許對操做進行分組,例如,來自用戶的單個undo請求能夠恢復已刪除的文本、從新選擇已刪除的文本和從新定位插入光標。

有不少方法來組織動做;History類使用fence,它是歷史列表中的標記,用於分隔相關操做的組。每次遍歷歷史。redo向後遍歷歷史記錄列表,撤消操做,直到到達下一個圍欄。fence的位置由調用History.addFence的高級代碼決定。

這種方法將撤銷的功能分爲三類,分別在不一樣的地方實現:

  • 一種通用的機制,用於管理和分組操做以及調用undo/redo操做(由History類實現)。
  • 特定操做的細節(由各類類實現,每一個類理解少許的操做類型)。
  • 分組操做的策略(由高級用戶界面代碼實現,以提供正確的總體應用程序行爲)。

這些類別中的每個均可以在不瞭解其餘類別的狀況下實現。歷史課不知道哪些行爲被撤銷了;它能夠用於各類各樣的應用。每一個action類只理解一種action,而History類和action類都不須要知道分組action的策略。

關鍵的設計決策是將撤消機制的通用部分與專用部分分離,並將通用部分單獨放在類中。一旦完成了這一步,剩下的設計就天然而然地結束了。

注意: 將通用代碼與專用代碼分離的建議是指與特定機制相關的代碼。例如,特殊用途的撤消代碼(例如撤消文本插入的代碼)應該與通用用途的撤消代碼(例如管理歷史記錄列表的代碼)分開。然而,將一種機制的專用代碼與另外一種機制的通用代碼組合起來一般是有意義的。text類就是這樣一個例子:它實現了管理文本的通用機制,可是它包含了與撤銷相關的專用代碼。撤消代碼是專用的,由於它只處理文本修改的撤消操做。將這段代碼與History類中通用的undo基礎結構結合在一塊兒是沒有意義的,可是將它放在text類中是有意義的,由於它與其餘文本函數密切相關。

9.8 分解和鏈接方法

什麼時候細分的問題不分解僅適用於類,也適用於方法:是否存在將現有方法劃分爲多個較小的方法更好的時機?或者,兩個較小的方法應該合併成一個較大的方法嗎?長方法每每比短方法更難理解,所以許多人認爲,長度自己就是分解方法的一個很好的理由。學生在課堂上常常被給予嚴格的標準,如「分解任何超過20行的方法!」

可是,長度自己不多是拆分方法的好理由。 通常來講,開發人員傾向於過多地分解方法。拆分方法會引入額外的接口,增長了複雜性。它還分離了原始方法的各個部分,若是這些部分其實是相關的,就會使代碼更難讀取。你不該該破壞一個方法,除非它使整個系統更簡單;我將在下面討論這是如何發生的。

長方法並不老是壞事。例如,假設一個方法包含五個按順序執行的20行代碼塊。若是這些塊是相對獨立的,則能夠一次讀取和理解一個塊;將每一個塊移動到一個單獨的方法中沒有什麼好處。若是代碼塊具備複雜的交互,那麼將它們放在一塊兒更重要,這樣讀者就能夠一次看到全部代碼;若是每一個塊位於一個單獨的方法中,讀者將不得不在這些展開的方法之間來回切換,以瞭解它們是如何協同工做的。若是方法具備簡單的簽名而且易於閱讀,那麼包含數百行代碼的方法就很好。這些方法很深奧(功能不少,接口簡單),這很好。

圖9.3:一個方法(A)能夠經過提取一個子任務(b)或者經過將其功能劃分爲兩個單獨的方法(c)來分解。

在設計方法時,最重要的目標是提供簡潔而簡單的抽象。 每一種方法都應該作一件事,並且要作得完全。 這個方法應該有一個乾淨簡單的界面,這樣用戶就不須要在他們的頭腦中有太多的信息來正確地使用它。方法應該是深度的:它的接口應該比它的實現簡單得多。 若是一個方法具備全部這些屬性,那麼它是否長可能並不重要。

總的來講,分解方法只有在產生更清晰的抽象時纔有意義。有兩種方法能夠作到這一點,如圖9.3所示。最好的方法是將一個子任務分解成單獨的方法,如圖9.3(b)所示。細分產生包含子任務的子方法和包含原始方法其他部分的父方法;父調用子調用。新父方法的接口與原始方法相同。這種形式的細分有意義若是有乾淨地分離的子任務的原始方法,這意味着(a)有人閱讀孩子的方法不須要知道任何關於父法和(b)有人閱讀父法不須要理解孩子的實現方法。一般這意味着子方法是相對通用的:它能夠被父方法以外的其餘方法使用。若是您對這個表單進行拆分,而後發現本身在父類和子類之間來回切換,以瞭解它們是如何協同工做的,那麼這就是一個危險信號(「聯合方法」),代表拆分可能不是一個好主意。

分解一個方法的第二種方法是將它分解成兩個單獨的方法,每一個方法對於原始方法的調用者都是可見的,如圖9.3(c)所示。若是原始方法有一個過於複雜的接口,這是有意義的,由於它試圖作許多不密切相關的事情。若是是這種狀況,能夠將方法的功能劃分爲兩個或多個更小的方法,每一個方法只具備原始方法的一部分功能。若是像這樣分解,每一個結果方法的接口應該比原始方法的接口簡單。理想狀況下,大多數調用者應該只須要調用兩個新方法中的一個;若是調用者必須同時調用這兩個新方法,那麼這就增長了複雜性,從而下降了拆分的可能性。新方法將更專一於它們所作的事情。若是新方法比原來的方法更通用,這是一個好跡象。你能夠想象在其餘狀況下分別使用它們)。

圖9.3(c)中所示的表單分解一般沒有意義,由於它們致使調用者必須處理多個方法,而不是一個。當您以這種方式進行劃分時,您可能會獲得幾個淺層方法,如圖9.3(d)所示。若是調用者必須調用每一個單獨的方法,在它們之間來回傳遞狀態,那麼分解不是一個好主意。若是您正在考慮相似圖9.3(c)中的拆分,那麼您應該根據它是否簡化了調用者的工做來判斷它。

在某些狀況下,能夠經過將方法鏈接在一塊兒來簡化系統。例如,鏈接方法能夠用一個較深的方法代替兩個較淺的方法;它能夠消除重複的代碼;它能夠消除原始方法或中間數據結構之間的依賴關係;它可能致使更好的封裝,所以之前在多個地方出現的知識如今被隔離在一個地方;或者,它可能致使一個更簡單的接口,如9.2節中所討論的那樣。

危險信號:聯合方法

應該可以獨立地理解每種方法。若是你不能理解一個方法的實現而不理解另外一個方法的實現,那就是一個危險信號。此微信型號也能夠出如今其餘上下文中:若是兩段代碼在物理上是分開的,可是每段代碼只能經過查看另外一段代碼來理解,這就是危險信號。

9.9 結論

拆分或聯接模塊的決策應該基於複雜性。選擇可以隱藏最佳信息、最少依賴和最深接口的結構。

相關文章
相關標籤/搜索