第 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
類實現)。 - 特定操作的細節(由多個類實現,每個類都理解少量的操作型別)。
- 分組操作的策略(由高層級使用者介面程式碼實現,以提供正確的整體應用程式行為)。
這些類別中的每一個都可以在不瞭解其他類別的情況下被實現。History
類不知道要撤消哪種操作;它可以在多種應用中被使用。每個操作類僅理解一種操作,並且 History
類和操作類都不需要知道將操作分組的策略。
關鍵的設計決策是將撤消機制的通用部分與專用部分分開,為通用部分建立單獨的類並將專用的部分下沉到 History.Action
的子類中。一旦完成,其餘的設計就自然而然的出現了。
注意:建議將通用程式碼與特定機制相關的專用程式碼分離開來。例如,特殊用途的撤消程式碼(例如撤消文字插入的程式碼)應該與通用用途的撤消程式碼(例如管理歷史記錄列表的程式碼)分開。然而,將一種機制的專用程式碼與另一種機制的通用程式碼組合起來可能也是有意義的。文字類就是這樣一個例子:它實現了管理文字的通用機制,但是它包含了與撤銷相關的專用程式碼。這些撤消程式碼是專用的,因為它只處理文字修改的撤消操作。將這段程式碼與 History
類中通用的撤銷程式碼組合在一起沒有意義,但是將它放在文字類中是有意義的,因為它與其他文字函式密切相關。
6.8 消除程式碼裡的特殊情況
到目前為止的討論都是針對類和方法設計裡的專用化。另一種形式的專用化發生在方法的實現體裡,以特殊情況的形態出現。特殊情況會導致程式碼中充斥著 if
語句,這使程式碼難以理解並容易導致缺陷。因此,應儘可能地消除特殊情況。做到這一點的最好方法是以一種無需任何額外程式碼就能自動處理邊界情況的方式來設計正常情況。
在文字編輯器專案中,學生必須實現一種選擇文字以及複製或刪除所選內容的機制。大多數學生在他們的選擇實現中引入了狀態變數,以表明選擇是否存在。他們之所以使用這種方法,是因為有時螢幕上看不到任何選擇,因此在實現中似乎很自然地代表了這一概念。但是,這種方法導致了大量的檢查,以檢測“沒有選擇”的情況,並專門處理它。
透過消除“沒有選擇”的特殊情況,可以簡化選擇處理程式碼,從而使選擇始終存在。當螢幕上沒有可見的選擇時,可以在內部用空的選擇表示,其開始和結束位置相同。使用這種方法,管理選擇的程式碼無需對“沒有選擇”進行任何檢查。複製所選內容時,如果所選內容為空,則將在新位置插入 0 位元組。如果正確實現,無需將 0 位元組作為特殊情況來處理。同樣,對於刪除選擇的程式碼,應該也能設計成無需任何對特殊情況的檢查就可以處理選擇為空的情況。考慮選擇一整行的情況。要刪除選擇,提取選擇之前的行的一部分,並將其與選擇之後的行的部分連線起來以形成新行。如果選擇為空,則此方法將重新生成原始行。
第 10 章將討論異常(它們導致了更多的特殊情況)以及如何減少必須處理異常的地方的數量。
6.9 結論
不管是專用的類或方法還是程式碼裡的特殊情況,都是軟體複雜性的主要來源。專用程式碼無法完全消除,但透過好的設計能夠顯著減少專用程式碼,並將專用程式碼與通用程式碼分開。這能使類更深、做到更好的資訊隱藏以及讓程式碼更簡單、更清晰。