第 9 章 在一起更好還是分開更好?
軟體設計中最基本的問題之一是:給定兩個功能,它們應該在同一個地方一起實現,還是應該分開實現?這個問題適用於系統中的所有層級,例如功能、方法、類和服務。例如,應該在提供面向流的檔案 I/O 的類中包括緩衝,還是應該在單獨的類中提供?HTTP 請求的解析應該完全在一個方法中實現,還是應該在多個方法(甚至多個類)之間劃分?本章討論做出這些決定時要考慮的因素。這些因素中的一些已經在前面的章節中進行了討論,但是為了完整起見,這裡將再次對其進行討論。
在決定是組合還是分開時,目標是降低整個系統的複雜性並改善其模組化。可能看起來實現此目標的最佳方法是將系統劃分為大量的小元件:每個單獨的元件越小,元件可能就越簡單。但是,細分的行為會帶來額外的複雜性,而這在細分之前是不存在的:
- 一部分複雜性就來自元件的數量:元件越多,就越難以追蹤所有元件,也就越難在大的元件集合中找到所需的元件。細分通常會導致更多介面,而每個新介面都會增加複雜性。
- 細分可能會導致需要附加的程式碼來管理元件。例如,在細分之前使用單個物件的一段程式碼現在可能必須管理多個物件。
- 細分會產生分離:細分後的元件將比細分前的元件距離更遠。例如,在細分之前位於單個類中的方法可能在細分之後位於不同的類中,並且可能在不同的檔案中。分離使開發人員更難於同時檢視這些元件,甚至很難知道它們的存在。如果元件真正獨立,那麼分離是好的:它使開發人員可以一次專注於單個元件,而不會被其他元件分散注意力。另一方面,如果元件之間存在依賴性,則分離是不好的:開發人員最終將在元件之間來回跳轉。更糟糕的是,他們可能不瞭解這些依賴關係,這可能導致程式碼缺陷。
- 細分可能導致重複:細分之前的單例程式碼可能需要存在於每個細分的元件中。
如果程式碼段緊密相關,則將它們組合在一起是最有益的。如果程式碼段互相無關,則最好分開。以下是判斷兩個程式碼段是否相關的一些訊號:
- 它們共享資訊;例如,這兩段程式碼都可能依賴於一個特定型別文件的語法。
- 它們總是一起被使用:任何使用其中一段程式碼的人都可能同時使用另一段程式碼。這種關係形式僅在其是雙向關係時才值得注意。作為反例,磁碟塊的快取記憶體幾乎總是會涉及到雜湊表,但是雜湊表可以在許多不涉及磁碟塊快取記憶體的情況下被使用。因此,這些模組應該分開。
- 它們在概念上重疊,因為存在一個更高層級的簡單類別可以涵蓋這兩段程式碼。例如,搜尋子字串和大小寫轉換都屬於字串操作的範疇,而流量控制和可靠的資訊傳遞都屬於網路通訊的範疇。
- 不看其中的一段程式碼就很難理解另一段。
本章的其餘部分使用更具體的規則以及示例來說明何時將程式碼段組合在一起以及何時將它們分開是有意義的。
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 語句通常被認為是一個壞主意,如果不加選擇地使用它們,可能會導致無法維護的程式碼,但是在諸如此類的情況下,它們可用於擺脫巢狀程式碼,因此也是有用的。
switch (common->opcode) {
case DATA: {
DataHeader* header = received->getStart<DataHeader>();
if (header == NULL) {
LOG(WARNING, "%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
}
...
case GRANT: {
GrantHeader* header = received->getStart<GrantHeader>();
if (header == NULL) {
LOG(WARNING, "%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
}
...
case RESEND: {
ResendHeader* header = received->getStart<ResendHeader>();
if (header == NULL) {
LOG(WARNING, "%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
}
...
}
圖 9.1:此程式碼處理不同型別的傳入網路資料包。對於每種型別,如果資料包對於該型別而言太短,則會記錄一條訊息。在此版本的程式碼中,LOG 語句對於幾種不同的資料包型別是重複的。
switch (common->opcode) {
case DATA: {
DataHeader* header = received->getStart<DataHeader>();
if (header == NULL)
goto packetTooShort;
...
case GRANT: {
GrantHeader* header = received->getStart<GrantHeader>();
if (header == NULL)
goto packetTooShort;
...
case RESEND: {
ResendHeader* header = received->getStart<ResendHeader>();
if (header == NULL)
goto packetTooShort;
...
}
...
packetTooShort:
LOG(WARNING, "%s packet from %s too short (%u bytes)",
opcodeSymbol(common->opcode),
received->sender->toString(),
received->len);
return;
圖 9.2:對圖 9.1 中的程式碼進行了重新組織,因此只有 LOG 語句的一個副本。
9.4 分離通用程式碼和專用程式碼
如果模組包含了可用於多種不同目的的機制,則它應僅提供一種通用機制。它不應包含專門針對特定用途的機制的程式碼,也不應包含其他通用機制。與通用機制關聯的專用程式碼通常應放在不同的模組中(通常是與特定用途關聯的模組)。第 6 章 中的圖形介面編輯器討論闡明瞭這一原則:最佳設計是文字類提供通用文字操作,而特定於使用者介面的操作(例如刪除所選的區域)則在使用者介面模組中實現。這種方法消除了早期設計中存在的資訊洩露和額外的介面,而在早期設計中,專門的使用者介面操作是在文字類中實現的。
危險訊號:重複
如果相同的程式碼(或幾乎相同的程式碼)一遍又一遍地出現,那是一個危險訊號,說明您沒有找到正確的抽象。
9.5 示例:插入游標和區域選擇
接下來的章節將透過兩個示例說明上述原則。在第一個示例中,最好的方法是分開相關的程式碼段。而在第二個示例中,最好將它們組合到一起。
第一個示例由第 6 章的影像介面編輯器專案中的插入游標和區域選擇組成。編輯器會顯示一個閃爍的垂直線,用來指示使用者鍵入的文字將出現在文件中的何處。它還會顯示一個高亮的字元範圍,稱之為選擇的區域,用於複製或刪除文字。插入游標始終可見,但是有時可能沒有選擇文字。如果存在選擇的區域,則插入游標始終位於其某一端。
區域選擇和插入游標在某些方面是相關的。例如,游標始終位於所選區域的一端,並且傾向於將插入游標和區域選擇一起操作:單擊並拖動滑鼠同時修改兩者,然後插入文字時會首先刪除所選的文字(如果有),然後在游標位置插入新的文字。因此,使用單個物件來管理區域選擇和插入游標似乎是合乎邏輯的,並且有一個專案團隊就採用了這種方法。該物件在檔案中儲存了兩個位置,以及兩個布林值,用來指示游標位於所選區域的哪一端以及是否存在區域選擇。
但是,組合的物件有點尷尬。它對較高層級的程式碼沒有任何好處,因為較高層級的程式碼仍然需要將區域選擇和插入游標視為不同的實體,並且對它們進行單獨操作(在插入文字期間,它首先在組合物件上呼叫一個方法來刪除選定的文字;然後呼叫另一個方法來檢索游標位置,以插入新的文字)。實際上,組合物件比分離的物件實現起來要複雜得多。它避免了將游標位置儲存為單獨的實體,但又不得不儲存一個布林值,以表示游標位於所選區域的哪一端。為了檢索游標位置,組合物件必須首先檢查布林值,然後再檢查所選區域對應的起始或結束位置。
在這種情況下,區域選擇和插入游標之間的關聯度不足以將它們組合在一起。當修改程式碼以分開區域選擇和插入游標時,用法和實現都變得更加簡單。與必須從中提取所選區域和插入游標資訊的組合物件相比,分開的物件提供了更簡單的介面。插入游標的實現也變得更加簡單,因為插入游標的位置是直接表示的,而不是透過所選區域和一個布林值間接表示的。實際上,在修訂的版本中,沒有專門的類用於區域選擇或插入游標。相反,引入了一個新的 Position
類來表示檔案中的位置(行號和行內的字元數)。所選區域用兩個 Position
表示,游標用一個 Position
表示。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 拆分和組合方法
何時細分的問題不僅適用於類,而且還適用於方法:是否最好將現有方法分為多個較小的方法?還是應該將兩種較小的方法組合為一種較大的方法?長方法比短方法更難於理解,因此許多人認為方法長度就是拆分方法的一個很好的理由。課堂上的學生通常會獲得嚴格的標準,例如“拆分超過 20 行的任何方法!”
但是,長度本身很少是拆分方法的一個很好的理由。通常,開發人員傾向於過多地拆分方法。拆分方法會引入額外的介面,從而增加了複雜性。它還將原始方法的各個部分分開,如果這些部分實際上是相關的,會使得程式碼更難閱讀。您只應該在會使整個系統更加簡單的情況下拆分一個方法,我將在下面討論這種情況。
長方法並不總是壞的。例如,假設一個方法包含按順序執行的五個 20 行的程式碼塊。如果這些塊是相對獨立的,則可以一次閱讀並理解該方法的一個塊。將每個塊移動到單獨的方法中並沒有太大的好處。如果這些塊之間具有複雜的互動,則將它們保持在一起就顯得更為重要,這樣讀者就可以一次看到所有程式碼。如果每個塊使用單獨的方法,則讀者將不得不在這些分散開的方法之間來回切換,以瞭解它們如何協同工作。如果方法具有簡單的簽名並且易於閱讀,則包含數百行程式碼的方法是可以接受的。這些方法很深(功能多,介面簡單),很好。
設計方法時,最重要的目標是提供整潔的抽象。每個方法都應該做一件事並且完整地做這件事。該方法應該具有簡單的介面,以便使用者無需費神就可以正確使用它。該方法應該是深的:其介面應該比其實現簡單得多。如果一個方法具有所有這些屬性,那麼它的長短與否就無關緊要了。
總體而言,拆分一個方法只有在會產生更清晰的抽象時才有意義。有兩種方式可以做到這一點,如圖 9.3 所示。最佳方法是將子任務分解為單獨的方法,如圖 9.3(b)所示。該細分產生一個包含該子任務的子方法和一個包含原始方法其餘部分的父方法;父方法呼叫子方法。新的父方法的介面與原始方法的介面相同。如果存在一個與原始方法的其餘部分完全可分離的子任務,則這種細分形式是有意義的,這意味著閱讀子方法的人不需要了解有關父方法的任何資訊,以及在閱讀父方法時不需要了解子方法的實現。通常,這意味著子方法是相對通用的:可以想象除父方法外,其他方法也可以使用它。如果您進行了這種形式的拆分,然後發現自己在父方法和子方法之間來回跳轉以瞭解它們如何一起工作,那是一個危險訊號(“連體方法”),表明拆分可能不是一個好主意。
圖 9.3:方法(a)可以透過提取子任務(b)或將其功能劃分為兩個單獨的方法(c)進行拆分。但如果會導致淺方法,則不應進行方法拆分,如(d)所示。
拆分方法的第二種方法是將其拆分為兩個獨立的方法,每個方法都對原始方法的呼叫者可見,如圖 9.3(c)所示。如果原始方法的介面過於複雜,這是有意義的,因為該介面試圖執行多個並不密切相關的操作。在這種情況下,可以將方法的功能劃分為兩個或更多個較小的方法,每個方法僅具有原始方法功能的一部分。如果進行這樣的拆分,則每個子方法的介面應該比原始方法的介面更簡單。理想情況下,大多數呼叫者只需要呼叫兩個新方法之一即可;如果呼叫者必須同時呼叫這兩個新方法,則將增加複雜性,這可能表明這樣的拆分不是一個好主意。新方法將更加專注於它們自己的工作。如果新方法比原始方法更具通用性,那麼這是一個好兆頭(例如,您可以想象在其他情況下單獨使用它們)。
圖 9.3(c)所示形式的拆分並不是很有意義,因為它們導致呼叫者不得不處理多個方法而不是一個方法。當您以這種方式拆分時,您可能會遇到變成多個淺方法的風險,如圖 9.3(d)所示。如果呼叫者必須呼叫每個單獨的方法,並在它們之間來回傳遞狀態,則拆分不是一個好主意。如果您正在考慮像圖 9.3(c)所示的拆分,則應基於它是否簡化了呼叫者的使用情況來進行判斷。
在某些情況下,透過將方法組合在一起可以簡化系統。例如,組合方法可以用一個更深的方法代替兩個淺的方法。它可以消除重複的程式碼;它可以消除原始方法或中間資料結構之間的依賴關係;它可以產生更好的封裝,從而使以前存在於多個位置的知識現在被隔離在一個位置;它也可以產生更簡單的介面,如 9.2 節所述。
危險訊號:連體方法
應該可以獨立地理解每一個方法。如果您只能在理解一個方法的實現的前提下才能理解另一個方法的實現,那就是一個危險訊號。該危險訊號也可以在其他情況下發生:如果兩段程式碼在物理上是分開的,但是隻有透過檢視另一段程式碼才能理解它們,這就是危險訊號。
9.8 不同的觀點:整潔的程式碼
在《整潔程式碼之道》一書中 [1],Robert Martin 認為函式應該僅根據長度進行拆分。他說,函式應該非常短,甚至 10 行都太長了:
函式的第一規則是要短小,第二條規則是還要更短小... if 語句、else 語句、while 等語句塊中的程式碼應該只有一行,該行大抵是一個函式呼叫語句... 這也意味著函式不應該大到足以容納巢狀結構。所以,函式的縮排層級不應多於一層或兩層。當然,這樣的函式易於閱讀和理解。
我同意短的函式一般來說比長的函式更容易理解。然而,一旦函式的程式碼行數少到幾十行,進一步的減少行數不太可能對可讀性產生太大的影響。更重要的是,函式的分解是否降低了系統的整體複雜性?換句話說,閱讀幾個短函式並理解它們是如何協同工作的比閱讀一個較大的函式更容易嗎?更多的函式意味著更多的介面需要文件化和學習。如果函式太小了,它們就失去了獨立性,導致必須一起閱讀和理解的連體函式。當這種情況發生時,最好還是保留較大的函式,以便所有相關的程式碼都在一個地方。深度比長度更重要:首先使函式變深,然後嘗試使它們足夠短,以便易於閱讀。不要為了長度而犧牲深度。
9.9 結論
拆分或組合模組的決定應基於複雜性。請選擇一種可以提供最好的資訊隱藏、最少的依賴關係和最深的介面的結構。
[1] 整潔程式碼之道, Robert C. Martin, Pearson Education, Inc., Boston, MA 2009