大雨。
烏云象鉛塊一樣低低的壓了下來,豆大的雨滴打的玻璃窗啪啪作響,難得一見的異常天氣正在竭力表現它令人討厭的一面。不過這一切似
乎并沒有影響到
Solmyr,他仍然以他習慣的舒適姿勢半躺在寬大的椅子里,手里還托著一杯熱騰騰的果汁,在他背后,zero
在鍵盤上敲打著什么。
“唉,Solmyr
,標準庫中的
stack
怎么會是這個樣子?設計糟透了。”zero
停止了工作,轉過身來面對
Solmyr
,看起來有些困惑。
“胡亂批評被納入神圣標準的成員是會遭天遣的。”Solmyr
低著頭,以一種算命先生似的語調答道。
不知道上天是否打算加強
Solmyr
的說服力,恰在此時天空劃過一道閃電,藍白色的電光掙扎著努力向地面撲來,緊接著就是“喀喇”一聲巨響
——— 這個雷很近。
一秒鐘前還在想“這未免也太扯了”的
zero
表情一下子變得很古怪,良久才恢復正常。他標出了兩行代碼接著說到:“好、好吧,Solmyr,那請你解釋一下為什么
stack
的界面是這個樣子。”
std::stack<int>
si;
……
int i = si.top();
si.pop();
“只要讓
pop()
返回棧頂元素就可以把上面兩行合成一行,而且更加直觀,為什么要搞成現在這樣?”
目睹了
zero
表情變化的
Solmyr
強忍住放聲大笑的沖動
——— 老天知道他忍的有多辛苦 ———
緩緩的把杯子放到桌上,轉過身來開始講解這個問題:
“原因在于異常。”
“異常?”
“對,
很多代碼在沒有異常的時候工作的挺好,但是一旦出現異常就變得不可收拾,就像一間茅草屋,平時看起來沒什么問題,一遇到今天這種天氣
…… ”,Solmyr
指了指窗外,“
…… 立刻就會垮掉。考慮一下如果 pop()
返回棧頂元素需要怎樣實現,假設棧內部用數組實現,且不考慮棧是否為空的問題。”
“很簡單啊。”,zero
打開了編輯器,寫下:
template
<typename T>
T stack<T>::pop()
{
...
...
return data[top--]; // 假設數據存儲于數組
data
中,top
代表棧頂位置
}
Solmyr 搖搖頭:“這就是茅草屋。要知道
stack
是個模板類,它存放的元素
T
可能是用戶定義的類。我來問你,如果類型
T
的拷貝構造函數拋出異常,會出現什么情況?”
“嗯
…… 按值返回,返回值是個臨時對象,該臨時對象以
data[top]
拷貝構造
…… 嗯,這樣一來函數返回時可能拋出異常,客戶此時無法取得該元素。”
“還有呢?”
“還有?”
“提示,你的
top
怎么了?”
“
…… 哎呀!糟了!top
此時已經減一,棧頂元素就此丟失了!這樣的話
…… 必須實現一個函數允許客戶修改 ……”,zero
說不下去了。他想了一會,搖搖頭承認失敗:“不行,這里拷貝構造發生在函數返回之后,無論如何無法避免這種情況。只能在文檔里寫明:要求
T
的拷貝構造函數不拋出異常。”
zero
停了一停,小心翼翼的問
Solmyr
:“這個不算過分的要求吧?”
Solmyr
的回答異常簡短:“new”
“哦對,new
在分配內存失敗時會拋出
std::bad_alloc
…… 算我沒說。Solmyr
,我明白了,為了處理異常的情況,調整棧頂位置必須在所有數據拷貝完成之后,所以按值返回是不可接受的。”
“正確。所以對于一個設計目標是最大限度可復用性的標準庫成員而言,這是不可接受的。”
Solmyr
頓了頓,繼續說到:“而且異常帶來的影響遠不止此。我剛才說‘假設棧內部用數組實現’,但如果你充分考慮拋出異常的各種可能性,你就會發現用數組實現是糟糕的主意。”
“
…… …… …… …… ……
這是為什么?在沒有傳值返回的情況下,我們總可以捕捉到發生的異常并加以處理啊?”,zero
謹慎的發問。
Solmyr
贊許的看著
zero
。“發問之前先自行思考,習慣不錯。”,Solmyr
心想,但是臉上一點也沒表現出來:“沒錯,但捕捉到異常不代表你總能正確的處理它。考慮一下
stack
的賦值運算符,如果我們用數組來實現,那么在拷貝數據的時候肯定會有類似這樣的一個循環:”
//
各變量的意義與上面相同
template
<typename T>
stack<T>& stack<T>::perator=(const
stack<T>& rhs)
{
... ...
for(int i=0; i<rhs.top; i++)
data
= rhs.data;
...
...
}
“現在考慮類型
T
的賦值運算符可能拋出異常,該怎樣修改上面的代碼。”
Solmyr
停了下來,再度捧起了杯子。
“用
try
把
…… 哦 …… …… …… …… …… ……”,zero
似乎發現了問題所在,沉默良久,才接著說到:“這個循環可能在運行到一半的時候拋出異常,這樣會導致一部分數據已經成功賦值,另一部分卻還是老的。除非我
們用 catch(...)
捕捉所有異常,忽略之并繼續賦值。”
“但是這樣
……”,Solmyr
有意識的引導
zero
繼續深入思考。
“……
但是這樣,賦值運算符拋出的異常就被我們‘吃掉了’,異常總是代表著某些不該發生的事情發生了,所以應該讓客戶接收到這個異常才對。”
zero
皺著眉頭,一字一頓,顯得相當辛苦。
“正
確。stack
作為一個通用的標準庫成員,在面對異常時必須做到兩點。一、異常安全,也就是說異常不會導致它本身處于一種錯誤的狀態或是導致數據丟失或是造成資源泄漏;
二、異常透明,也就是說客戶代碼 ——— 這里指它存放的類型
T
的實現
——— 拋出的任何異常,不應該被‘吃掉’或者被改變,應該透明的傳遞給客戶。一望即知,上面的代碼無可能同時做到這兩點。”
“是這樣,我懂了,這大概就是標準庫中的
stack
不用數組實現的主要原因了吧”,zero
露出了很有把握的神情。
“當然不是!有點常識好不好,用數組實現的話
stack
的大小固定,這怎么能夠接受呢?!”
又一次的,Solmyr
目睹了
zero
表情發生難以言喻的劇烈變化。這次他沒能忍住放聲大笑的沖動,連杯子里的果汁也灑了出來,一時間,笑聲充滿了整個辦公室
——— 不僅僅是他的,還包括了(眾位看官應該猜的到吧?)圍觀同事們的笑聲。
驅散了圍觀者之后,zero
面帶慍色的坐下:“有那么好笑嗎?”
“抱
歉抱歉,我 …… 哈哈哈 …… 我 …… 哈哈 ……
我只是一時忍不住 …… 哈哈哈哈 …… ”,Solmyr
好容易平息了大笑,坐直了身子,放下了果汁,正色道:“關鍵在于上面引入的應該遵循的兩條原則,也就是異常安全,和異常透明。現在你考慮一下如果
stack
內部的數據以指針存放,怎樣在賦值運算符中保證上述兩點?”
“
…… 嗯
…… 還是會有上面那樣一個循環 …… 呃 …… ”,zero
面有難色。
“提示,不一定非得直接拷貝到
stack
保存數據的內存里。”
“
…… 嗯
…… 不直接拷貝,那么就是 …… 就是拷貝到 ……
啊!我明白了!”,zero
抓住了其中的關鍵,飛快的寫下:
//
pdata 代表指向存放數據內存的指針,top
代表棧頂元素的偏移量
template
<typename T>
stack<T>& stack<T>::perator=(const
stack<T>& rhs)
{
... ...
T*
ptemp = new T[rhs.top];
try
{
for(int i=0; i<rhs.top;
i++)
*(ptemp+i)
= *(rhs.pdata+i);
}
catch(...)
// 捕捉可能出現的異常
{
delete[] ptemp;
throw; // 重新拋出
}
delete[] pdata; // 釋放當前的內存
pdata
= ptemp; // 讓
pdata
指向賦值成功的內存塊
...
...
}
“只
要這樣”,zero
邊輸入邊說,“只要先把數據拷貝到一個臨時分配的緩沖區,在此過程中處理異常,然后讓
pdata
指向成功分配的內存就行了。這里的關鍵是讓拷貝動作成為可以
…… 呃 …… 可以安全的取消的,剩下的賦值動作就是簡單的指針賦值,肯定不會拋出異常了。”
“非常好。值得指出的是,這是一種相當常見的手段,有個名字叫做
copy
& swap ,它不僅僅可以用來應付異常,也可以有效的實現一些其他特征。OK,這個問題大概就是這樣了。”
問題似乎可以告一段落了,Solmyr
開始打算就此結束這個話題。可
zero
疑惑的表情阻止了他。
“還有什么問題嗎?zero
?”
“啊
……
沒什么,我只是在想,異常導致了這么多麻煩,這一次,還有上一次的線程死鎖問題(參見“小品文系列”的前一篇,“成對出現”)都是因為異常的存在才會變得如此復雜的,那為什么
C++
還要支持它呢?有錯誤完全可以在返回值里報告嘛。”
“嗯,
這確實是個常見的疑惑,不過答案也很簡單,異常的存在有它自己的價值。一、使用異常報告錯誤可以避免污染函數界面;二、如果你希望報告比較豐富的錯誤信
息,使用一個異常對象比簡單的返回值要有效的多,而且避免了返回復雜對象造成的開銷;三、也是我認為比較重要的,有些錯誤不合適用返回值來報告。舉個例
子,動態內存分配。我問你,C
語言中怎樣報告動態內存分配錯誤?”,Solmyr
轉過頭來看著
zero
。
“malloc
函數返回一個
NULL
值代表動態內存分配錯誤。”
“但是你見過多少
C
程序員在每次使用
malloc
之后都檢查返回值?”
“
…… ”
“沒有是嗎?這很正常,每次使用
malloc
之后檢查返回值是件令人痛苦的事情,所以即使有
Steve
Maguire(注:《Writing
Clean Code》一書的作者)這樣的老程序員諄諄教導、耳提面命,還是有數以萬計的
C
程序中存在這樣的代碼:”,Solmyr
順手鍵入:
/*
傳統
C
程序
*/
int* p = malloc( sizeof(int) );
*p = 10;
“一旦
malloc
失敗返回
NULL,這個程序就會崩潰。然而如果是
C++
程序,使用
new
的話
…… ”,Solmyr
鍵入了對應的代碼:
//
C++ 程序
int*
p = new int;
*p = 10;
“就不存在這樣的問題。我問你,這是為什么?”
zero
很快找到了答案:“因為如果
new
失敗,它會拋出
std::bad_alloc
異常,于是函數在此中斷、退出,下面這一行也就不會被調用了。”
“正
確。而且你不必在每一處處理這個異常,你只要保證你的程序對異常透明,就可以在
main
函數中寫下
try
... catch 對,捕獲所有未捕獲的異常。比如你可以在
main
函數中捕捉
std::bad_alloc,在輸出‘內存不足’錯誤信息,然后保存所有未保存的數據,完成所有的清理工作,最后結束程序。一言以蔽之,體面的退出。”
zero
點著頭,喃喃的重復著:“對,體面的退出。”
見
zero
領會了他的意思,Solmyr
繼續開始下一個議題:“異常的存在還有最后一個重要價值
——— 也是當初設計它的初衷之一 ———
提供一個通用的手段讓構造函數可以方便的報告錯誤:因為構造函數沒有返回值。”
“還有析構函數也是。”沒等
Solmyr
說完,zero
就加上了這一句。
Solmyr
對著自作聰明的
zero
搖了搖頭:“不要想當然,關于異常有一個非常重要的原則:永遠不要讓你的析構函數拋出異常。知道為什么嗎?”
“
…… 不知道。”
zero
這次決定老實承認。
“因為拋出異常的析構函數會導致最簡單的程序無法正確運行,比如下面兩句:”這次出現在屏幕上的,是看來似乎毫無瑕疵的兩行代碼:
evil
p = new evil[10];
delete[] p;
“看上去一點問題也沒有是么?仔細分析一下
delete[]
p 這一句,它會調用
10
次
evil
類的析構函數,假設其中第
5
次
evil
類的析構函數拋出異常,會出現什么情況?”
zero
陷入了沉思,視線盯著屏幕一動不動,神情看起來就象是一段執行復雜運算的程序,而且是沒有輸出的那種。不過沒多久,zero
就換了一種表情,這種表情通常被形容為胸有成竹:“我知道了
Solmyr
,在這種情況下,delete[]
面臨兩難選擇。選擇一是不捕捉這個異常,讓它傳播到調用者那里,但這樣一來
delete[]
就被中斷了,后面的
5
個
evil
對象占用的內存就會無法釋放,導致資源泄漏;選擇二是捕捉這個異常以防止資源泄漏,但這樣一來這個異常就被
delete[]
吃掉了,違反了‘對異常透明’的原則。所以無論怎么做,都沒法妥善的處理析構函數拋出異常的情況。”
Solmyr
贊許的點頭:“非常好。接下來,你的任務是
……”
“我知道我知道,把這些討論整理成文檔是吧?我這就動手。”
zero
轉過身去,開始埋頭于他的文檔。而
Solmyr
則再度恢復了半躺半坐的舒適姿勢,捧起了他的果汁,并且略略有些意外的發現
———
天氣放晴了。