1. 程式人生 > >軟體設計的哲學: 第十章 定義不存在錯誤

軟體設計的哲學: 第十章 定義不存在錯誤

目錄

  • 10.1 異常增加複雜性的原因
  • 10.2 例外情況太多
  • 10.3 定義不存在的錯誤
  • 10.4 示例:在Windows中刪除檔案
  • 10.5 示例:Java子字串方法
  • 10.6 遮蔽異常
  • 10.7 異常聚合
  • 10.8 事故?
  • 10.9 設計不存在的特殊情況
  • 10.10 做過了頭
  • 10.11 結論

異常處理是軟體系統中最糟糕的複雜性來源之一。處理特殊情況的程式碼天生就比處理正常情況的程式碼更難編寫,而且開發人員經常在定義異常時沒有考慮如何處理它們。本章討論了異常對複雜性的不成比例的貢獻,然後展示瞭如何簡化異常處理。本章的主要教訓是減少必須處理異常的地方;在許多情況下,可以修改操作的語義,使正常行為可以處理所有情況,並且不需要報告任何異常情況(這就是本章的標題)。

10.1 異常增加複雜性的原因

我使用術語異常來指改變程式中正常控制流的任何不尋常的情況。許多程式語言都包含一個正式的異常機制,該機制允許底層程式碼丟擲異常並通過封裝程式碼捕獲異常。但是,即使不使用正式的異常報告機制,也可能發生異常,例如當一個方法返回一個特殊值,表明它沒有完成正常行為。所有這些形式的異常都增加了複雜性。

一段特定的程式碼可能會遇到幾種不同的異常:

  • 呼叫者可能提供錯誤的引數或配置資訊。
  • 被呼叫的方法可能無法完成請求的操作。例如,I/O操作可能失敗,或者所需的資源可能不可用。
  • 在分散式系統中,網路資料包可能丟失或延遲,伺服器可能無法及時響應,或者對等節點可能以無法預料的方式通訊。
  • 程式碼可能會檢測出bug、內部不一致或無法處理的情況。

大型系統必須處理許多異常情況,特別是當它們是分散式的或者需要容錯的時候。異常處理佔系統中所有程式碼的很大一部分。

異常處理程式碼天生就比正常情況下的程式碼更難寫。異常中斷了正常的程式碼流;它通常意味著某事沒有像預期的那樣工作。當異常發生時,程式設計師可以用兩種方法處理它,每種方法都很複雜。第一種方法是向前推進並完成正在進行的工作,儘管存在例外。例如,如果一個網路資料包丟失,它可以被重發;如果資料損壞了,也許可以從冗餘副本中恢復。第二種方法是中止正在進行的操作,向上報告異常。但是,中止可能很複雜,因為異常可能發生在系統狀態不一致的地方(資料結構可能已經部分初始化);異常處理程式碼必須恢復一致性,例如通過撤銷發生異常之前所做的任何更改。

此外,異常處理程式碼為更多的異常創造了機會。考慮重新發送丟失的網路包的情況。也許包裹實際上並沒有丟失,只是被耽擱了。在這種情況下,重新發送資料包將導致重複的資料包到達對等點;這引入了一個新的異常條件,對等方必須處理。或者,考慮從冗餘副本中恢復丟失的資料的情況:如果冗餘副本也丟失了怎麼辦?在恢復期間發生的次要異常通常比主要異常更微妙和複雜。如果通過中止正在進行的操作來處理異常,則必須將此異常作為另一個異常報告給呼叫者。為了防止異常的無休止級聯,開發人員最終必須找到一種方法來處理異常,而不引入更多的異常。

對異常的語言支援往往冗長而笨拙,這使得異常處理程式碼難以閱讀。例如,考慮以下程式碼,它使用Java對物件序列化和反序列化的支援從檔案中讀取tweet集合:

