軟件設計的哲學: 第九章 合並還是分解


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

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


免責聲明!

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



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