C與C++中的異常處理16 (轉)

gugu99發表於2008-08-06
C與C++中的異常處理16 (轉)[@more@]

:namespace prefix = o ns = "urn:schemas--com::office" />

  根據讀者們的建議,經過反思,我部分修正在Part14中申明的原則:

l  只要可能,使用那些構造不拋異常的基類和成員子。

l  不要從你的建構函式中丟擲任何異常。

  這次,我將思考讀者的意見,C++先知們的智慧,以及我自己的新的認識和提高。然後將它們轉化為指導方針來闡明和引申那些最初的原則。

  (關鍵字說明:我用“子物件”或“被包容物件”來表示陣列中元素、無名的基類、有名的資料成員;用“包容物件”來表示陣列、派生類物件或有資料成員的物件。)

 

1.1  C++的精髓

  你可能認為建構函式在遇到錯誤時有職責拋異常以正確地阻止包容物件的構造行為。Herb Sutter在一份私人信件中寫道:

一個物件的生命期始於構造完成。

推論:一個物件當它的構造沒有完成時,它從來就沒存在過。

推論:通報構造失敗的唯一方法是用異常來退出建構函式。

  我估計你正在做這種概念上就錯誤的事(“錯”是因為它不符合C++的精髓),而這也正是做起來困難的原因。

  “C++的精髓”是主要靠口頭傳授的C++神話。它是我們最初的法則,從ISO標準和實際中得出的公理。如果沒有存在過這樣的C++精髓的聖經,混亂將統治世界。Given that no actual canon for the Spirit exists, confusion reigns over what is and is not within the Spirit, even among presumed experts.

  C和C++的精髓之一是“trust the programmer”。如同我寫給Herb的:

最終,我的“完美”觀點是:在錯誤的傳播過程中將異常對映為其它形式應該是設計人員選定的。這麼做不總是最佳的,但應該這麼做。C++最強同時也是最弱的地方是你可以偏離你實際上需要的首選方法。還有一些其它被語言許可的危險影行為,取決於你是否知道你正在做什麼。In the end, my "perfect" ive was to map exceptions to some other foof error propagation should a designer choose to do so. Not that it was always best to do so, but that it could be done. One of the simultaneous strengths/weaknesses of C++ is that you can deviate from the preferred path if you really need to. There are other dangerous behaviors the language tolerates, under the assumption you know what you are doing.

  C++標準經常容忍甚至許可潛在的不行為,但不是在這個問題上。顯然,認同員的判斷力應該服從於一個更高層次的目的(Apparently, the desire to allow programmer discretion yields to a higher purpose)。Herb在C++精髓的第二個表現形式上發現了這個更高層次的目的:一個物件不是一個真正的物件(因此也是不可用的),除非它被完全構造(意味著它的所有要素也都被完全構造了)。

  看一下這個例子:

struct X

  {

  A a;

  B b;

  C c;

  void f();

  };

 

try

  {

  X x;

  x.f();

  }

catch (...)

  {

  }

 

  這裡,A、B和C是其它的類。假設x.a和x.b的構造完成了,而x.c的構造過程中拋了異常。如我們在前面幾部分中看到的,語言規則規定這樣的序列:

l  x的建構函式拋了異常

l  x.b的解構函式被

l  x.a的解構函式被呼叫

l  控制權交給異常處理函式

  這個規則符合C++的精髓。因為x.c沒有完成構造,它從未成為一個物件。於是,x也從未成為一個物件,因為它的一個內部成員(x.c)從沒存在過。因為沒有一個物件真的存在過,所以也沒有哪個需要正式地析構。

  現在假設x的建構函式不知怎麼控制住了最初的異常。在這種情況下,執行序列將是:

l  x.f()被呼叫

l  x.c的解構函式被呼叫

l  x.b的解構函式被呼叫

l  x.a的解構函式被呼叫

l  x的解構函式被呼叫

l  控制權跳過異常處理函式向下走

於是異常將會允許析構那些從沒被完全構造的物件(x.c和x)。這將造成自相矛盾:一個死亡的物件是從來都沒有產生過的。透過強迫建構函式拋異常,語言構造避免了這種矛盾。

 

1.2  C++的幽靈

  前面表明一個物件當且僅當它的成員被完全構造時才真的存在。但真的一個物件存在等價於被完全構造?尤其x.c的構造失敗“總是”如此惡劣到x必須在真的在被產生前就死亡?

  在C++語言有異常前,x的定義過程必定成功,並且x.f()的呼叫將被執行。代替拋異常的方法,我們將呼叫一個狀態檢測函式:

X x;

if (x.is_OK())

  x.f();

或使用一個回傳狀態引數:

bool is_OK;

X x(is_OK);