try (
      FileInputStream fileStream =new FileInputStream(fileName);
      BufferedInputStream bufferedStream =new BufferedInputStream(fileStream);
      ObjectInputStream objectStream =new ObjectInputStream(bufferedStream);

) 
{
      for (int i = 0; i < tweetsPerFile; i++) {
            tweets.add((Tweet) objectStream.readObject());
      }

}
catch (FileNotFoundException e) {
      ...
}

catch (ClassNotFoundException e) {
      ...
}
catch (EOFException e) {
      // Not a problem: not all tweet files have full
      // set of tweets.
}

catch (IOException e) {
      ...
}

catch (ClassCastException e) {
      ...
}

但是,基本的try-catch樣板程式碼比正常情況下的操作程式碼行數更多,甚至不考慮實際處理異常的程式碼。很難將異常處理程式碼與正常情況程式碼聯絡起來:例如,在哪裡生成每個異常並不明顯。另一種方法是把程式碼分成許多不同的try塊;在極端情況下,可以嘗試生成異常的每一行程式碼。這將使異常發生的地方變得清晰,但是try塊本身會破壞程式碼流,使其更難讀取;此外,一些異常處理程式碼可能會在多個try塊中重複。

很難確保異常處理程式碼真正有效。有些異常,比如I/O錯誤,在測試環境中很難生成,因此很難測試處理它們的程式碼。異常在執行的系統中不經常發生,所以很少執行異常處理程式碼。bug可能很長一段時間都無法檢測到,當最終需要異常處理程式碼時,它很可能無法工作(我最喜歡的說法之一是:“未執行的程式碼無法工作”)。最近的一項研究發現,在分散式資料密集型系統中,超過90%的災難性故障是由錯誤處理引起的。當異常處理程式碼失敗時,很難除錯問題,因為它發生的頻率很低。

10.2 例外情況太多

程式設計師通過定義不必要的異常而加劇了與異常處理相關的問題。大多數程式設計師都被告知檢測和報告錯誤很重要;他們通常將其解釋為“檢測到的錯誤越多越好”。這導致了一種過度防禦的風格,任何看起來有點可疑的東西都會被異常拒絕,這導致了不必要的異常的擴散,增加了系統的複雜性。

在設計Tcl指令碼語言時,我自己也犯了這個錯誤。Tcl包含一個未設定的命令,可用於刪除變數。我定義了unset以便在變數不存在時丟擲錯誤。當時我認為,如果有人試圖刪除一個不存在的變數,那麼它一定是一個bug,所以Tcl應該報告它。然而,unset最常見的用途之一是清理以前操作建立的臨時狀態。通常很難準確地預測建立了什麼狀態,特別是在操作中途中止的情況下。因此,最簡單的方法是刪除可能已經建立的所有變數。unset的定義使得這種情況很尷尬:開發人員最終會在catch語句中封裝對unset的呼叫,以捕獲並忽略unset丟擲的錯誤。回顧過去,unset命令的定義是我在Tcl設計中犯下的最大錯誤之一。

使用異常來避免處理困難的情況是很有誘惑力的:與其找出一個乾淨的方法來處理它,不如丟擲一個異常並把問題推給呼叫者。有些人可能會認為這種方法賦予了呼叫者權力,因為它允許每個呼叫者以不同的方式處理異常。然而,如果你在特定情況下不知道該怎麼做,很有可能打電話的人也不知道該怎麼做。在這種情況下生成異常只會將問題傳遞給其他人,並增加系統的複雜性。

類丟擲的異常是其介面的一部分;具有大量異常的類具有複雜的介面,並且它們比具有較少異常的類要淺。異常是介面中特別複雜的元素。它可以在被捕獲之前向上傳播幾個堆疊級別,因此它不僅影響方法的呼叫者,還可能影響更高級別的呼叫者(及其介面)。

丟擲異常很容易,處理它們很困難。因此,異常的複雜性來自於異常處理程式碼。減少異常處理造成的複雜性損害的最佳方法是減少必須處理異常的地方的數量。 本章的其餘部分將討論減少異常處理程式數量的四種技術。

10.3 定義不存在的錯誤

