Guru of the Week 條款08:GotW挑戰篇——異常處理的安全性
GotW #08 CHALLENGE EDITION Exception Safety
著者:Herb Sutter
翻譯:kingofark
[宣告]:本文內容取自www.gotw.ca網站上的Guru of the Week欄目,其著作權歸原著者本人所有。譯者kingofark在未經原著者本人同意的情況下翻譯本文。本翻譯內容僅供自學和參考用,請所有閱讀過本文的人不要擅自轉載、傳播本翻譯內容;下載本翻譯內容的人請在閱讀瀏覽後,立即刪除其備份。譯者kingofark對違反上述兩條原則的人不負任何責任。特此宣告。
Revision 1.0
Guru of the Week 條款08:GotW挑戰篇——異常處理的安全性
難度:9 / 10
(異常處理機制是解決某些問題的上佳辦法,但同時它也引入了許多隱藏的控制流程;有時候,要正確無誤的使用它並不容易。不妨試試自己實現一個簡單的container(這是一種可以對其進行push和pop操作的棧),看看它在異常-安全的(exception-safe)和異常-中立的(exception-neutral)情況下,到底會發生哪些事情。)
[問題]
1.實現如下異常-中立的(exception-neutral
template <class T>
// T 必須有預設的建構函式和拷貝建構函式
class Stack
{
public:
Stack();
~Stack();
Stack(const Stack&);
Stack& operator=(const Stack&);
unsigned Count();// 返回T在棧裡面的數目
voidPush(const T&);
TPop();// 如果為空,則返回預設構造出來的T
private:
T*v_;// 指向一個用於'vsize_' T物件的
//足夠大的記憶體空間
unsigned vsize_;// 'v_' 區域的大小
unsigned vused_;// 'v_' 區域中實際使用的T的數目
};
附加題:
2.根據當前的C++標準,標準庫中的container是異常-安全的(exception-safe)還是異常-中立的(exception-neutral)?
3.應該讓container成為異常-中立的(exception-neutral)嗎?為什麼?有什麼折衷方案嗎?
4.Container應該使用異常規則嗎?比如,我們到底應不應該作諸如“Stack::Stack()throw(bad_alloc);”的宣告?
挑戰極限的問題:
5.由於在目前許多的編譯器中使用try和catch會給你的程式帶來一些額外的負荷,所以在我們這種低階的可複用(reusable)Container中,最好避免使用它們。你能在不使用try和catch的情況下,按照要求實現Stack所有的成員函式嗎?
在這裡提供兩個例子以供參考(注意這兩個例子並不一定符合上述題目中的要求,僅供參考,以便於你下手解題):
template<class T>
Stack<T>::Stack()
: v_(0),
vsize_(10),
vused_(0)
{
v_ = new T[vsize_]; // 初始的記憶體分配(建立物件)
}
template<class T>
T Stack<T>::Pop()
{
T result; //如果為空,則返回預設構造出來的T
if( vused_ > 0)
{
result = v_[--vused_];
}
return result;
}
[解答]
[作者記:這裡的解決方案並不完全正確。本文經修正的增強版本,你可以在C++Report 1997年的9月號、11月號和12月號上面找到;另外,其最終版本在我的《Exceptional C++》裡面。]
重要的事項:我確實不敢保證下面的解決方案完全滿足了我原題的要求。實際上我連能夠正確編譯這些程式碼的編譯器都找不到!在這裡,我討論了所有我能想得到的那些互動作用;而本文的主要目的則是希望說明,在編寫異常-安全的(exception-safe)程式碼時需要格外的小心。
另外,Tom Cargill也有一篇非常棒的文章《Exception Handling:A False Sense of Security》(C++Report, vol.9 no.6, Nov-Dec 1994)。他通過這篇文章來說明,異常處理是個棘手的小花招,技巧性非常強,但也並不就是籠統的說不要使用異常處理,而是說人們不要過分的迷信異常處理。只要認識到這一點,並在使用時小心一點就可以了。
[作者再記:最後再說一點。為了簡化解決方案,我決定不去討論用來解決異常-安全(exception-safe)資源之歸屬問題的基類技術(base class technique)。我會邀請Dave Abrahams(或者其他人)來繼續討論,闡述這個非常有效的技術。]
現在先回顧一下我們的問題。需要的介面如下:
template <class T>
// T 必須有預設的建構函式和拷貝建構函式
class Stack
{
public:
Stack();
~Stack();
Stack(const Stack&);
Stack& operator=(const Stack&);
unsigned Count();//返回T在棧裡面的數目
voidPush(const T&);
TPop();//如果為空,則返回預設構造出來的T
private:
T*v_;//指向一個用於'vsize_' T物件的
//足夠大的記憶體空間
unsigned vsize_;// 'v_'區域的大小
unsigned vused_;// 'v_' 區域中實際使用的T的數目
};
現在我們來看看實現。我們對T有一個要求,就是T的解構函式(destructor)不能丟擲異常。這是因為,如果允許T的解構函式(destructor)丟擲異常,那我們就很難甚至是不可能在保證程式碼安全性的前提下進行實現了。
//----- DEFAULT CTOR ----------------------------------------------
template<class T>
Stack<T>::Stack()
: v_(new T[10]),// 預設的記憶體分配(建立物件)
vsize_(10),
vused_(0)// 現在還沒有被使用
{
// 如果程式到達這裡,說明構造過程沒有問題,okay!
}
//----- 拷貝建構函式 -------------------------------------------------
template<class T>
Stack<T>::Stack( const Stack<T>& other )
: v_(0),// 沒分配記憶體,也沒有被使用
vsize_(other.vsize_),
vused_(other.vused_)
{
v_ = NewCopy( other.v_, other.vsize_, other.vsize_ );
//如果程式到達這裡,說明拷貝構造過程沒有問題,okay!
}
//----- 拷貝賦值 -------------------------------------------
template<class T>
Stack<T>& Stack<T>::operator=( const Stack<T>& other )
{
if( this != &other )
{
T* v_new = NewCopy( other.v_, other.vsize_, other.vsize_ );
//如果程式到達這裡,說明記憶體分配和拷貝過程都沒有問題,okay!
delete[] v_;
// 這裡不能丟擲異常,因為T的解構函式不能丟擲異常;
// ::operator delete[] 被宣告成throw()
v_ = v_new;
vsize_ = other.vsize_;
vused_ = other.vused_;
}
return *this;// 很安全,沒有拷貝問題
}
//----- 解構函式 ----------------------------------------------------
template<class T>
Stack<T>::~Stack()
{
delete[] v_;// 同上,這裡也不能丟擲異常
}
//----- 計數 -----------------------------------------------------
template<class T>
unsigned Stack<T>::Count()
{
return vused_;// 這只是一個內建型別,不會有問題
}
//----- push操作 -----------------------------------------------------
template<class T>
void Stack<T>::Push( const T& t )
{
if( vused_ == vsize_ )// 可以隨著需要而增長
{
unsigned vsize_new = (vsize_+1)*2; // 增長因子
T* v_new = NewCopy( v_, vsize_, vsize_new );
//如果程式到達這裡,說明記憶體分配和拷貝過程都沒有問題,okay!
delete[] v_;//同上,這裡也不能丟擲異常
v_ = v_new;
vsize_ = vsize_new;
}
v_[vused_] = t; // 如果這裡丟擲異常,增加操作則不會執行,
++vused_;//狀態也不會改變
}
//----- pop操作 ------------------------------------------------------
template<class T>
T Stack<T>::Pop()
{
T result;
if( vused_ > 0)
{
result = v_[vused_-1];//如果這裡丟擲異常,相減操作則不會執行,
--vused_;//狀態也不會改變
}
return result;
}
//
// 注意: 細心的讀者Wil Evers第一個指出,
//“正如在問題中定義的那樣, Pop()強迫使用者編寫非異常-安全的程式碼,
//這首先就產生了一個負面效應(即從棧中間pop出一個元素);
//其次,這還可能導致遺漏某些異常(比如將返回值拷貝到程式碼呼叫者的目標
//物件上)。”
//
// 同時這也表明,很難編寫異常-安全的程式碼的一個原因就是因為
// 它不僅影響程式碼的實現部分,而且還會影響其介面!
// 某些介面(比如這裡的這一個)不可能在完全保證異常-安全的情況下被實現。
//
// 解決這個問題的一個可行方法是把函式重新構造成
// "void Stack<T>::Pop( T& result)".
// 這樣,我們就可以在棧的狀態改變之前得知到結果的拷貝是否真的成功了。
// 舉個例子如下,
// 這是一個更具有異常-安全性的Pop()
//
template<class T>
void Stack<T>::Pop( T& result )
{
if( vused_ > 0)
{
result = v_[vused_-1];//如果這裡丟擲異常,
--vused_;//相減操作則不會執行,
}//狀態也不會改變
}
//
// 這裡我們還可以讓Pop()返回void,然後再提供一個Front() 成員函式,
// 用來訪問頂端的物件
//
//----- 輔助函式 -------------------------------------------
// 當我們要把T從緩衝區拷貝到一個更大的緩衝區時,
//這個輔助函式會幫助分配新的緩衝區,並把元素原樣拷貝過來。
//如果在這裡發生了異常,輔助函式會釋放佔用得所有臨時資源,
//並把這個異常傳遞出去,保證不發生記憶體洩漏。
//
template<class T>
T* NewCopy( const T* src, unsigned srcsize, unsigned destsize )
{
destsize = max( srcsize, destsize ); // 基本的引數檢查
T* dest = new T[destsize];
// 如果程式到達這裡,說明記憶體分配和建構函式都沒有問題,okay!
try
{
copy( src, src+srcsize, dest );
}
catch(...)
{
delete[] dest;
throw;// 重新丟擲原來的異常
}
// 如果程式達到這裡,說明拷貝操作也沒有問題,okay!
return dest;
}
對附加題的解答:
第2題:根據當前的C++標準,標準庫中的container是異常-安全的(exception-safe)還是異常-中立的(exception-neutral)?
關於這個問題,目前還沒有明確的說法。最近委員會也展開了一些相關的討論,涉及到應該提供並保證弱異常安全性(即“container總是可以進行析構操作”)還是應該提供並保證強異常安全性(即“所有的container操作都要從語義上具有‘要麼執行要麼撤銷(commit-or-rollback)’的特性”)。正如Dave Abrahams在委員會中的一次討論以及隨後通過電子郵件進行的討論中所表明的那樣,如果實現了對弱異常安全性的保證,那麼強異常安全性也就很容易得到保證了。我們在上面提到的幾個操作正是這樣的。
第3題:應該讓container成為異常-中立的(exception-neutral)嗎?為什麼?有什麼折衷方案嗎?
有時候,為了保證某些container異常-中立性(exception-neutrality),其內的某些操作將會不可避免的付出一些空間代價。可見異常-中立性(exception-neutrality)本身並不錯,但是當實現強異常安全性所要付出的空間或時間代價遠遠大於實現弱異常安全性的付出的時候,要實現異常-中立性(exception-neutrality)就太不現實了。有一個比較好的折衷方案,那就是用文件記錄下T中不允許丟擲異常的操作,然後通過遵守這些文件規則來保證其異常-中立性(exception-neutrality)。
第4題:Container應該使用異常規則嗎?比如,我們到底應不應該作諸如“Stack::Stack()throw(bad_alloc);”的宣告?
答案是否定的。我們不能這樣做,因為我們預先並不知道T中哪些操作會丟擲異常,也不知道會丟擲什麼樣的異常。
應該注意的是,有些container的某些操作(例如,Count())只是簡單的返回一個數值,所以我們可以斷定它不會丟擲異常。雖然我們原則上可以用throw()來宣告這類操作,但是最好不要這麼做;原因有兩個:第一,如果你這樣做了,那麼當你以後想修改實現細節使其可以丟擲異常的時候,就會發現其存在著很大的限制;第二,無論異常是否被丟擲,異常宣告(exception specification)都會帶來額外的效能開銷。因此,對於那些頻繁使用的操作,最好不要作異常宣告(exception specification)以避免這種效能開銷。
對挑戰極限題的解答:
第5題:由於在目前許多的編譯器中使用try和catch會給你的程式帶來一些額外的負荷,所以在我們這種低階的可複用(reusable)Container中,最好避免使用它們。你能在不使用try和catch的情況下,按照要求實現Stack所有的成員函式嗎?
是的,這是可行的,因為我們僅僅只需要捕獲“...”部分(見下面的程式碼)。一般,形如
try { TryCode(); } catch(...) { CatchCode(parms); throw; }
的程式碼都可以改寫成這樣:
struct Janitor {
Janitor(Parms p) : pa(p) {}
~Janitor() { if uncaught_exception() CatchCode(pa); }
Parms pa;
};
{
Janitor j(parms); // j is destroyed both if TryCode()
// succeeds and if it throws
TryCode();
}
我們只在NewCopy函式中使用了try和catch。下面就是重寫的NewCopy函式,用以體現上面說的改寫技術:
template<class T>
T* NewCopy( const T* src, unsigned srcsize, unsigned destsize )
{
destsize = max( srcsize, destsize ); // basic parm check
struct Janitor {
Janitor( T* p ) : pa(p) {}
~Janitor() { if( uncaught_exception() ) delete[] pa; }
T* pa;
};
T* dest = new T[destsize];
// if we got here, the allocation/ctors were okay
Janitor j(dest);
copy( src, src+srcsize, dest );
// if we got here, the copy was okay... otherwise, j
// was destroyed during stack unwinding and will handle
// the cleanup of dest to avoid leaking memory
return dest;
}
我已經說過,我曾與幾個擅長靠經驗來進行速度測試的人討論過上述問題。結論是在沒有異常發生的情況下,try和catch往往要比其它方法快得多,而且今後還可能變得更快。但儘管如此,這種避免使用try和catch的技術還是非常重要的,一來是因為有時候就是需要寫一些比較規整、比較容易維護的程式碼;二來是因為現有的一些編譯器在處理try和catch的時候,無論在產生異常的情況下還是在不產生異常的情況下,都會生成效率極其低下的程式碼。