第 6 章 通用的模組是更深的
在講授軟體設計課程的過程中,我一直在嘗試找出學生程式碼中複雜性的來源,這也改變了我對軟體設計的若干看法。其中最重要的變化,涉及通用性與專用化的權衡。我反覆發現,專用化會導致複雜性;現在我認為,過度專用化可能是軟體中最大的複雜性來源。相反,更通用的程式碼更簡單、更整潔,也更容易理解。
本章概覽介面要多通用才合適?
這個原則適用於軟體設計的不同層級。設計類或方法等模組時,構建深 API 的最佳方法之一是使其通用化(通用 API 能隱藏更多資訊)。編寫具體程式碼時,簡化程式碼的最有效方法之一是消除特殊情況,讓處理常見情況的程式碼也能處理邊界情況。消除特殊情況還可以提高程式碼的效率,第 20 章將介紹這一點。
本章討論專用化帶來的問題和通用性的好處。專用化無法完全消除,因此本章也會介紹如何將專用程式碼與通用程式碼分開。
6.1 使類的介面足夠通用
設計新類時,最常見的決策之一就是採用通用還是專用的實作方式。有人可能主張採用通用方式,構建一種能解決多種問題的機制,而不侷限於當前重要的問題。這樣,新機制將來可能派上意想不到的用場,從而節省時間。通用方式似乎符合第 3 章討論的投資思維:前期多花一點時間,以節省以後的時間。
另一方面,軟體系統的未來需求很難預測,因此通用方案可能包含永遠用不到的功能。如果實作過於通用,還可能無法很好地解決眼前的具體問題。因此,也有人可能主張只關注當前需求,只構建確定需要的功能,並針對預期的使用方式進行專用化處理。採用專用方式後,如果將來發現其他用途,仍可以透過重構使其通用。專用方式似乎符合增量軟體開發的理念。
剛開始講授軟體設計課程時,我傾向於第二種方式(先構建專用實作),但教過幾輪後,我改變了主意。審查學生專案時,我注意到通用類幾乎總是優於專用類。尤其令我驚訝的是,通用介面不僅更簡單、更深,實作所需的程式碼也更少。事實證明,即使一個類只用於特定用途,以通用方式構建它也更省力。將來把這個類用於其他用途時,通用設計還能節省更多時間。即便不復用這個類,通用方式仍然更好。
根據我的經驗,最合適的做法是讓新模組適度通用。“適度通用”意味著,模組的功能應反映當前需求,但介面不應侷限於這些需求。介面應該足夠通用,能夠支援多種用途,同時又便於滿足當前需求,而不與當前用途繫結。“適度”很重要:不要追求通用性過了頭,反而讓模組難以用於當前需求。
6.2 範例:為編輯器儲存文字
來看軟體設計課程中的一個範例:學生需要構建一個簡單的圖形介面文字編輯器。編輯器必須能顯示檔案,並允許使用者透過指向、點選和輸入來編輯檔案。它還必須支援在不同視窗中同時顯示同一檔案的多個視圖,以及對檔案修改進行多級撤銷和重做。
每個學生專案都包括一個管理檔案文字的類。文字類通常提供以下方法:將檔案載入到記憶體、讀取和修改檔案中的文字,以及將修改後的文字寫回檔案。
許多學生團隊為文字類實作了專用的 API。他們知道這個類將用於互動式編輯器,因此根據編輯器必須提供的功能,定製了文字類的 API。例如,使用者按下退格鍵時,編輯器會刪除緊鄰游標左側的字元;按下刪除鍵時,則刪除緊鄰游標右側的字元。為此,一些團隊在文字類中為每項功能分別建立了一個方法:
void backspace(Cursor cursor);
void delete(Cursor cursor);
這些方法都以游標位置為參數,用專用型別 Cursor 表示這一位置。編輯器還必須支援複製或刪除所選區域。學生為此定義了 Selection 類,並在刪除時將這個類的物件傳給文字類:
void deleteSelection(Selection selection);
學生們可能認為,讓文字類的方法與使用者可見的功能相對應,會更容易實作使用者介面。實際上,這種專用化對使用者介面程式碼幾乎沒有好處,卻給開發使用者介面或文字類的人帶來了很高的認知負荷。文字類最終包含大量淺方法,每個都只適用於一種使用者介面操作。許多方法(例如 delete)只在一處呼叫。結果,使用者介面開發人員必須學習文字類的大量方法。
這種方式造成了使用者介面與文字類之間的資訊洩露。區域選擇、退格鍵等使用者介面抽象出現在文字類中,增加了文字類開發人員的認知負荷。每增加一種使用者介面操作,都需要在文字類中定義一個新方法,因此使用者介面開發人員很可能還要修改文字類。類設計的目標之一是讓每個類都能獨立開發,但專用方式卻將使用者介面與文字類繫結在了一起。
6.3 更通用的 API
更好的方法是使文字類更通用。其 API 應僅根據基本的文字功能進行定義,而不應反映用其實作的更高層級的操作。例如,只需提供兩個方法即可修改文字:
void insert(Position position, String newText);
void delete(Position start, Position end);
前一個方法在文字的任意位置插入任意字串,後一個方法刪除從開始位置(包含)到結束位置(不包含)之間的所有字元。這個 API 還用更通用的 Position 型別代替了針對特定使用者介面的 Cursor 型別。文字類也應提供操作文字位置的通用方法,例如:
Position changePosition(Position position, int numChars);
此方法回傳一個新位置,與給定位置相距指定的字元數。如果 numChars 參數為正,新位置就在給定位置之後;如果 numChars 為負,新位置就在給定位置之前。必要時,該方法會自動跳到下一行或上一行。藉助這些方法,可以用以下程式碼實作刪除鍵的行為(假定 cursor 變數儲存當前游標位置):
text.delete(cursor, text.changePosition(cursor, 1));
類似地,可以按以下方式實作退格鍵的行為:
text.delete(text.changePosition(cursor, -1), cursor);
使用通用文字 API 實作刪除、退格等使用者介面功能,程式碼比原先使用專用文字 API 時稍長一些,但更易理解。使用者介面開發人員可能需要知道退格鍵會刪除哪些字元。在新程式碼中,這一點一目瞭然;而在舊程式碼中,開發人員必須閱讀文字類中退格方法的文件或程式碼,才能確認其行為。此外,通用方式的總程式碼量比專用方式更少,因為它用少量通用方法替代了文字類中的大量專用方法。
使用通用介面實作的文字類,除了互動式編輯器,還可能用於其他場景。例如,假設你要構建一個應用程式,將指定檔案中所有匹配某個字串的內容替換為另一個字串。專用文字類中的方法(例如 backspace 和 delete)對這個應用程式幾乎沒有價值,而通用文字類已經具備它所需的大部分功能。缺少的只是查詢給定字串下一個匹配項的方法,例如:
Position findNext(Position start, String string);
當然,互動式文字編輯器很可能已有搜尋和替換機制,這樣文字類也就已經包含這個方法了。
6.4 通用性可以更好地隱藏資訊
通用方式讓文字類與使用者介面類的職責劃分更清晰,從而更好地隱藏資訊。文字類無需瞭解如何處理退格鍵等使用者介面細節,這些細節都封裝在使用者介面類中。新增新的使用者介面功能時,也無需在文字類中增加配套的方法。通用介面還減輕了認知負荷:使用者介面開發人員只需學習幾個簡單的方法,就能將它們用於多種用途。
文字類原始版本中的 backspace 方法是錯誤的抽象。它試圖隱藏“刪除哪些字元”這一資訊,但使用者介面模組恰恰需要知道這一點。使用者介面開發人員很可能會閱讀 backspace 方法的程式碼,以確認其確切行為。把這個方法放在文字類中,只會讓他們更難獲得所需的資訊。軟體設計最重要的要素之一,就是確定誰需要在什麼時候知道什麼。重要的細節應當明確表達,並儘可能容易看出來,修改後的退格操作實作就是如此。把這些資訊隱藏在介面後面,只會產生模糊性。
譯者圖解三個刪除動作,一種文字機制
6.5 問自己一些問題
識別一個整潔的通用類設計,比自己設計出來容易。下面這些問題可以幫助你在介面的通用性與專用性之間找到適當的平衡。
滿足當前所有需求的最簡單的介面是什麼? 如果能減少 API 中的方法數量而不降低其整體功能,那你可能正在建立更通用的方法。專用的文字 API 至少具有三個刪除文字的方法:backspace、delete 和 deleteSelection。而更通用的 API 只有一個刪除文字的方法,它可以同時滿足所有三個目的。僅在每個方法的 API 都保持簡單的前提下,減少方法的數量才有意義。如果你必須引入許多額外的參數才能減少方法數量,那麼你可能並沒有真正簡化介面。
這個方法會在多少種情況下被使用? 如果一個方法只為某種特定用途而設計,例如 backspace 方法,這就是一個危險訊號,表明它可能過於專用。看看是否可以用一個通用方法替換幾個專用方法。
這個 API 對於當前的需求來說是否易於使用? 這個問題可以幫助你判斷,是否過度追求 API 的簡單和通用。如果為了滿足當前用途,必須額外編寫大量程式碼才能使用這個類,這就是介面未提供合適功能的危險訊號。例如,可以圍繞單字元操作設計文字類:用 insert 方法插入單個字元,用 delete 方法刪除單個字元。這個 API 既簡單又通用,但在文字編輯器中不太便於使用:高層級程式碼需要編寫大量迴圈來插入或刪除一段字元,處理大規模操作時效率也很低。因此,文字類最好內建對字元範圍操作的支援。
6.6 將專用程式碼上移(或下移!)
大多數軟體系統都不可避免地包含一些專用程式碼。例如,應用程式為使用者提供的具體功能往往具有很強的專用性,因此通常無法完全消除專用化。不過,專用程式碼應與通用程式碼明確分開。將專用程式碼沿軟體棧上移或下移,就能做到這一點。
分離專用程式碼的一種方式是將其上移。應用程式的頂層類提供具體功能,自然需要針對這些功能進行專用化,但這種專用化不必滲透到實作功能所用的底層類中。本章前面的編輯器範例就體現了這一點。學生的原始實作將專用的使用者介面細節(如退格鍵的行為)洩露到了文字類的實作中。改進後的文字 API 將所有專用程式碼上移到使用者介面中,文字類只保留通用程式碼。
有時,將專用程式碼下移才是最好的分離方式,裝置驅動程式就是一個例子。作業系統通常必須支援數百或數千種裝置,例如各種輔助儲存裝置。每種裝置都有自己的專用命令集。為了防止裝置的專用特徵洩露到作業系統主體程式碼中,作業系統會定義一個介面,規定所有輔助儲存裝置都必須實作的通用操作,例如“讀取塊”和“寫入塊”。裝置驅動程式模組利用各裝置的專用功能來實作這一通用介面。這樣,專用程式碼就下移到了裝置驅動程式中,編寫作業系統核心程式碼時,無需瞭解特定裝置的特徵。這也便於新增新裝置:只要裝置具備實作驅動程式介面所需的功能,就可以將其加入系統,而無需修改作業系統主體程式碼。
6.7 範例:編輯器撤銷機制
圖形介面編輯器專案的一項要求是支援多級撤銷和重做,不僅針對文字修改,也包括所選區域、插入游標和視圖的變化。例如,使用者選中一些文字並刪除,滾動到檔案中的其他位置,再執行撤銷時,編輯器必須恢復到刪除前的狀態。這包括恢復被刪除的文字、重新選中它,並讓所選文字在視窗中可見。
一些學生專案把整個撤銷機制都放在文字類中實作。文字類維護一個列表,記錄所有可撤銷的更改,每次修改文字時都會自動新增記錄。對於所選區域、插入游標和視圖的變化,使用者介面程式碼會呼叫文字類中的額外方法,將相應記錄加入撤銷列表。使用者請求撤銷或重做時,使用者介面程式碼呼叫文字類中的方法,由它處理撤銷列表中的記錄。對於文字相關的記錄,直接更新文字類的內部狀態;對於所選區域等其他內容的記錄,文字類則回撥使用者介面程式碼,執行撤銷或重做。
這種做法讓文字類包含了一組難以協調的功能。撤銷和重做的核心是一個通用機制:管理已執行的動作列表,並在撤銷或重做時逐個處理這些動作。這個核心與針對文字、所選區域等具體內容的專用撤銷和重做處理常式,一起放在文字類中。所選區域和插入游標的專用撤銷處理常式與文字類的其他內容毫無關係,卻造成了文字類與使用者介面之間的資訊洩露,還要求兩個模組增加方法,來回傳遞撤銷資訊。將來每增加一種可撤銷的實體,都需要修改文字類,為該實體新增專用方法。此外,通用的撤銷核心與類中的通用文字功能也幾乎沒有關係。
將撤銷和重做機制中的通用核心提取出來,放到單獨的類中,就能解決這些問題:
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 介面的物件。每個 History.Action 描述一個操作,例如插入文字或更改游標位置,並提供撤銷或重做該操作的方法。History 類無需瞭解這些操作儲存了什麼資訊,也不必知道它們如何實作撤銷和重做。History 類維護一個歷史列表,記錄應用程式生命週期內執行的所有操作;它提供的撤銷和重做方法,會響應使用者請求,向後或向前遍歷列表,並呼叫 History.Actions 中的撤銷和重做方法。
History.Actions 都是專用物件,每個物件只瞭解某種特定的可撤銷操作。它們在 History 類之外、瞭解相應操作的模組中實作。文字類可以實作 UndoableInsert 和 UndoableDelete 物件,描述文字的插入和刪除。每次插入文字時,文字類都會建立一個新的 UndoableInsert 物件來描述此次插入,再呼叫 History.addAction 將它加入歷史列表。編輯器的使用者介面程式碼則可以建立 UndoableSelection 和 UndoableCursor 物件,描述所選區域和插入游標的變化。
History 類還允許將操作分組。例如,使用者的一次撤銷請求可以同時恢復被刪除的文字、重新選中這些文字,並重新定位插入游標。History 類用柵欄來實作分組:柵欄是歷史列表中的標記,用於分隔各組相關操作。每次呼叫 History.redo 都會向後遍歷歷史列表,撤銷操作,直到下一個柵欄。柵欄的位置由更高層級的程式碼透過呼叫 History.addFence 決定。
譯者注(勘誤):原文此處寫作
History.redo,但描述的是向後遍歷歷史列表並撤銷操作。根據本節對撤銷和重做的定義,此處應為History.undo。正文保留原文寫法。
這種方式將撤銷功能分為三類,分別在不同的地方實作:
- 管理操作、對操作分組並呼叫撤銷和重做操作的通用機制(由
History類實作)。 - 特定操作的細節(由多個類實作,每個類都理解少量的操作型別)。
- 分組操作的策略(由高層級使用者介面程式碼實作,以提供正確的整體應用程式行為)。
實作每一類功能時,都不必瞭解其他類別。History 類不知道正在撤銷哪種操作,因此可以用於多種應用程式。每個操作類只瞭解一種操作,而 History 類和操作類都無需瞭解操作分組的策略。
關鍵的設計決策是將撤銷機制的通用部分與專用部分分開:為通用部分建立單獨的類,將專用部分下移到 History.Action 的子類中。確定這一點後,其餘設計就順理成章了。
譯者注(勘誤):原文稱這些類為
History.Action的“子類”,但本節程式碼將History.Action宣告為介面,因此更準確的說法是“實作該介面的類”。前文的History.Actions指多個實作該介面的物件,並非程式碼中另行宣告的型別。正文保留原文表述。
注意:將通用程式碼與專用程式碼分開的建議,針對的是同一種機制中的程式碼。例如,專用的撤銷程式碼(如撤銷文字插入的程式碼)應與通用的撤銷程式碼(如管理歷史列表的程式碼)分開。但將一種機制的專用程式碼與另一種機制的通用程式碼放在一起,可能是合理的。文字類就是這樣的例子:它實作了通用的文字管理機制,也包含專用的撤銷程式碼。這些撤銷程式碼是專用的,因為它們只處理文字修改的撤銷。將它們與 History 類中的通用撤銷程式碼放在一起並不合理,而放在文字類中則合理,因為它們與其他文字功能密切相關。
譯者圖解撤銷時,誰知道要恢復什麼?
6.8 消除程式碼裡的特殊情況
前面的討論圍繞類和方法設計中的專用化展開。方法體中的特殊情況,是專用化的另一種形式。特殊情況可能讓程式碼充斥著 if 語句,既難理解,也容易引入缺陷,因此應儘可能消除。最好的做法是設計好正常情況的處理邏輯,讓它無需額外程式碼就能自動處理邊界情況。
在文字編輯器專案中,學生需要實作區域選擇機制,並支援複製或刪除所選內容。大多數學生在實作時引入一個狀態變數,表示所選區域是否存在。他們可能是因為有時螢幕上沒有可見的所選區域,才認為在實作中表示這一狀態很自然。然而,這種做法導致程式碼中出現大量檢查,專門識別和處理“沒有所選區域”的情況。
消除“沒有所選區域”這一特殊情況,讓所選區域始終存在,就能簡化區域選擇的處理程式碼。螢幕上沒有可見的所選區域時,可以在內部用起止位置相同的空選區表示。這樣,管理所選區域的程式碼就不必檢查“沒有所選區域”的情況。複製所選內容時,如果所選區域為空,就會在新位置插入 0 位元組;只要實作正確,就不必將 0 位元組作為特殊情況進行檢查。同樣,刪除所選區域的程式碼也可以設計成無需特殊檢查,就能處理空區域。以所選區域完全位於同一行內為例:刪除時,取出該行中位於所選區域之前和之後的兩部分,將它們拼接成新行。如果所選區域為空,這種做法就會重新生成原來那一行。
第 10 章將討論例外帶來的更多特殊情況,以及如何減少需要處理例外的位置。
譯者圖解沒有選區,也能走同一條路徑
6.9 結論
不必要的專用化是軟體複雜性的重要來源,無論它表現為專用的類和方法,還是程式碼中的特殊情況。專用化無法完全消除,但良好的設計能顯著減少它,並將專用程式碼與通用程式碼分開。這樣可以使類更深,更好地隱藏資訊,讓程式碼更簡單、更易理解。