天天看點

Effective C++之 Item 29: 争取異常安全(exception-safe)的代碼

異常安全(Exception safety)有點像懷孕(pregnancy)……但是,請把這個想法先控制一會兒。我們還不能真正地議論生育(reproduction),直到我們排除萬難渡過求愛時期(courtship)。(此段作者使用的 3 個詞均有雙關含義,pregnancy 也可了解為富有意義,reproduction 也可了解為再現,再生,courtship 也可了解為争取,謀求。為了與後面的譯文對應,故按照現在的譯法。——譯者注)

假設我們有一個類,代表帶有背景圖像的 GUI 菜單。這個類被設計成在多線程環境中使用,是以它有一個用于并行控制(concurrency control)的互斥體(mutex):

class PrettyMenu {

public:

  ...

  void changeBackground(std::istream& imgSrc);           // change background

  ...                                                    // image

private:

  Mutex mutex;                    // mutex for this object

  Image *bgImage;                 // current background image

  int imageChanges;               // # of times image has been changed

};

考慮這個 PrettyMenu 的 changeBackground 函數的可能的實作:

void PrettyMenu::changeBackground(std::istream& imgSrc)

{

  lock(&mutex);                      // acquire mutex (as in Item 14)

  delete bgImage;                    // get rid of old background

  ++imageChanges;                    // update image change count

  bgImage = new Image(imgSrc);       // install new background

  unlock(&mutex);                    // release mutex

}

從異常安全的觀點看,這個函數爛到了極點。異常安全有兩條要求,而這裡全都沒有滿足。

當一個異常被抛出,異常安全的函數應該:

  • 沒有資源洩露。上面的代碼沒有通過這個測試,因為如果 "new Image(imgSrc)" 表達式産生一個異常,對 unlock 的調用就永遠不會執行,而那個互斥體也将被永遠挂起。
  • 不允許資料結構惡化。如果 "new Image(imgSrc)" 抛出異常,bgImage 被遺留下來指向一個被删除對象。另外,盡管并沒有将一張新的圖像設定到位,imageChanges 也已經被增加。(在另一方面,舊的圖像被明确地删除,是以我料想你會争辯說圖像已經被“改變”了。)

規避資源洩露問題比較容易,因為 Item 13 解釋了如何使用對象管理資源,而 Item 14 又引進了 Lock 類作為一種時尚的確定互斥體被釋放的方法:

void PrettyMenu::changeBackground(std::istream& imgSrc)

{

  Lock ml(&mutex);                 // from Item 14: acquire mutex and

                                   // ensure its later release

  delete bgImage;

  ++imageChanges;

  bgImage = new Image(imgSrc);

}

關于像 Lock 這樣的資源管理類的最好的事情之一是它們通常會使函數變短。看到對 unlock 的調用不再需要了嗎?作為一個一般的規則,更少的代碼就是更好的代碼。因為在改變的時候這樣可以較少誤入歧途并較少産生誤解。

随着資源洩露被我們甩在身後,我們可以把我們的注意力集中到資料結構惡化。在這裡我們有一個選擇,但是在我們能選擇之前,我們必須先面對定義我們的選擇的術語。

異常安全函數提供下述三種保證之一:

  • 函數提供基本保證(the basic guarantee),允諾如果一個異常被抛出,程式中剩下的每一件東西都處于合法狀态。沒有對象或資料結構被破壞,而且所有的對象都處于内部調和狀态(所有的類不變量都被滿足)。然而,程式的精确狀态可能是不可預期的。例如,我們可以重寫 changeBackground,以緻于如果一個異常被抛出,PrettyMenu 對象可以繼續保留原來的背景圖像,或者它可以持有某些預設的背景圖像,但是客戶無法預知到底是哪一個。(為了查明這一點,他們大概必須調用某個可以告訴他們目前背景圖像是什麼的成員函數。)
  • 函數提供強力保證(the strong guarantee),允諾如果一個異常被抛出,程式的狀态不會發生變化。調用這樣的函數在感覺上是極其微弱的,如果它們成功了,它們就完全成功,如果它們失敗了,程式的狀态就像它們從沒有被調用過一樣。