消除異常處理複雜性的最佳方法是定義api,這樣就沒有異常需要處理:定義不存在的錯誤。 這可能看起來有些褻瀆,但在實踐中卻非常有效。考慮前面討論的Tcl unset命令。當unset被要求刪除一個未知變數時,它應該簡單地返回,而不是丟擲一個錯誤。我應該稍微修改一下unset的定義:unset應該確保一個變數不再存在,而不是刪除一個變數。對於第一個定義,如果變數不存在,unset就無法執行其任務,因此生成異常是有意義的。對於第二個定義,使用不存在的變數的名稱來呼叫unset是非常自然的。在這種情況下,它的工作已經完成,所以它可以簡單地返回。不再需要報告錯誤情況。

10.4 示例:在Windows中刪除檔案

檔案刪除提供了另一個如何定義錯誤的例子。如果檔案在程序中開啟,Windows作業系統不允許刪除該檔案。對於開發人員和使用者來說,這是一個持續的沮喪之源。為了刪除正在使用的檔案,使用者必須在系統中搜索,找到開啟該檔案的程序,然後殺死該程序。有時使用者會放棄並重新啟動他們的系統,只是為了刪除一個檔案。

Unix作業系統更優雅地定義了檔案刪除。在Unix中,如果檔案在刪除時開啟,Unix不會立即刪除該檔案。

它將檔案標記為刪除,然後刪除操作成功返回。該檔名已從其目錄中刪除,因此其他程序無法開啟舊檔案,並且可以建立具有相同名稱的新檔案,但現有的檔案資料將持續存在。已經開啟檔案的程序可以繼續正常地讀取和寫入檔案。一旦檔案被所有訪問程序關閉,它的資料就會被釋放。

Unix方法定義了兩種不同的錯誤。首先,刪除操作不再返回一個錯誤,如果檔案當前正在使用;刪除成功,檔案最終將被刪除。其次,刪除正在使用的檔案不會為使用該檔案的程序建立異常。解決這個問題的一種可能的方法是立即刪除檔案,並標記所有開啟的檔案來禁用它們;其他程序讀取或寫入刪除檔案的任何嘗試都將失敗。但是,這種方法會為那些要處理的程序建立新的錯誤。相反,Unix允許它們繼續正常地訪問檔案;延遲檔案刪除定義了不存在的錯誤。

Unix允許程序繼續讀寫一個命中註定的檔案,這似乎有些奇怪,但我從未遇到過這種情況,它會導致嚴重的問題。對於開發人員和使用者來說,Unix下的檔案刪除定義要比Windows下的定義簡單得多。

10.5 示例:Java子字串方法

最後一個例子是Java String類及其子String方法。給定一個字串中的兩個索引,substring返回從第一個索引給出的字元開始並以第二個索引之前的字元結束的子字串。但是,如果其中一個索引超出了字串的範圍,則子字串將丟擲IndexOutOfBoundsException。此異常是不必要的,並使此方法的使用複雜化。我經常遇到這樣的情況,其中一個或兩個索引可能在字串的範圍之外,我希望提取字串中與指定範圍重疊的所有字元。不幸的是,這需要我檢查每一個指標,把它們四捨五入到0或到字串的末尾;一個單行的方法呼叫現在變成了5-10行程式碼。

如果Java子字串方法自動執行此調整,那麼它將更容易使用,以便實現以下API:“返回索引大於或等於beginIndex而小於endIndex的字串字元(如果有的話)。這是一個簡單而自然的API,它定義了IndexOutOfBoundsException異常。即使一個或兩個索引是負的,或者beginIndex大於endIndex,該方法的行為也已經定義好了。這種方法簡化了方法的API,同時增加了它的功能,因此使方法更加深入。許多其他語言都採用了無錯誤的方法;例如,Python為超出範圍的列表片返回一個空結果。

