1. 程式人生 > >軟體設計的哲學: 第九章 合併還是分解

軟體設計的哲學: 第九章 合併還是分解

目錄

  • 9.1 如果共享資訊,則將資訊集合在一起
  • 9.2 如果可以簡化介面,就一起使用
  • 9.3 消除重複
  • 9.4 通用程式碼和專用程式碼分開
  • 9.5 示例:插入游標和選擇
  • 9.6示例:日誌記錄的單獨類
  • 9.7示例:編輯器撤銷機制
  • 9.8 分解和連線方法
  • 9.9 結論

軟體設計中最基本的問題之一是:給定兩部分功能,它們應該在同一個地方一起實現,還是應該分開實現? 這個問題適用於系統中的所有級別,比如函式、方法、類和服務。 例如,緩衝應該包含在提供面向流的檔案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 結論

拆分或聯接模組的決策應該基於複雜性。選擇能夠隱藏最佳資訊、最少依賴和最深介面的結