與提供強力保證的函數一起工作比與隻提供基本保證的函數一起工作更加容易,因為調用提供強力保證的函數之後,僅有兩種可能的程式狀态:像預期一樣成功執行了函數,或者繼續保持函數被調用時當時的狀态。與之相比,如果調用隻提供基本保證的函數引發了異常,程式可能存在于任何合法的狀态。

  • 函數提供不抛出保證(the nothrow guarantee),允諾決不抛出異常,因為它們隻做它們答應要做的。所有對内建類型(例如,ints,指針,等等)的操作都是不抛出(nothrow)的(也就是說,提供不抛出保證)。這是異常安全代碼中必不可少的基礎構件。

假定一個帶有空的異正常格(exception specification)的函數是不抛出的似乎是合理的,但這不一定正确的。例如,考慮這個函數:

int doSomething() throw();          // note empty exception spec.

這并不是說 doSomething 永遠不會抛出異常;而是說如果 doSomething 抛出一個異常,它就是一個嚴重的錯誤,應該調用 unexpected 函數 [1]。實際上,doSomething 可能根本不提供任何異常保證。一個函數的聲明(如果有的話,也包括它的異正常格(exception specification))不能告訴你一個函數是否正确,是否可移植,或是否高效,而且,即便有,它也不能告訴你它會提供哪一種異常安全保證。所有這些特性都由函數的實作決定,而不是它的聲明能決定的。

[1] 關于 unexpected 函數的資料,可以求助于你中意的搜尋引擎或包羅萬象的 C++ 課本。(你或許有幸搜到 set_unexpected,這個函數用于指定 unexpected 函數。)

異常安全函數必須提供上述三種保證中的一種。如果它沒有提供,它就不是異常安全的。于是,選擇就在于決定你寫的每一個函數究竟要提供哪種保證。除非要處理遺留下來的非異常安全的代碼(本 Item 稍後我們要讨論這個問題),隻有當你的最高明的需求分析團隊為你的應用程式識别出的一項需求就是洩漏資源以及運作于被破壞的資料結構之上時,不提供異常安全保證才能成為一個選項。

作為一個一般性的規則,你應該提供實際可達到的最強力的保證。從異常安全的觀點看,不抛出的函數(nothrow functions)是極好的,但是在 C++ 的 C 部分之外部不調用可能抛出異常的函數簡直就是寸步難行。使用動态配置設定記憶體的任何東西(例如,所有的 STL 容器)如果不能找到足夠的記憶體來滿足一個請求(參見 Item 49),在典型情況下,它就會抛出一個 bad_alloc 異常。隻要你能做到就提供不抛出保證,但是對于大多數函數,選擇是在基本的保證和強力的保證之間的。

在 changeBackground 的情況下,提供差不多的強力保證并不困難。首先,我們将 PrettyMenu 的 bgImage 資料成員的類型從一個内建的 Image* 指針改變為 Item 13 中描述的智能資源管理指針中的一種。坦白地講,在預防資源洩漏的基本原則上,這完全是一個好主意。它幫助我們提供強大的異常安全保證的事實進一步加強了 Item 13 的論點——使用對象(諸如智能指針)管理資源是良好設計的基礎。在下面的代碼中,我展示了 tr1::shared_ptr 的使用,因為當進行通常的拷貝時它的更符合直覺的行為使得它比 auto_ptr 更可取。

第二,我們重新排列 changeBackground 中的語句,以緻于直到圖像發生變化,才增加 imageChanges。這是一個很好的政策——直到某件事情真正發生了,再改變一個對象的狀态來表示某事已經發生。

這就是修改之後的代碼:

class PrettyMenu {

  ...

  std::tr1::shared_ptr<Image> bgImage;

  ...

};

void PrettyMenu::changeBackground(std::istream& imgSrc)

{

  Lock ml(&mutex);

  bgImage.reset(new Image(imgSrc));  // replace bgImage's internal

                                     // pointer with the result of the

                                     // "new Image" expression

  ++imageChanges;

}

注意這裡不再需要手動删除舊的圖像,因為在智能指針内部已經被處理了。此外,隻有當新的圖像被成功建立了删除行為才會發生。更準确地說,隻有當 tr1::shared_ptr::reset 函數的參數("new Image(imgSrc)" 的結果)被成功建立了,這個函數才會被調用。隻有在對 reset 的調用的内部才會使用 delete,是以如果這個函數從來不曾進入,delete 就從來不曾使用。同樣請注意一個管理資源(動态配置設定的 Image)的對象(tr1::shared_ptr)的使用再次縮短了 changeBackground 的長度。