當我主張定義不存在的錯誤時,人們有時反駁說丟擲錯誤會捕獲bug;如果錯誤被定義為不存在,那麼這是否會導致bug生成?也許這就是為什麼Java開發人員決定子字串應該丟擲異常的原因。這種錯誤的方法可能會捕獲一些bug,但也會增加複雜性,從而導致其他bug。在錯誤的方法中,開發人員必須編寫額外的程式碼來避免或忽略錯誤,這增加了錯誤的可能性;或者,他們可能忘記編寫額外的程式碼,在這種情況下,可能會在執行時丟擲意外的錯誤。相反,定義不存在的錯誤簡化了api,並減少了必須編寫的程式碼量。

總的來說,減少錯誤的最好方法是使軟體更簡單。

10.6 遮蔽異常

減少必須處理異常的位置數量的第二種技術是異常遮蔽。 使用這種方法,可以在系統的較低級別上檢測和處理異常情況,這樣較高級別的軟體就不必知道該情況。異常遮蔽在分散式系統中特別常見。例如,在網路傳輸協議(如TCP)中,可以由於各種原因(如損壞和擁塞)丟棄資料包。TCP通過在其實現中重新發送丟失的包來掩蓋包丟失,因此所有資料最終都能通過,而客戶端並不知道丟失的包。

NFS網路檔案系統中出現了一個更具爭議性的遮蔽示例。如果NFS檔案伺服器崩潰或由於任何原因沒有響應,客戶端會不斷地向伺服器重新發出請求,直到問題最終得到解決。客戶機上的低階檔案系統程式碼不向呼叫應用程式報告任何異常。正在進行的操作(以及應用程式)只是掛起,直到操作成功完成。如果掛起持續的時間較長,那麼NFS客戶機將在使用者的控制檯列印“NFS伺服器xyzzy沒有響應,仍然在嘗試”的訊息。

NFS使用者經常抱怨他們的應用程式在等待NFS伺服器恢復正常操作時掛起。許多人建議,NFS應該在異常情況下中止操作,而不是掛起。然而,報告異常只會使事情變得更糟,而不是更好。如果一個應用程式失去了對其檔案的訪問權,那麼它就無能為力了。一種可能性是應用程式重試檔案操作,但這仍將把應用程式,並且更容易執行重試在NFS層在一個地方,而不是在每個檔案系統呼叫在每個應用程式(編譯器不應該擔心這個)。另一種方法是應用程式中止並將錯誤返回給呼叫者。呼叫方也不太可能知道該做什麼,所以它們也會中止,從而導致使用者的工作環境崩潰。當檔案伺服器關閉時,使用者仍然無法完成任何工作,而且一旦檔案伺服器恢復正常,他們將不得不重新啟動所有應用程式。

因此,最佳的替代方案是NFS遮蔽錯誤並掛起應用程式。使用這種方法,應用程式不需要任何程式碼來處理伺服器問題,一旦伺服器恢復正常,它們就可以無縫地恢復。如果使用者厭倦了等待,他們總是可以手動中止應用程式。

異常遮蔽並非在所有情況下都有效,但在它有效的情況下,它是一個強大的工具。它會產生更深層的類,因為它減少了類的介面(減少了使用者需要注意的異常),並以程式碼的形式增加了掩蓋異常的功能。異常遮蔽是降低複雜性的一個例子。

10.7 異常聚合

第三種減少異常複雜性的技術是異常聚合。異常聚合背後的思想是用一段程式碼處理許多異常;與其為許多單獨的異常編寫不同的處理程式,不如使用單個處理程式在一個地方處理它們。

考慮如何處理Web伺服器中丟失的引數。Web伺服器實現一個url集合。當伺服器接收到傳入的URL時,它將傳送到特定於URL的服務方法來處理該URL並生成響應。URL包含用於生成響應的各種引數。每個服務方法將呼叫一個較低級別的方法(讓我們將其稱為getParameter)來從URL中提取所需的引數。如果URL不包含所需的引數,則getParameter丟擲異常。