if (is_OK)

  x.f();

  在那個時候,我們不知何故在如x.c這樣的子物件的構造失敗時沒有強調:這樣的物件從沒真的存在過。那時的設計真的這麼根本錯誤(而我們現在絕不允許的這樣行為了)? C++的精髓真的在那時是不同的?或者我們生活在夢中,沒有想到過x真的沒有成形、沒有存在過?

  公正地說,這個問題有點過份,因為C++語言現在和過去相比已不是同樣的語言。將老的(異常支援以前)的C++當作現在的C++如同將C當作C++。雖然它們有相同的語法,但語意卻是不相同的。看一下:

struct X

  {

  X()

   {

  p = new T; // assume 'new' fails

  }

  void f();

  };

 

X x;

x.f();

 

  假設new語句沒有成功分配一個T物件。異常支援之前的(或禁止異常的現代編譯器)下,new返回NULL,x的建構函式和x.f()被呼叫。但在異常允許後,new拋異常,x構造失敗,x.f()沒有被呼叫。同樣的程式碼,非常不同的含意。

  在過去,物件沒有自毀的能力,它們必須構造,並且依賴我們來發現它的狀態。它們不處理構造失敗的子物件。並且,它們不呼叫標準執行庫中拋異常的庫函式。簡而言之,過去的程式和現在的程式存在於不同的世界中。我們不能期望它們對同樣的錯誤總有同樣的反應。

 

1.3  這是你的最終答案嗎?

  我現在相信C++標準的行為是正確的:建構函式拋異常將析構正在處理的物件及其包容物件。我不知道C++標準委員會制訂這個行為的精確原因,但我猜想是:

l  部分構造的物件將導致一些微妙的錯誤,因為它的使用者對其的構造程度的假設超過了實際。同樣的類的不同物件將會有出乎意料的和不可預測的不同行為。

l  編譯器需要額外的紀錄。當一個部分構造的物件消失時,編譯器要避免對它及它的部分構造的子物件呼叫解構函式。

l  物件被構造和物件存在的等價關係將被打破,破壞了C++的精髓。

 

1.4  對物件的使用者的指導

  異常是物件的介面的一部分。如果能夠,事先準備好介面可能拋的異常集。如果一個介面沒有提供異常規格申明,而且又不能從其它地方得知其異常行為,那麼假設它可能在任何時候拋任意的異常。

  換句話說,準備好捕獲或至少要過濾所有可能的異常。不要讓任何異常在沒有被預料到的情況下進入或離開你的程式碼;即使你只是簡單地傳遞或重新丟擲異常,也必須是經過認真選擇的。

 

1.5  建構函式拋異常

  準備好所有子物件的建構函式可能拋的異常的異常集,並在你的建構函式中捕獲它們。如:

struct A

  {

  A() throw(char, int);

  };

 

struct B

  {

  B() throw(int);

  };

 

struct C

  {

  C() throw(long);

  };

 

struct X

  {

  A a;

  B b;

  C c;

  X();

  };

 

  子物件建構函式的異常集是{char,int,long}。它就是X的建構函式遭遇的可能異常。如果X的建構函式未經過濾就傳遞這些異常,它的異常規格申明將是

X() throw(char, int, long);

但使用function try塊,建構函式可以將這些異常對映為其它型別:

X() throw(unsigned)

  try

  {

  // ... X::X body

  }

  catch (...)

  {

  // map caught sub-object exceptions to another type

  throw 1U; // type unsigned

  }

  如同前面的部分所寫,的建構函式不能阻止子物件的異常傳播出去,但能控制傳遞出去的型別,透過將進入的異常對映為受控的傳出型別(這兒是unsigned)。

 

1.6  建構函式不拋異常

  如果沒有子物件的建構函式拋異常,其異常集是空,表明包容物件的建構函式不會遇到異常。唯一能確定你的建構函式不拋異常的辦法是隻包容不拋異常的子物件。

  如果必須包容一個可能拋異常的子物件,但仍然不想從你自己的建構函式中丟擲異常,考慮使用被叫做Handle Class或Pimpl的方法(“Pimpl”個雙關語:pImpl或“pointer to implementation”)。長久以來被用作減短編譯時間的技巧,它也提高異常安全性。

  回到前面的例子:

  class X

  {

public:

  X();

  // ...other X members

private:

  A a;

  B b;

  C c;

  };

  根據這種方法,必須將X分割為兩個獨立的部分。第一部分是被X的使用者引用的“公有”頭:

struct X_implementation;

 

class X

  {

public:

  X() throw();

  // ...other X members

private:

  struct X_implementation *implementation;

  };

 

  而第二部分是私有實現

struct X_implementation

  {

  A a;

  B b;

  C c;

  };

 

X::X() throw()

  {

  try

  {

  implementation = new X_implementation;

  }

  catch (...)

  {

  // ... Exception handled, but not implicitly rethrown.

  }

  }

 