正如我所說的,這兩處改動差不多有能力使 changeBackground 提供強力異常安全保證。美中不足的是什麼呢?參數 imgSrc。如果 Image 的構造函數抛出一個異常,輸入流(input stream)的讀标記(read marker)可能已經被移動,而這樣的移動就成為對程式的其它部分來說可見的一個狀态的變化。直到 changeBackground 着手解決這個問題之前,它隻能提供基本異常安全保證。

無論如何,讓我們把它放在一邊,并且依然假裝 changeBackground 可以提供強力保證。(我相信你至少能用一種方法做到這一點,或許可以通過将它的參數從一個 istream 改變到包含圖像資料的檔案的檔案名。)有一種通常的設計政策可以有代表性地産生強力保證,而且熟悉它是非常必要的。這個政策被稱為 "copy and swap"。它的原理很簡單。先做出一個你要改變的對象的拷貝,然後在這個拷貝上做出全部所需的改變。如果改變過程中的某些操作抛出了異常,最初的對象保持不變。在所有的改變完全成功之後,将被改變的對象和最初的對象在一個不會抛出異常的操作中進行交換。

這通常通過下面的方法實作:将每一個對象中的全部資料從“真正的”對象中放入到一個單獨的實作對象中,然後将一個指向實作對象的指針交給真正對象。這通常被稱為 "pimpl idiom",Item 31 描述了它的一些細節。對于 PrettyMenu 來說,它一般就像這樣:

struct PMImpl {                               // PMImpl = "PrettyMenu

  std::tr1::shared_ptr<Image> bgImage;        // Impl."; see below for

  int imageChanges;                           // why it's a struct

};

class PrettyMenu {

  ...

private:

  Mutex mutex;

  std::tr1::shared_ptr<PMImpl> pImpl;

};

void PrettyMenu::changeBackground(std::istream& imgSrc)

{

  using std::swap;                            // see Item 25

  Lock ml(&mutex);                            // acquire the mutex

  std::tr1::shared_ptr<PMImpl>                // copy obj. data

    pNew(new PMImpl(*pImpl));

  pNew->bgImage.reset(new Image(imgSrc));     // modify the copy

  ++pNew->imageChanges;

  swap(pImpl, pNew);                          // swap the new

                                              // data into place

}                                             // release the mutex

在這個例子中,我選擇将 PMImpl 做成一個結構體,而不是類,因為通過讓 pImpl 是 private 就可以確定 PrettyMenu 資料的封裝。将 PMImpl 做成一個類雖然有些不那麼友善,卻沒有增加什麼好處。(這也會使有面向對象潔癖者走投無路。)如果你願意,PMImpl 可以嵌套在 PrettyMenu 内部,像這樣的打包問題與我們這裡所關心的寫異常安全的代碼的問題沒有什麼關系。

copy-and-swap 政策是一種全面改變或絲毫不變一個對象的狀态的極好的方法,但是,在通常情況下,它不能保證全部函數都是強力異常安全的。為了弄清原因,考慮一個 changeBackground 的抽象化身—— someFunc,它使用了 copy-and-swap,但是它包含了對另外兩個函數(f1 和 f2)的調用:

void someFunc()

{

  ...                                     // make copy of local state

  f1();

  f2();

  ...                                     // swap modified state into place

}

很明顯,如果 f1 或 f2 低于強力異常安全,someFunc 就很難成為強力異常安全的。例如,假設 f1 僅提供基本保證。為了讓 someFunc 提供強力保證,它必須寫代碼在調用 f1 之前測定整個程式的狀态,并捕捉來自 f1 的所有異常,然後恢複到最初的狀态。

即使 f1 和 f2 都是強力異常安全的,事情也好不到哪去。如果 f1 運作完成,程式的狀态已經發生了毫無疑問的變化,是以如果随後 f2 抛出一個異常,即使 f2 沒有改變任何東西,程式的狀态也已經和調用 someFunc 時不同。