當軟體設計類的學生實現這樣一個伺服器時,他們中的許多人將每個不同的getParameter呼叫包裝在一個單獨的異常處理程式中,以捕獲NoSuchParameter異常,如圖10.1所示。這導致了大量的處理程式,所有的處理程式本質上都做相同的事情(生成錯誤響應)。

圖10.1:頂部的程式碼分派給Web伺服器中的幾個方法中的一個,每個方法處理一個特定的URL。每個方法(底部)都使用來自傳入HTTP請求的引數。在這個圖中,每個對getParameter的呼叫都有一個單獨的異常處理程式;這會導致重複的程式碼。

更好的方法是聚合異常。不捕獲各個服務方法中的異常,而是讓它們向上傳播到Web伺服器的頂級分派方法,如圖10.2所示。此方法中的單個處理程式可以捕獲所有異常併為丟失的引數生成適當的錯誤響應。

聚合方法可以在Web示例中更進一步。除了在處理Web頁面時可能出現的引數丟失之外,還有許多其他錯誤;例如,引數可能沒有正確的語法(服務方法期望的是一個整數,但是值是“xyz”),或者使用者可能沒有請求操作的許可權。在每種情況下,錯誤應該導致錯誤響應;錯誤只在響應中包含的錯誤訊息中有所不同(“URL中不存在引數‘quantity’”或“quantity”引數的“bad value’xyz”;必須是正整數”)。因此,導致錯誤響應的所有條件都可以使用一個頂級異常處理程式來處理。可以在丟擲異常時生成錯誤訊息,並將其作為變數包含在異常記錄中;例如,getParameter將生成“URL中不存在引數‘quantity’”訊息。頂級處理程式從異常中提取訊息並將其合併到錯誤響應中。

圖10.2:這段程式碼在功能上與圖10.1相同,但是異常處理已經聚合:dispatcher中的一個異常處理程式從所有url特定的方法捕獲所有NoSuchParameter異常。

從封裝和資訊隱藏的角度來看,上述聚合具有良好的特性。頂級異常處理程式封裝了關於如何生成錯誤響應的知識,但它對特定的錯誤一無所知;它只使用異常中提供的錯誤訊息。getParameter方法封裝了有關如何從URL提取引數的知識,並且還知道如何以人類可讀的形式描述提取錯誤。這兩條資訊是密切相關的,所以把它們放在一起是有道理的。但是,getParameter對HTTP錯誤響應的語法一無所知。隨著新功能被新增到Web伺服器,像getParameter這樣的新方法可能會建立它們自己的錯誤。如果新方法以與getParameter相同的方式丟擲異常(通過生成從相同超類繼承的異常,並在每個異常中包含一條錯誤訊息),它們可以插入到現有的系統中,而不需要進行其他更改:頂級處理程式將自動為它們生成錯誤響應。

此示例演示了用於異常處理的通用設計模式。如果系統處理了一系列請求,那麼定義一個異常來中止當前請求、清理系統狀態並繼續下一個請求是很有用的。異常捕獲在系統請求處理迴圈頂部附近的單個位置。此異常可在處理請求的任何時刻丟擲,以中止請求;可以為不同的條件定義異常的不同子類。這種型別的異常應該與對整個系統致命的異常明確區分開來。

如果異常在處理之前在堆疊上向上傳播了幾個級別,則異常聚合工作得最好;這允許在同一個地方處理來自更多方法的更多異常。這與異常掩蔽相反:掩蔽通常在用低階方法處理異常時工作得最好。對於掩蔽,低階方法通常是許多其他方法使用的庫方法,因此允許異常傳播將增加處理它的位置的數量。遮蔽和聚合的相似之處在於,這兩種方法都將異常處理程式放置在能夠捕獲最多異常的位置,從而消除了許多需要建立的處理程式。

另一個異常聚合的例子發生在用於崩潰恢復的RAMCloud儲存系統中。RAMCloud系統由一組儲存伺服器組成,這些伺服器儲存每個物件的多個副本,因此係統可以從各種故障中恢復。例如,如果伺服器崩潰並丟失了所有資料,RAMCloud將使用儲存在其他伺服器上的副本來重新構建丟失的資料。錯誤也可能在較小的範圍內發生;例如,伺服器可能發現某個物件已損壞。

