C++的營養
莫華楓
動物都會攝取食物,吸收其中的營養,用于自身生長和活動。然而,并非食物中所有的物質都能為動物所吸收。那些無法消化的物質,通過消化道的另一頭(某些動
物消化道只有一頭)排出體外。不過,一種動物無法消化的排泄物,是另一種動物(生物)的食物,后者可以從中攝取所需的營養。
一門編程語言,對于程序員而言,如同食物那樣,包含著所需的養分。當然也包含著無法消化的東西。不同的是,隨著程序員不斷成長,會逐步消化過去無法消化的那些東西。
C++可以看作一種成分復雜的食物,對于多數程序員而言,是無法完全消化的。正因為如此,很多程序員認為C++太難以消化,不應該去吃它。但是,C++的
營養不可謂不豐富,就此舍棄,而不加利用,則是莫大的罪過。好在食物可以通過加工,變得易于吸收,比如說發酵。鑒于程序員們的消化能力的差異,也為了讓C
++的營養能夠造福他人,我就暫且扮演一回酵母菌,把C++的某些營養單獨提取出來,并加以分解,讓那些消化能力不太強的程序員也能享受它的美味。:)
(為了讓這些營養便于消化,我將會用C#做一些案例。選擇C#的原因很簡單,因為我熟悉。:))
RAII
RAII,好古怪的營養啊!它的全稱應該是“Resource Acquire Is Initial”。這是C++創始人Bjarne
Stroustrup發明的詞匯,比較令人費解。說起來,RAII的含義倒也不算復雜。用白話說就是:在類的構造函數中分配資源,在析構函數中釋放資源。
這樣,當一個對象創建的時候,構造函數會自動地被調用;而當這個對象被釋放的時候,析構函數也會被自動調用。于是乎,一個對象的生命期結束后將會不再占用
資源,資源的使用是安全可靠的。
下面便是在C++中實現RAII的典型代碼:
class file
{
public:
file(string const& name) {
m_fileHandle=open_file(name.cstr());
}
~file() {
close_file(m_fileHandle);
}
...
private:
handle m_fileHandle;
}
很典型的“在構造函數里獲取,在析構函數里釋放”。如果我寫下代碼:

void fun1() ...{
file myfile("my.txt");
... //操作文件
} //此處銷毀對象,調用析構函數,釋放資源
當函數結束時,局部對象myfile的生命周期也結束了,析構函數便會被調用,資源會得到釋放。而且,如果函數中的代碼拋出異常,那么析構函數也會被調用,資源同樣會得到釋放。所以,在RAII下,不僅僅資源安全,也是異常安全的。
但是,在如下的代碼中,資源不是安全的,盡管我們實現了RAII:

void fun2() ...{
file pfile=new file("my.txt");
... //操作文件
}
因為我們在堆上創建了一個對象(通過new),但是卻沒有釋放它。我們必須運用delete操作符顯式地加以釋放:

void fun3() ...{
file pfile=new file("my.txt");
... //操作文件
delete pfile;
}
否則,非但對象中的資源得不到釋放,連對象本身的內存也得不到回收。(將來,C++的標準中將會引入GC(垃圾收集),但正如下面分析的那樣,GC依然無法確保資源的安全)。
現在,在fun3(),資源是安全的,但卻不是異常安全的。因為一旦函數中拋出異常,那么delete pfile;這句代碼將沒有機會被執行。C++領域的諸位大牛們告誡我們:如果想要在沒有GC的情況下確保資源安全和異常安全,那么請使用智能指針:

void fun4() ...{
shared_ptr<file> spfile(new file("my.txt"));
... //操作文件
} //此處,spfile結束生命周期的時候,會釋放(delete)對象
那么,智能指針又是怎么做到的呢?下面的代碼告訴你其中的把戲(關于智能指針的更進一步的內容,請參考std::auto_ptr,boost或tr1的智能指針):
template<typename T>
class smart_ptr

...{
public:

smart_ptr(T* p):m_ptr(p) ...{}

~smart_ptr() ...{ delete m_ptr; }
...
private:
T* m_ptr;
}
沒錯,還是RAII。也就是說,智能指針通過RAII來確保內存資源的安全,也間接地使得對象上的RAII得到實施。不過,這里的RAII并不是十分嚴
格:對象(所占的內存也是資源)的創建(資源獲取)是在構造函數之外進行的。廣義上,我們也把它劃歸RAII范疇。但是,Matthew
Wilson在《Imperfect C++》一書中,將其獨立出來,稱其為RRID(Resource Release Is
Destruction)。RRID的實施需要在類的開發者和使用者之間建立契約,采用相同的方法獲取和釋放資源。比如,如果在shared_ptr構造
時使用malloc(),便會出現問題,因為shared_ptr是通過delete釋放對象的。
對于內置了GC的語言,資源管理相對簡單。不過,事情并非總是這樣。下面的C#代碼摘自MSDN Library的C#編程指南,我略微改造了一下:
static void CodeWithoutCleanup()

...{
System.IO.FileStream file = null;
System.IO.FileInfo fileInfo = new System.IO.FileInfo("C:\file.txt");
file = fileInfo.OpenWrite();
file.WriteByte(0xF);
}
那么資源會不會泄漏呢?這取決于對象的實現。如果通過OpenWrite()獲得的FileStream對象,在析構函數中執行了文件的釋放操作,那么資
源最終不會泄露。因為GC最終在執行GC操作的時候,會調用Finalize()函數(C#類的析構函數會隱式地轉換成Finalize()函數的重
載)。這是由于C#使用了引用語義(嚴格地講,是對引用類型使用引用語義),一個對象實際上不是對象本身,而是對象的引用。如同C++中的那樣,引用在離
開作用域時,是不會釋放對象的。否則,便無法將一個對象直接傳遞到函數之外。在這種情況下,如果沒有顯式地調用Close()之類的操作,資源將不會得到
立刻釋放。但是像文件、鎖、數據庫鏈接之類屬于重要或稀缺的資源,如果等到GC執行回收,會造成資源不足。更有甚者,會造成代碼執行上的問題。我曾經遇到
過這樣一件事:我執行了一個sql操作,獲得一個結果集,然后執行下一個sql,結果無法執行。這是因為我使用的SQL Server
2000不允許在一個數據連接上同時打開兩個結果集(很多數據庫引擎都是這樣)。第一個結果集用完后沒有立刻釋放,而GC操作則尚未啟動,于是便造成在一
個未關閉結果集的數據連接上無法執行新的sql的問題。
所以,只要涉及了內存以外的資源,應當盡快釋放。(當然,如果內存能夠盡快釋放,就更好了)。對于上述CodeWithoutCleanup()函數,應當在最后調用file對象上的Close()函數,以便釋放文件:
static void CodeWithoutCleanup()

...{
System.IO.FileStream file = null;
System.IO.FileInfo fileInfo = new System.IO.FileInfo("C:\file.txt");
file = fileInfo.OpenWrite();
file.WriteByte(0xF);
file.Close();
}
現在,這個函數是
嚴格資源安全的,但卻不是
嚴格異常安全的。如果在文件的操作中拋出異常,Close()成員將得不到調用。此時,文件也將無法及時關閉,直到GC完成。為此,需要對異常作出處理:
static void CodeWithCleanup()

...{
System.IO.FileStream file = null;
System.IO.FileInfo fileInfo = null;
try

...{
fileInfo = new System.IO.FileInfo("C:\file.txt");
file = fileInfo.OpenWrite();
file.WriteByte(0xF);
}
catch(System.Exception e)

...{
System.Console.WriteLine(e.Message);
}
finally

...{
if (file != null)

...{
file.Close();
}
}
}
try-catch-finally是處理這種情況的標準語句。但是,相比前面的C++代碼fun1()和fun4()繁瑣很多。這都是沒有RAII的后果啊。下面,我們就來看看,如何在C#整出RAII來。
一個有效的RAII應當包含兩個部分:構造/析構函數的資源獲取/釋放和確定性的析構函數調用。前者在C#中不成問題,C#有構造函數和析構函數。不過,
C#的構造函數和析構函數是不能用于RAII的,原因一會兒會看到。正確的做法是讓一個類實現IDisposable接口,在IDisposable::
Dispose()函數中釋放資源:
class RAIIFile : IDisposable

...{

public RAIIFile(string fn) ...{
System.IO.FileInfo fileInfo = new System.IO.FileInfo(fn);
file = fileInfo.OpenWrite();
}


public void Dispose() ...{
file.Close();
}

private System.IO.FileStream file = null;
}
下一步,需要確保文件在退出作用域,或發生異常時被確定性地釋放。這項工作需要通過C#的using語句實現:
static void CodeWithRAII()

...{
using(RAIIFile file=new RAIIFile("C:\file.txt"))

...{
... //操作文件
} //文件釋放
}
一旦離開using的作用域,file.Dispose()將被調用,文件便會得到釋放,即便拋出異常,亦是如此。相比CodeWithCleanup
()中那坨雜亂繁復的代碼,CodeWithRAII()簡直可以算作賞心悅目。更重要的是,代碼的簡潔和規則將會大幅減少出錯可能性。值得注意的是
using語句只能作用于實現IDisposable接口的類,即便實現了析構函數也不行。所以對于需要得到RAII的類,必須實現
IDisposable。通常,凡是涉及到資源的類,都應該實現這個接口,便于日后使用。實際上,.net庫中的很多與非內存資源有關的類,都實現了
IDisposable,都可以利用using直接實現RAII。
但是,還有一個問題是using無法解決的,就是如何維持類的成員函數的RAII。我們希望一個類的成員對象在該類實例創建的時候獲取資源,而在其銷毀的時候釋放資源:
class X

...{
public:

X():m_file("c:\file.txt") ...{}
private:
File m_file; //在X的實例析構時調用File::~File(),釋放資源。
}
但是在C#中無法實現。由于uing中實例化的對象在離開using域的時候便釋放了,無法在構造函數中使用:
class X

...{

public X() ...{
using(m_file=new RAIIFile("C:\file.txt"))

...{
}//此處m_file便釋放了,此后m_file便指向無效資源
}
pravite RAIIFile m_file;
}
對于成員對象的RAII只能通過在析構函數或Dispose()中手工地釋放。我還沒有想出更好的辦法來。
至此,RAII的來龍去脈已經說清楚了,在C#里也能從中汲取到充足的養分。但是,這還不是RAII的全部營養,RAII還有更多的擴展用途。在
《Imperfect C++》一書中,Matthew
Wilson展示了RAII的一種非常重要的應用。為了不落個鸚鵡學舌的名聲,這里我給出一個真實遇到的案例,非常簡單:我寫的程序需要響應一個Grid
控件的CellTextChange事件,執行一些運算。在響應這個事件(執行運算)的過程中,不能再響應同一個事件,直到處理結束。為此,我設置了一個
標志,用來控制事件響應:
class MyForm

...{
public:

MyForm():is_cacul(false) ...{}
...

void OnCellTextChange(Cell& cell) ...{
if(is_cacul)
return;
is_cacul=true;
... //執行計算任務
is_cacul=false;
}
private:
bool is_cacul;
};
但是,這里的代碼不是異常安全的。如果在執行計算的過程中拋出異常,那么is_cacul標志將永遠是true。此后,即便是正常的
CellTextChange也無法得到正確地響應。同前面遇到的資源問題一樣,傳統上我們不得不求助于try-catch語句。但是如果我們運用
RAII,則可以使得代碼簡化到不能簡化,安全到不能再安全。我首先做了一個類:
class BoolScope

...{
public:
BoolScope(bool& val, bool newVal)

:m_val(val), m_old(val) ...{
m_val=newVal;
}

~BoolScope() ...{
m_val=m_old;
}

private:
bool& m_val;
bool m_old;
};
這個類的作用是所謂“域守衛(scoping)”,構造函數接受兩個參數:第一個是一個bool對象的引用,在構造函數中保存在m_val成員里;第二個
是新的值,將被賦予傳入的那個bool對象。而該對象的原有值,則保存在m_old成員中。析構函數則將m_old的值返還給m_val,也就是那個
bool對象。有了這個類之后,便可以很優雅地獲得異常安全:
class MyForm

...{
public:

MyForm():is_cacul(false) ...{}
...

void OnCellTextChange(Cell& cell) ...{
if(is_cacul)
return;
BoolScope bs_(is_cacul, true);
... //執行計算任務
}
private:
bool is_cacul;
};
好啦,任務完成。在bs_創建的時候,is_cacul的值被替換成true,它的舊值保存在bs_對象中。當OnCellTextChange()返回
時,bs_對象會被自動析構,析構函數會自動把保存起來的原值重新賦給is_cacul。一切又都回到原先的樣子。同樣,如果異常拋出,is_cacul
的值也會得到恢復。
這個BoolScope可以在將來繼續使用,分攤下來的開發成本幾乎是0。更進一步,可以開發一個通用的Scope模板,用于所有類型,就像《Imperfect C++》里的那樣。
下面,讓我們把戰場轉移到C#,看看C#是如何實現域守衛的。考慮到C#(.net)的對象模型的特點,我們先實現引用類型的域守衛,然后再來看看如何對付值類型。其原因,一會兒會看到。
我曾經需要向一個grid中填入數據,但是填入的過程中,控件不斷的刷新,造成閃爍,也影響性能,除非把控件上的AutoDraw屬性設為false。為此,我做了一個域守衛類,在填寫操作之前關上AutoDraw,完成或異常拋出時再打開:
class DrawScope : IDisposable

...{

public DrawScope(Grid g, bool val) ...{
m_grid=g;
m_old=g->AutoDraw;
m_grid->AutoDraw=val;
}

public void Dispose() ...{
g->AutoDraw=m_old;
}
private Grid m_grid;
private bool m_old;
};
于是,我便可以如下優雅地處理AutoDraw屬性設置問題:

static void LoadData(Grid g) ...{
using(DrawScope ds=new DrawScope(g, false))

...{
... //執行數據裝載
}
}
現在,我們回過頭,來實現值類型的域守衛。案例還是采用前面的CellTextChange事件。當我試圖著手對那個is_cacul執行域守衛時,遇到了不小的麻煩。起初,我寫下了這樣的代碼:
class BoolScope

...{
private ??? m_val; //此處用什么類型?
private bool m_old;
};
m_val應當是一個指向一個對象的引用,C#是沒有C++那些指針和引用的。在C#中,引用類型定義的對象實際上是一個指向對象的引用;而值類型定義的
對象實際上是一個對象,或者說“棧對象”,但卻沒有一種指向值類型的引用。(關于這種對象模型的優劣,后面的“題外話”小節有一些探討)。我嘗試著采用兩
種辦法,一種不成功,而另一種成功了。
C#(.net)有一種box機制,可以將一個值對象打包,放到堆中創建。這樣,或許可以把一個值對象編程引用對象,構成C#可以引用的東西:
class BoolScope : IDisposable

...{

public BoolScope(object val, bool newVal) ...{
m_val=val; //#1
m_old=(bool)val;
(bool)m_val=newVal; //#2
}

public void Dispose() ...{
(bool)m_val=m_old; //#3
}
private object m_val;
private bool m_old;
}
使用時,應當采用如下形式:
class MyForm

...{

public MyForm() ...{
is_cacul=new bool(false); //boxing
}
...

void OnCellTextChange(Cell& cell) ...{
if(is_cacul)
return;
using(BoolScope bs=new BoolScope(is_cacul, true))

...{
... //執行計算任務
}
}
private object is_cacul;
};
很可惜,此路不通。因為在代碼#1的地方,并未執行引用語義,而執行了值語義。也就是說,沒有把val(它是個引用)的值賦給m_val(也是個引用),
而是為m_val做了個副本。以至于在代碼#2和#3處無法將newVal和m_old賦予val(也就是is_cacul)。或許C#的設計者有無數理
由說明這種設計的合理性,但是在這里,卻扼殺了一個非常有用的idom。而且,缺少對值對象的引用手段,大大限制了語言的靈活性和擴展性。
第二種方法就非常直白了,也絕對不應當出問題,就是使用包裝類:
class BoolVal

...{
public BoolVal(bool v)

...{
m_val=v;
}

public bool getVal() ...{
return m_val;
}

public void setVal(bool v) ...{
m_val=v;
}
private bool m_val;
}
class BoolScope : IDisposable

...{
public IntScope(BoolVal iv, bool v)

...{
m_old = iv.getVal();
m_Val = iv;
m_Val.setVal(v);
}
public virtual void Dispose()

...{
m_Val.setVal(m_old);
}
private BoolVal m_Val;
private bool m_old;
}
這里,我做了一個包裝類BoolVal,是個引用類。然后以此為基礎,編寫了一個BoolScope類。然后,便可以正常使用域守衛:
class MyForm

...{

public MyForm() ...{
m_val.setVal(false); //boxing
}
...

void OnCellTextChange(Cell& cell) ...{
if(is_cacul)
return;
using(BoolScope bs=new BoolScope(m_val, true))

...{
... //執行計算任務
}
}
private BoolVal m_val;
};
好了,一切都很不錯。盡管C#的對象模型給我們平添了不少麻煩,使得我多寫了不少代碼,但是使用域守衛類仍然是一本萬利的事情。作為GP fans,我當然也嘗試著在C#里做一些泛型,以免去反復開發包裝類和域守衛類的苦惱。這些東西,就留給大家做練習吧。:)
在某些場合下,我們可能會對一些對象做一些操作,完事后在恢復這個對象的原始狀態,這也是域守衛類的用武之地。只是守衛一個結構復雜的類,不是一件輕松的
工作。最直接的做法是取出所有的成員數據,在結束后再重新復制回去。這當然是繁復的工作,而且效率不高。但是,我們將在下一篇看到,如果運用swap手
法,結合復制構造函數,可以很方便地實現這種域守衛。這我們以后再說。
域守衛作為RAII的一個擴展應用,非常簡單,但卻極具實用性。如果我們對“資源”這個概念加以推廣,把一些值、狀態等等內容都納入資源的范疇,那么域守衛類的使用是順理成章的事。
題外話:C#的對象模型
C#的設計理念是簡化語言的學習和使用。但是,就前面案例中出現的問題而言,在特定的情況下,特別是需要靈活和擴展的時候,C#往往表現的差強人意。C#
的對象模型實際上是以堆對象和引用語義為核心的。不過,考慮到維持堆對象的巨大開銷和性能損失,應用在一些簡單的類型上,比如int、float等等,實
在得不嘗失。為此,C#將這些簡單類型直接作為值處理,當然也允許用戶定義自己的值類型。值類型擁有值語義。而值類型的本質是棧對象,引用類型則是堆對
象。
這樣看起來應該是個不錯的折中,但是實際上卻造成了不大不小的麻煩。前面的案例已經明確地表現了這種對象模型引發的麻煩。由于C#拋棄值和引用的差異(為
了簡化語言的學習和使用),那么對于一個引用對象,我們無法用值語義訪問它;而對于一個值對象,我們無法用引用語義訪問。對于前者,不會引發本質性的問
題,因為我們可以使用成員函數來實現值語義。但是對于后者,則是無法逾越的障礙,就像在BoolScope案例中表現的那樣。在這種情況下,我們不得不用
引用類包裝值類型,使得值類型喪失了原有的性能和資源優勢。
更有甚者,C#的對象模型有時會造成語義上的沖突。由于值類型使用值語義,而引用類型使用引用語義。那么同樣是對象定義,便有可能使用不同的語義:
int i, j=10; //值類型
i=j; //值語義,兩個對象復制內容
i=5; //i==5, j==10
StringBuilder s1, s2 = new StringBuilder("s2"); //引用類型
s1 = s2; //引用語義,s1和s2指向同一個對象
s1.Append(" is s1"); //s1==s2=="s1 is s2"
同一個形式具有不同語義,往往會造成意想不到的問題。比如,在軟件開發的最初時刻,我們認為某個類型是值類型就足夠了,還可以獲得性能上的好處。但是,隨
著項目進入后期階段,發現最初的設計有問題,值類型限制了該類型的某些特性(如不能擁有析構函數,不能引用等等),那么需要把它改成引用類型。于是便引發
一大堆麻煩,需要檢查所有使用該類型的代碼,然后把
賦值操作改成
復制操作。這肯定不是討人喜歡的工作。為此,在實際開發中,很少自定義值類型,以免將來自縛手腳。于是,值類型除了語言內置類型和.net庫預定義的類型外,成了一件擺設。
相比之下,傳統語言,如Ada、C、C++、Pascal等,區分引用和值的做法盡管需要初學者花更多的精力理解其中的差別,但在使用中則更加妥善和安全。畢竟學習是暫時的,使用則是永遠的。