問題在于副作用。隻要函數僅對局部狀态起作用(例如,someFunc 僅僅影響調用它的那個對象的狀态),它提供強力保證就相對容易。當函數的副作用影響了非局部資料,它就會困難得多。例如,如果調用 f1 的副作用是改變資料庫,讓 someFunc 成為強力異常安全就非常困難。一般情況下,沒有辦法撤銷已經送出的資料庫變化,其他資料庫客戶可能已經看見了資料庫的新狀态。

類似這樣的問題會阻止你為函數提供強力保證,即使你希望去做。另一個問題是效率。copy-and-swap 的要點是這樣一個想法:改變一個對象的資料的拷貝,然後在一個不會抛出異常的操作中将被改變的資料和原始資料進行交換。這就需要做出每一個要改變的對象的拷貝,這可能會用到你不能或不情願動用的時間和空間。強力保證是非常值得的,當它可用時你應該提供它,除非在它不能 100% 可用的時候。

當它不可用時,你就必須提供基本保證。在實踐中,你可能會發現你能為某些函數提供強力保證,但是效率和複雜度的成本使得它難以支援大量的其它函數。無論何時,隻要你作出過一個提供強力保證的合理的成果,就沒有人會因為你僅僅提供了基本保證而站在批評你的立場上。對于很多函數來說,基本保證是一個完全合理的選擇。

如果你寫了一個根本沒有提供異常安全保證的函數,事情就不同了,因為在這一點上有罪推定是合情合理的,直到你證明自己是清白的。你應該寫出異常安全的代碼。除非你能做出有說服力的答辯。請再次考慮 someFunc 的實作,它調用了函數 f1 和 f2。假設 f2 根本沒有提供異常安全保證,甚至沒有基本保證。這就意味着如果 f2 發生一個異常,程式可能會在 f2 内部洩漏資源。這也意味着 f2 可能會惡化資料結構,例如,已排序數組可能不再排序,一個正在從一個資料結構傳送到另一個資料結構去的對象可能丢失,等等。沒有任何辦法可以讓 someFunc 能彌補這些問題。如果 someFunc 調用的函數不提供異常安全保證,someFunc 本身就不能提供任何保證。

請允許我回到懷孕。一個女性或者懷孕或者沒有。局部懷孕是絕不可能的。與此相似,一個軟體或者是異常安全的或者不是。沒有像一個局部異常安全的系統這樣的東西。一個系統即使隻有一個函數不是異常安全的,那麼系統作為一個整體就不是異常安全的,因為調用那個函數可能發生洩漏資源和惡化資料結構。不幸的是,很多 C++ 的遺留代碼在寫的時候沒有留意異常安全,是以現在的很多系統都不是異常安全的。它們混合了用非異常安全(exception-unsafe)的方式書寫的代碼。

沒有理由讓事情的這種狀态永遠持續下去。當書寫新的代碼或改變現存代碼時,要仔細考慮如何使它異常安全。以使用對象管理資源開始。(還是參見 Item 13。)這樣可以防止資源洩漏。接下來,決定三種異常安全保證中的哪一種是你實際上能夠為你寫的每一個函數提供的最強的保證,隻有當你不調用遺留代碼就别無選擇的時候,才能滿足于沒有保證。既是為你的函數的客戶也是為了将來的維護人員,文檔化你的決定。一個函數的異常安全保證是它的接口的可見部分,是以你應該特意選擇它,就像你特意選擇一個函數接口的其它方面。

四十年前,到處都是 goto 的代碼被尊為最佳實踐。現在我們為書寫結構化控制流程而奮鬥。二十年前,全局可通路資料被尊為最佳實踐。現在我們為封裝資料而奮鬥,十年以前,寫函數時不必考慮異常的影響被尊為最佳實踐。現在我們為寫異常安全的代碼而奮鬥。

時光在流逝。我們生活着。我們學習着。

Things to Remember

  • 即使當異常被抛出時,異常安全的函數不會洩露資源,也不允許資料結構被惡化。這樣的函數提供基本的,強力的,或者不抛出保證。
  • 強力保證經常可以通過 copy-and-swap 被實作,但是強力保證并非對所有函數都可用。
  • 一個函數通常能提供的保證不會強于他所調用的函數中最弱的保證。