異常安全看上去像是孕育生命,但是讓我們先把這種觀點(diǎn)暫時(shí)放在一邊。因?yàn)樵谝粚偃私Y(jié)婚之前,討論生育問題還為時(shí)尚早。
假設(shè)我們正在設(shè)計(jì)一個(gè)表示GUI菜單的類,這種菜單是有背景圖片的,由于這個(gè)類設(shè)計(jì)用于多線程環(huán)境中,因此它擁有一個(gè)互斥鎖來確保正常的并發(fā)控制:
class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); // 更改背景圖片
...
private:
Mutex mutex; // 本對象使用的互斥鎖
Image *bgImage; // 當(dāng)前的背景圖片
int imageChanges; // 圖片更改的次數(shù)
};
下面是PrettyMenu的changeBackground函數(shù)實(shí)現(xiàn)的一個(gè)備選方案:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex); // 申請上鎖(同條目14)
delete bgImage; // 刪除舊的背景圖片
++imageChanges; // 更新圖片改變的次數(shù)
bgImage = new Image(imgSrc); // 裝載新的背景圖片
unlock(&mutex); // 解鎖
}
從異常安全的角度來說,這個(gè)函數(shù)簡直一無是處。異常安全的兩個(gè)基本要求,這個(gè)函數(shù)完全沒有考慮到。
當(dāng)拋出異常時(shí),異常安全的代碼應(yīng)該做到:
l 不要泄漏資源。上面的代碼沒有進(jìn)行這項(xiàng)檢測,這是因?yàn)槿绻?#8220;new Image(imgSrc)”語句產(chǎn)生了異常,那么對unlock的調(diào)用則永遠(yuǎn)不會兌現(xiàn),這樣該互斥鎖將永遠(yuǎn)不會被解開。
l 不能讓數(shù)據(jù)結(jié)構(gòu)遭到破壞。如果“new Image(imgSrc)”拋出異常,bgImage就會指向一個(gè)已經(jīng)銷毀的對象。另外,無論新的圖形是否裝載成功,imageChanges的數(shù)值都會增加。(從另一個(gè)角度說,舊的圖形肯定是被刪除了,那么你又怎么能確保圖形被“改變”了呢。)
處理資源泄漏問題還是比較簡單的,因?yàn)闂l目13中介紹過如何使用對象來管理資源,條目14中介紹過如何通過Lock類確保互斥鎖在適當(dāng)?shù)臅r(shí)候被解開:
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex); // 來自條目14的經(jīng)驗(yàn):
// 申請一個(gè)互斥鎖,并確保適時(shí)解鎖
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
能夠讓函數(shù)變得更短,是諸如Lock這樣的資源管理類最讓人興奮的事情之一。你是否注意到:這里甚至不需要調(diào)用unlock。更短的代碼就是更優(yōu)秀的代碼,因?yàn)榇a越短,它帶來錯(cuò)誤和誤解的機(jī)會就越少。這是一條通用的準(zhǔn)則。
把資源泄漏問題放在一旁,讓我們把注意力集中在數(shù)據(jù)結(jié)構(gòu)破壞的問題上。這里我們可以進(jìn)行一次選擇,但是在進(jìn)行選擇之前,我們首先要了解所需要的幾個(gè)術(shù)語。
異常安全函數(shù)做出以下三種保證中的一項(xiàng):
l 提供基本保證的函數(shù)會做出這樣的承諾:如果拋出了一個(gè)異常,那么程序中的一切都將保持合法的狀態(tài)。沒有任何對象或數(shù)據(jù)結(jié)構(gòu)會遭到破壞,所有的對象的內(nèi)部都保持協(xié)調(diào)的狀態(tài)(比如說,類所有的慣例都得到了滿足)。然而,程序的具體狀態(tài)可能是無法預(yù)知的。比如說,我們可以這樣編寫changeBackground:如果某一時(shí)刻拋出一個(gè)異常,那么PrettyMenu對象可能繼續(xù)使用舊的背景圖片,也可以用某個(gè)默認(rèn)的背景圖片來代替。但是客戶無法做出任何預(yù)測。(為了找到答案,客戶大概會調(diào)用某個(gè)能明示當(dāng)前背景圖片的成員函數(shù)。)
l 提供增強(qiáng)保證的函數(shù)會做出這樣的承諾:如果拋出了一個(gè)異常,那么函數(shù)的狀態(tài)將保持不變。這樣的函數(shù)仿佛是一個(gè)單一的原子,因?yàn)槿绻{(diào)用成功了,它就會大獲全勝;一旦出了差錯(cuò),程序狀態(tài)將顯示它從來沒有被調(diào)用過。
使用提供強(qiáng)保證的的函數(shù)要比使用僅提供基本保證的函數(shù)簡單一些,這是因?yàn)樵谡{(diào)用一個(gè)提供強(qiáng)保證的函數(shù)之后,程序只可能存在兩種狀態(tài):一、按預(yù)期進(jìn)行,函數(shù)成功執(zhí)行;二、程序?qū)⒈3趾瘮?shù)調(diào)用前的狀態(tài)。反觀提供基本保證的函數(shù),如果在調(diào)用它時(shí)拋出了一個(gè)異常,那么程序可能會處于任何合法的狀態(tài)。
l 提供零異常保證的函數(shù)承諾程序決不會拋出異常,因?yàn)樗鼈冇肋h(yuǎn)都會按部就班的運(yùn)行。所有的內(nèi)建數(shù)據(jù)類型(int、指針,等等)的操作都是零異常的(提供零異常保證)。這是異常安全代碼必不可少的一個(gè)因素。
我們可以假設(shè)包含空異常規(guī)范的函數(shù)是不會拋出異常的,這樣做看上去很合理,但是事實(shí)并不一定這樣,請看下面的函數(shù):
int doSomething() throw(); // 請注意:空異常規(guī)范
這并不是說doSomething將永遠(yuǎn)不會拋出異常,這只是說,如果doSomething拋出了異常,那么此時(shí)就出現(xiàn)了一個(gè)嚴(yán)重的錯(cuò)誤,同時(shí)程序應(yīng)該調(diào)用一個(gè)名為unexpected的函數(shù)。實(shí)際上,doSomething可能根本不會提供任何異常保證。函數(shù)的聲明(包括它的異常規(guī)范,如果有的話)并不會告訴你它是否正確、是否小巧、是否高效,同時(shí)也不會告訴你它他提供了哪個(gè)層面上的異常安全保證。所有那些特性都要在函數(shù)實(shí)現(xiàn)中確定下來,而不是聲明中。
異常安全的代碼必須要提供上述三個(gè)層面的保證中的一種。否則它就不是異常安全的。那么你要做的就是:對于所寫的每一個(gè)函數(shù)都要選擇一個(gè)恰當(dāng)層面的保證。除非我們正在處理異常不安全的老舊代碼(這一點(diǎn)我們稍候再提)。只有當(dāng)你的“優(yōu)秀”的需求分析小組提出“你的程序需要泄露資源,并且需要使用破壞的數(shù)據(jù)結(jié)構(gòu)”時(shí),不提供異常安全保證也許才是一個(gè)可行的選擇。
作為一個(gè)通用的準(zhǔn)則,你會希望提供可行范圍內(nèi)最強(qiáng)的保證。從異常安全的角度來說,零異常的函數(shù)是美妙的,但是如果不去調(diào)用可能會拋出異常的函數(shù),你是很難逾越C++中C這一部分的。只要涉及動態(tài)內(nèi)存分配(比如所有的STL容器),如果無法尋找到足夠的內(nèi)存來滿足當(dāng)前的要求,那么通常程序都會拋出一個(gè)bad_alloc異常(參見條目49)。在可行的時(shí)候你應(yīng)該為函數(shù)提供零異常保證,但是對于大多數(shù)函數(shù)而言,你需要在基本保證和增強(qiáng)保證之間做出選擇。
對于changeBackground,提供增強(qiáng)保證似乎并不是件難事。首先,我們可以改變PrettyMenu的bgImage數(shù)據(jù)成員的類型,從一個(gè)內(nèi)建的Image*指針類型轉(zhuǎn)變?yōu)橹悄苜Y源管理指針(參見條目13)。坦白的說,單獨(dú)從防止資源泄漏理論的角度上說,這是一個(gè)非常好的設(shè)計(jì)方案。事實(shí)上它簡單地通過使用對象(比如智能指針)來管理資源(也就是遵循了條目13中的建議,這是優(yōu)秀設(shè)計(jì)的基本要求),幫助我們提供了增強(qiáng)的異常安全保證。在下面的代碼中,我將使用tr1::shared_ptr,這是因?yàn)樗男袨楦庇^,在進(jìn)行復(fù)制操作時(shí)比auto_ptr更合適。
其次,我們從新編排了changeBackground中語句的順序,從而使imageChanges直到圖像改變以后才進(jìn)行自加。作為一個(gè)通用的準(zhǔn)則,直到一個(gè)事件真真切切地發(fā)生了,才去改變對象的狀態(tài)來描述這個(gè)事件。
下面是改進(jìn)后的代碼:
class PrettyMenu {
...
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
bgImage.reset(new Image(imgSrc)); // 將bgImage的內(nèi)部指針替換為
// ”new Image”語句的結(jié)果
++imageChanges;
}
請注意這里不需要手動刪除舊圖片,因?yàn)檫@件事情完全由智能指針自行解決了。而且,只有在新圖像成功創(chuàng)建之后,刪除操作才會進(jìn)行。更精確地說,tr1::shared_ptr::reset函數(shù)只有在其參數(shù)(“new Image(imgSrc)”的結(jié)果)成功創(chuàng)建以后才會得到調(diào)用。由于只有在調(diào)用reset過程中才會使用delete,因此如果從未進(jìn)入該函數(shù),就永遠(yuǎn)不會用到delete。注意:使用對象(tr1::shared_ptr)來管理資源(動態(tài)分配的Image),再次精簡了changeBackground。
如前所述,這兩項(xiàng)改變似乎使changeBackground滿足了增強(qiáng)的異常安全保證。可是白璧微瑕,imgSrc參數(shù)還有一個(gè)小問題。如果Image的構(gòu)造函數(shù)拋出了一個(gè)異常,那么輸入流的讀標(biāo)記很可能已被移動,這樣的移動可能會造成狀態(tài)的變化,而這種變化對程序其它部分來說是可見的。在changeBackground解決這一問題之前,它僅僅提供基本的異常安全保證。
然而,讓我們把這個(gè)問題暫時(shí)放在一旁,假裝changeBackground確實(shí)可以提供增強(qiáng)保證。(我相信你可以想出一個(gè)辦法來,可以通過改變參數(shù)的類型:從輸入流變?yōu)榘瑘D像信息的文件名。)有一個(gè)一般化的設(shè)計(jì)方案,可以使函數(shù)做到增強(qiáng)保證,了解這一方案十分重要。該方案一般稱為“復(fù)制并swap。”從理論上來講,它非常簡單。為需要修改的對象做一個(gè)副本,然后將所有需要做的改變應(yīng)用于這個(gè)副本之上。如果期間任一個(gè)修改操作拋出了異常,那么原始的對象依然紋絲未動。在所有改變順利完成之后,通過一次不拋出異常的操作將修改過的對象與原始對象相交換即可(參見條目25)。
上述方案通常這樣實(shí)現(xiàn):將對象的所有數(shù)據(jù)從“真實(shí)的”對象復(fù)制到一個(gè)獨(dú)立實(shí)現(xiàn)的對象中,然后為真實(shí)對象創(chuàng)建一個(gè)指針,將其指向這個(gè)實(shí)現(xiàn)對象。這通常稱為“pimpl(pointer to implementation,指向?qū)崿F(xiàn)的指針)慣用法”,條目31中將將解它的一些細(xì)節(jié)。對于PrettyMenu而言,典型的實(shí)現(xiàn)是這樣的:
struct PMImpl { // PMImpl = PrettyMenu的實(shí)現(xiàn)
std::tr1::shared_ptr<Image> bgImage; // 下文將介紹它為什么是結(jié)構(gòu)體
int imageChanges;
};
class PrettyMenu {
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap; // 參見條目25
Lock ml(&mutex); // 上鎖
std::tr1::shared_ptr<PMImpl> // 復(fù)制對象數(shù)據(jù)
pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc)); // 修改副本
++pNew->imageChanges;
swap(pImpl, pNew); // 交換新數(shù)據(jù)就位
} // 解鎖
在這個(gè)示例中,我做出了這樣的選擇:PMImpl是一個(gè)結(jié)構(gòu)體而不是類,由于pImpl是私有的,因此PrettyMenu數(shù)據(jù)的封裝性得以保證。將PMImpl實(shí)現(xiàn)為類也不是不可以,但似乎便利性略差。(它同樣會讓面向?qū)ο蟮钠珗?zhí)狂們感到困惑。)如果需要,可以讓PMImpl嵌套在PrettyMenu的內(nèi)部,但是打包問題與編寫異常安全代碼的問題似乎沒有什么聯(lián)系,這不是我們當(dāng)前所關(guān)注的。
有些修改對象狀態(tài)的操作,要求要么是完全修改,要么完全不變,此時(shí)“復(fù)制并swap”策略是完美的。但是,一般情況下,它并不能確保整個(gè)函數(shù)都做到增強(qiáng)保證。請看下面changeBackground的一個(gè)抽象——someFunc,它使用了“復(fù)制并swap”策略,但是它包含了兩個(gè)其它函數(shù)(f1和f2)的調(diào)用:
void someFunc()
{
... // 為本地的狀態(tài)創(chuàng)建副本
f1();
f2();
... // 交換修改后的狀態(tài)就位
}
這里應(yīng)該很清楚了:如果f1或者f2沒有達(dá)到增強(qiáng)保證的要求,那么someFunc就很難滿足增強(qiáng)保證。比如,假設(shè)f1僅提供了基本保證,為了讓someFunc能滿足增強(qiáng)保證,就必須要為其編寫額外的代碼,用于調(diào)用f1之前確定整個(gè)程序的狀態(tài),捕獲f1拋出的所有異常,然后恢復(fù)原始的狀態(tài)。
如果f1和f2都滿足了增強(qiáng)保證,那么事情也不會好到哪去。如果f1運(yùn)行完成,那么程序的狀態(tài)可能經(jīng)歷了任意的修改過程,因此,一旦f2在此時(shí)拋出了一個(gè)異常,那么即使f2沒有做任何修改操作,程序的狀態(tài)也可能會與someFunc被調(diào)用時(shí)不一致。
問題在于函數(shù)的副作用。只要函數(shù)操作僅僅針對本地的狀態(tài)(比如說,someFunc僅僅影響到它所調(diào)用對象的狀態(tài)),提供增強(qiáng)保證就相對簡單些。當(dāng)函數(shù)對于非本地?cái)?shù)據(jù)存在副作用時(shí),則更加困難些。比如說,如果調(diào)用f1引入的副作用是數(shù)據(jù)庫被修改了,那么讓someFunc滿足異常安全的增強(qiáng)保證就比較困難。一般來說,已經(jīng)被系統(tǒng)接受的數(shù)據(jù)庫修改操作是無法撤銷的,這是因?yàn)槠渌臄?shù)據(jù)庫用戶已經(jīng)看到了數(shù)據(jù)庫的新狀態(tài)。
不管你情愿與否,諸如這樣的問題會在你期望編寫增強(qiáng)保證的函數(shù)時(shí)設(shè)置重重障礙。另一個(gè)問題是:效率。復(fù)制并swap策略的核心思想就是修改對象副本的數(shù)據(jù),然后通過一個(gè)不會拋出異常的操作來交換修改后的數(shù)據(jù)。這需要為每個(gè)需要修改的對象創(chuàng)建出一個(gè)副本,這樣做顯然會浪費(fèi)時(shí)間和空間,你也許不會情愿使用這一策略,現(xiàn)實(shí)條件有時(shí)也會阻止你。增強(qiáng)保證是我們良好的預(yù)期目標(biāo),只要可行你就應(yīng)該提供,但是現(xiàn)實(shí)中它并不總是可行的。
在增強(qiáng)保證不可行時(shí),你應(yīng)該提供基本保證。從實(shí)用角度說,如果你發(fā)現(xiàn)你可以為某些函數(shù)提供增強(qiáng)保證,但是由此帶來的效率和復(fù)雜度問題讓增強(qiáng)保證變得得不償失。只要你在必要的時(shí)候做出了努力使適當(dāng)?shù)暮瘮?shù)滿足了增強(qiáng)保證,那么對于一些函數(shù)僅提供基本保證就是無可厚非的。對于大多數(shù)函數(shù)而言,基本保證已經(jīng)是合理的、完美的選擇了。
如果你正在編寫一個(gè)完全不提供異常安全保證的函數(shù),那么就是另一番景象了。因?yàn)樵谶@里完全可以在未證明你無罪之前假定你有罪。你本應(yīng)該編寫異常安全代碼。但是你也可以為自己做出強(qiáng)有力的辯解。請?jiān)俅慰紤]一下someFunc的實(shí)現(xiàn),它調(diào)用了兩個(gè)函數(shù):f1和f2,假設(shè)f2完全沒有提供異常安全保證,即使基本保證也沒有,這就意味著一旦f2拋出一個(gè)異常,程序可能會在f2的內(nèi)部發(fā)生資源泄露。這意味著f2中可能會有破損的數(shù)據(jù)結(jié)構(gòu),比如:排好序的數(shù)組可能不再按順序排列,在兩個(gè)數(shù)據(jù)結(jié)構(gòu)之間轉(zhuǎn)送的對象也可能會丟失數(shù)據(jù),等等。這樣someFunc也無力回天。如果someFunc函數(shù)調(diào)用了沒有提供異常安全保證的函數(shù),那么someFunc自身就無法做出任何保證。
讓我們回到本節(jié)開篇時(shí)所說的“孕育生命”的問題。一位女性要么就是懷孕,要么就是沒有,絕沒有“部分懷孕”的狀態(tài)。類似的,一個(gè)軟件系統(tǒng)要么是異常安全的,要么就不是。沒有所謂的“部分異常安全”的狀態(tài)存在。在一個(gè)系統(tǒng)中,即使只有一個(gè)單獨(dú)的函數(shù)不是異常安全的,那么整個(gè)系統(tǒng)也就不是異常安全的。遺憾的是,許多較為古老的C++代碼在編寫的時(shí)候完全沒有考慮到異常安全問題,因此當(dāng)今許多系統(tǒng)便不是異常安全的。新系統(tǒng)中混雜著異常不安全的編寫習(xí)慣。
沒有理由去維持現(xiàn)狀。當(dāng)編寫新代碼或者修改現(xiàn)有代碼的時(shí)候,要認(rèn)真考慮一下如何使之做到異常安全。首先,使用對象管理資源。(依然參見條目13。)這將有效地防止資源泄露。然后對于你要編寫的每個(gè)函數(shù)確定你要使用哪一層面的異常安全保證,只有在調(diào)用古老的、沒有異常安全保證的代碼時(shí)才放棄異常安全保證,因?yàn)槟銊e無選擇。記錄下你的選擇,這即是為了你的客戶,也是為了今后的維護(hù)人員。函數(shù)的異常安全保證位于接口的可見部分,因此你應(yīng)該認(rèn)真規(guī)劃它,就像你認(rèn)真規(guī)劃接口其它部分一樣。
四十年前,人們迷信充斥著goto的代碼是完美的,現(xiàn)在我們卻為了編寫結(jié)構(gòu)化控制流而努力。二十年前,全局的完全可訪問的數(shù)據(jù)也是高踞神壇,然而當(dāng)今我們卻在提倡封裝數(shù)據(jù)。十年前,編寫函數(shù)時(shí)不去考慮異常的影響的做法倍受追捧,但是今天,我們堅(jiān)定不渝的編寫異常安全代碼。
歲月荏苒,我們在學(xué)習(xí)中不斷進(jìn)步……
時(shí)刻牢記
l 異常安全的函數(shù)即使在異常拋出時(shí),也不會帶來資源泄露,同時(shí)也不允許數(shù)據(jù)結(jié)構(gòu)遭到破壞。這類函數(shù)提供基本的、增強(qiáng)的、零異常的三個(gè)層面的異常安全保證。
l 增強(qiáng)保證可以通過復(fù)制并swap策略來實(shí)現(xiàn),但是增強(qiáng)保證并不是對所有函數(shù)都適用。
l 函數(shù)所提供的異常安全保證通常不會強(qiáng)于其所調(diào)用函數(shù)中保證層次最弱的一個(gè)。