對於每種不同型別的錯誤,RAMCloud沒有單獨的恢復機制。相反,RAMCloud將許多較小的錯誤“提升”為較大的錯誤。原則上,RAMCloud可以通過從備份副本中恢復一個損壞的物件來處理這個損壞的物件。然而,它並不這樣做。相反,如果它發現一個損壞的物件,它會使包含該物件的伺服器崩潰。RAMCloud使用這種方法是因為崩潰恢復非常複雜,而且這種方法最小化了必須建立的不同恢復機制的數量。為崩潰的伺服器建立恢復機制是不可避免的,因此RAMCloud對其他型別的恢復也使用相同的機制。這減少了必須編寫的程式碼量,而且這還意味著伺服器崩潰恢復將更頻繁地被呼叫。因此,恢復中的bug更有可能被發現和修復。

將損壞的物件升級到伺服器崩潰的一個缺點是,它大大增加了恢復的成本。這在RAMCloud中不是問題,因為物件損壞非常罕見。然而,錯誤提升對於頻繁發生的錯誤可能沒有意義。舉個例子,當一個伺服器的網路資料包丟失時,它不可能崩潰。

考慮異常聚合的一種方法是,它用一種能夠處理多種情況的通用機制替代了幾個專門用於特定情況的機制。這又一次說明了通用機制的好處。

10.8 事故?

降低異常處理複雜性的第四種技術是使應用程式崩潰。 在大多數應用程式中都會有一些不值得處理的錯誤。通常,這些錯誤很難或不可能處理,而且不經常發生。為響應這些錯誤,最簡單的方法是列印診斷資訊,然後中止應用程式。

一個例子是在儲存分配期間發生的“記憶體不足”錯誤。考慮C中的malloc函式,如果它不能分配所需的記憶體塊,它將返回NULL。這是一種不幸的行為,因為它假設malloc的每個呼叫者都將檢查返回值,並在沒有記憶體時採取適當的操作。應用程式包含大量對malloc的呼叫,因此在每次呼叫後檢查結果會增加很大的複雜性。如果程式設計師忘記了檢查(這是很有可能的),那麼如果記憶體耗盡,應用程式將取消對空指標的引用,從而導致掩蓋真正問題的崩潰。

此外,當應用程式發現記憶體耗盡時,它也無能為力。原則上,應用程式可以尋找不需要的記憶體來釋放,但是如果應用程式有不需要的記憶體,它可能已經釋放了記憶體,這將在一開始就防止記憶體不足的錯誤。今天的系統有如此多的記憶體,以至於記憶體幾乎永遠不會用完;如果是,通常表示應用程式中有bug。因此,嘗試處理記憶體不足的錯誤很少有意義;這造成了太多的複雜性,而得到的好處卻太少。

更好的方法是定義一個新的方法ckalloc,它呼叫malloc,檢查結果,如果記憶體耗盡,則用錯誤訊息中止應用程式。應用程式從不直接呼叫malloc;它總是呼叫ckalloc。

在較新的語言(如c++和Java)中,如果記憶體耗盡,新的操作符會丟擲異常。捕獲這個異常沒有多大意義,因為異常處理程式很可能也會嘗試分配記憶體,這也會失敗。動態分配記憶體是任何現代應用程式的基本元素,如果記憶體耗盡,應用程式繼續執行是沒有意義的;一旦檢測到錯誤,最好立即崩潰。

還有許多其他的錯誤示例,崩潰應用程式是有意義的。對於大多數程式,如果在讀取或寫入開啟的檔案時發生I/O錯誤(例如磁碟硬錯誤),或者無法開啟網路套接字,應用程式無法進行太多的恢復,因此使用明確的錯誤訊息中止是一種明智的方法。這些錯誤並不常見,因此不太可能影響應用程式的整體可用性。如果應用程式遇到內部錯誤(如不一致的資料結構),也可以使用錯誤訊息中止。這樣的條件可能表明程式中存在bug。

