第 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 ↩