// ...other X members

 

  X的建構函式捕獲了構造*implementation過程(也就是構造a、b和c的過程)中的所有異常。更進一層,如果資料成員變了,X的使用者不需要重新編譯,因為X的標頭檔案沒有變化。

  (反面問題:如果X::X捕獲了一個異常,*implementation及至少子物件a/b/c中的一個沒有完全構造。但是,包容類X的物件作為一個有效實體延續了生命期。這個X的部分構造的物件的存在違背C++精髓嗎?)

  許多C++的指導手冊討論這個方法,所以我不在這兒詳述了。一個極其詳細的討論出現在Herb Sutter的著作《Exceptional C++》的Items26-30上。

 

1.7  對物件提供者的指導

  不要將異常體系等同於一種錯誤處理體系,認為它和返回錯誤碼或設定全域性變數處在同一層次上。異常根本性地改變了它周圍的程式碼的結構和意義。它們臨時地改變了程式的執行期語意,跳過了一些通常都執行的程式碼,並啟用其它從沒被執行的程式碼。它們強迫你的程式回應和處理可導致程式死亡的錯誤狀態。

  因此,異常的特性和簡單的錯誤處理大不相同。如果你不希望這些特性,或不理解這些特性,或不想將這些特性寫入文件,那麼不要拋異常,使用其它的錯誤處理體系。

  如果決定拋異常,必須明白全部的因果關係。明白你的決定對使用你的程式碼的人有巨大的潛在影響。你的異常是你的介面的一部分;你必須在文件中寫入你的介面將拋什麼異常,什麼時候拋,以及為什麼拋。並將這文件在異常規格申明出注釋出來。

 

1.8  建構函式拋異常

  如果你的建構函式拋異常,或你(直接地或間接地)包容的某個子物件拋異常,包容你的物件的使用者物件也將拋異常並因此構造失敗。這就是重用你的程式碼的使用者的代價。要確保這個代價值得。

  你沒有被強迫要在建構函式里拋異常,老的方法仍然有效的。當你的建構函式遇到錯誤時,你必須判斷這些錯誤是致命的還是稍有影響。丟擲一個構造異常傳遞了一個強烈的資訊:這個物件被破壞且無法修補。返回一個構造狀態碼錶明一個不同資訊:這個物件被破壞但還具有功能。

  不拋異常只是因為它是一個時髦的方法:在一個物件真的不能或不該生存時,推遲其自毀。

 

1.9  過職

  別讓你的介面過職。如果知道你的介面的精確異常集,將它在異常規格申明中列舉出來。否則,不提供異常規格申明。沒有異常規格申明比撒謊的異常規格申明好,因為它不會欺騙使用者。

  這條規則的可能例外是:模板異常。如前三部分所寫,模板的編寫者通常不知道可能丟擲的異常。如果你的模板不提供異常規格申明,使用者將降低安全感和信心。如果你的模板有異常規格申明你必須:

l  要麼使用前面看過的異常安全的技巧來確保異常規格申明是精確的

l  要麼在文件中寫下你的模板只接受有確定特性的引數型別,並警告其它型別將導致失控(with the caveat that other types may induce interface-contract violations beyond your control)。

 

1.10  必要vs充分

  不要人為增加你的類的複雜度,只是為了適應所有可能的需求。不是所有物件都會被重用的。如pet Becker寫給我的:

  現在的程式設計師花了太多的時間來應付可能發生的事情,而他們本應該簡單地拒絕的。如果有一個拋異常的好理由的話,大膽地拋異常,並寫入文件,不要創造一些精巧的方法來避免拋這些異常。增加的複雜度可能導致維護上的惡夢,超過了錯誤使用受限版本時遇到的痛苦。

  Pete的說法對解構函式也同樣有用。看一下這條原則(從Part14引用過來的):

  不要在解構函式中拋異常。

  一般來說,符合這條原則比違背它好。但,有時不是這樣的:

l  如果你準備讓其他人包容你的物件,或至少不禁止別人包容你的物件,那麼別在解構函式中拋異常。

l  如果你真的有理由拋異常,並且知道它違背了安全策略,那麼大膽地拋異常,在文件中寫入原因。

  就如同在設計的時候必須考慮異常處理,也必須考慮重用。在解構函式上申明throw()是成為一個好的子物件的必要條件,但遠不充分。你必須前瞻性地考慮你的程式碼將遇到什麼上下文,它將容忍什麼、將反抗什麼。如果增加了設計的複雜度,確保這些複雜度是策略的一部分,而不是脆弱的“以防萬一”的保險單。

1.11  感謝

  (略)

  除了一些零星的東西,我已經完成了異常安全的主題!實際上我也幾乎完成了異常的專題。下次時間暫停,在三月中將討論很久前承諾的C++異常和Visual C++ SEH的混合使用。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10748419/viewspace-1008590/,如需轉載,請註明出處,否則將追究法律責任。

相關文章