崩潰是否可以接受取決於應用程式。對於複製的儲存系統,由於I/O錯誤而中止是不合適的。相反,系統必須使用複製的資料來恢復丟失的任何資訊。恢復機制將為程式增加相當大的複雜性,但是恢復丟失的資料是系統向用戶提供的價值的重要組成部分。

10.9 設計不存在的特殊情況

定義錯誤使其不存在是有意義的,同樣,定義其他特殊情況使其不存在也是有意義的。特殊情況會導致程式碼中充斥著if語句,這使得程式碼難以理解並導致bug。因此,應儘可能消除特殊情況。實現這一點的最佳方法是,以一種無需任何額外程式碼就能自動處理特殊情況的方式來設計正常情況。

在第6章描述的文字編輯器專案中,學生必須實現一種選擇文字和複製或刪除選擇的機制。大多數學生在他們的選擇實現中引入了一個狀態變數來表示選擇是否存在。他們之所以選擇這種方法,可能是因為有時在螢幕上看不到選擇,所以在實現中表示這種概念似乎是很自然的。然而,這種方法導致了大量的檢查來檢測“無選擇”條件,並對其進行特殊處理。

通過消除“沒有選擇”的特殊情況,可以簡化選擇處理程式碼,使選擇始終存在。當在螢幕上沒有可見的選擇時,可以用一個空的選擇在內部表示它,它的起始位置和結束位置是相同的。使用這種方法,可以編寫選擇管理程式碼,而不需要檢查“沒有選擇”。複製選擇時,如果選擇為空,則將在新位置插入0位元組(如果實現正確,則不需要作為特殊情況檢查0位元組)。類似地,應該可以設計用於刪除選擇的程式碼,以便在不進行任何特殊情況檢查的情況下處理空的情況。考慮在單行上進行選擇。要刪除所選內容,請提取所選內容之前的行部分,並將其與所選內容之後的行部分連線起來,以形成新行。如果選擇為空,則此方法將重新生成原始行。

這個例子也說明了第7章中“不同的層,不同的抽象”的思想。“無選擇”的概念對於使用者如何考慮應用程式的介面是有意義的,但這並不意味著它必須在應用程式內部顯式地表示。有一個總是存在的選擇,但有時是空的,因此是不可見的,結果是一個更簡單的實現。

10.10 做過了頭

定義異常或在模組內部遮蔽異常,只有在模組外部不需要異常資訊時才有意義。本章中的示例也是如此,比如cl unset命令和Java子字串方法;在呼叫者關心由異常檢測到的特殊情況的罕見情況下,可以通過其他方式獲取此資訊。

然而,這種想法可能會走得太遠。在一個用於網路通訊的模組中,一個學生團隊遮蔽了所有的網路異常:如果發生了網路錯誤,模組捕獲它,丟棄它,然後繼續處理,就好像沒有問題一樣。這意味著使用該模組的應用程式無法查明訊息是否丟失或對等伺服器是否故障;沒有這些資訊,就不可能構建健壯的應用程式。在這種情況下,即使異常增加了模組介面的複雜性,模組也必須公開異常。

與軟體設計中的許多其他領域一樣,對於例外,您必須確定什麼是重要的,什麼是不重要的。不重要的事情應該隱藏起來,越多越好。但當某件事很重要時,它必須被曝光。

10.11 結論

任何形式的特殊情況都會使程式碼更難理解,並增加bug的可能性。 本章重點討論異常,它是特殊情況程式碼最重要的來源之一,並討論瞭如何減少必須處理異常的地方。最好的方法是重新定義語義來消除錯誤條件。對於無法定義的異常,您應該尋找機會在較低的層次上遮蔽它們,這樣它們的影響就有限了,或者將幾個特殊情況處理程式聚合到一個更通用的處理程式中。總之,這些技術可以對整個系統的複雜性產生重大影