如果你愛他,讓他學VCL,因為那是天堂。
如果你恨他,讓他學VCL,因為那是地獄。
──《天方夜譚VCL》
傳說很久很久以前,中國和印度之間有個島。那裡的國王每天娶一個女子,過夜後就殺,鬧得雞犬不寧,最後宰相的女兒自願嫁入宮。第一晚,她講了一個非常有意思的故事,國王聽入了迷,第二天沒有殺她。此後她每晚講一個奇特的故事,一直講到第一千零一夜,國王終於幡然悔悟。這就是著名的《一千零一夜》,也就是《天方夜譚》。印度和中國陸地接壤,那麼相信傳說中所指的島,必然是在南中國海-馬六甲海峽-印度洋某個地方。現在我也算是在這其間的一個海島上,正值夜晚,也就借借“天方夜譚”的大名吧。
初中我最喜歡的編程環境是Turbo C 2.0,高一開始用Visual Basic。後來用了沒多久就發現,如果想做一個稍微復雜的東西,就需要不停地查資料來調用API,得在最前面作一個長得可怕的API函數聲明。於是我開始懷念簡潔的C語言。有位喜歡用Delphi的師哥,知道我極為憤恨Pascal,把我引向C++ Builder。即使對於C++中的繼承、多態這些簡單概念都還是一知半解,我居然也開始用VCL編一些莫名其妙的小程序(VCL上手倒真容易),開始熟悉VCL的結構,同時也了解了MFC和SDK,補習C++的基礎知識。後來我才覺得,VCL易學易用根本是個謊言。其實VCL相當難學,甚至比MFC更麻煩。
不知道為什麼,C++ Builder的資料出奇地少,也許正是這個原因,C++ Builder論壇上的人情味也特別濃。不管是我初學VCL時常問些莫名其妙白癡問題的天極論壇,還是現在我經常駐足的CSDN,C++ Builder論壇給人的感覺總是很溫馨。每次C++ Builder都比同等版本Delphi晚出,每次用C++還不得不看Object Pascal的臉色,我想這是很多人心裡的感受。CLX已經出現在Delphi6中,C++ Builder6的發布似乎還遙遙無期。CLX會代替VCL嗎?看來似乎不會,後面還會提到。我也看過不少要號召把VCL用C++改寫的帖子,往往雷聲大雨點小。看看別人老外,說干就干,一個FreeCLX項目就這麼啟動了。
用MFC的人比用VCL的運氣好,他們有Microsoft的支持,有Inside Visual C++、Programming Windows 95 with MFC、MFC Internals這些天王巨星的英文名著和中文翻譯,也有諸如侯捷先生的《深入淺出MFC》(即Dissecting MFC)這些出色的中文原創作品。使用Delphi的人也遠比使用C++ Builder的命好,關於Delphi的精彩資料遠遠比C++ Builder多,很無奈,真的很無奈。
C++ View雜志的主編向我約稿,我很為難,因為時間和技術水平都成問題。借用侯捷先生一句話,要拒絕和你住在同一個大腦同一個軀殼的人日日夜夜旦旦夕夕的請求,是很困難的。於是我下決心,寫一系列分析VCL內部原理的文章。所謂“天方夜譚”,當然對初學者不會有立桿見影的幫助,甚至於會讓您覺得“無聊”。這些文章面向的朋友應該比較熟悉VCL,有一定C++的基礎(當然會Object Pascal和匯編更好),比如希望知道VCL底層運作機制的朋友,和希望自己開發應用框架或者想用C++重寫VCL的朋友。同時我更希望大家交流一下解剖應用框架的經驗,讓我們不局限於VCL或者MFC,能站在更高的角度看問題,共同提高自己的能力。
在深入探討VCL之前,先得把VCL主要的性質說一下。
所以,VCL的本質是一個Object Pascal類庫,提供了Object Pascal和C++兩個接口。在剖析的過程中,請時刻牢記這一點。
文章的組織結構是就事論事,一次一個話題。由於VCL並不像MFC是一個獨立的框架,它與Object Pascal、IDE、編譯器結合非常緊密,所以在剖析過程中不免會提到匯編。當然不會匯編的朋友也不用怕,我會把匯編代碼都解釋清楚,並盡量用C++改寫。
文中有很多圖是表示類的內存結構,如圖所示。其中方框表示一個變量,兩端伸出表示還有若干個變量,橢圓標注是說明虛線圓圈中的整個對象(在後面虛線圓圈不會畫出)。
圖1 圖例
文中的程序,如非特別說明,均可以在Console Application模式下(如果使用了VCL類則需要復選“Use VCL”)編譯通過。
倒霉者如愚公,開門就見太行、王屋山。在一怒之下他開始移山,最後幸虧天神幫忙搬走了。中國人不喜歡開門見山的性格可能就是愚公傳下來的,說話做事老愛繞彎。當然我也不能免俗,前面廢話了一大堆,現在接著來。
提起RTTI(runtime type identification,運行時間類型辨別),相信大家都很熟悉。C++的RTTI功能相當有限,主要由typeid和dynamic_cast提供[1]。至於這兩者的實現方式[2],不是我們今天的話題,我們所關注的,乃是VCL所提供的“高級”RTTI的底層機制。
熟悉框架的朋友都知道,框架往往會提供“高級”的RTTI功能。我曾看過一個論調,說Java和Object Pascal比C++好,原因是因為它們的RTTI更“高級”。且不論濫用RTTI極為有害,事實上,C++用宏(macro)亦可以模擬出相同功能的RTTI[3]。
不過對於VCL類來說,您清楚其RTTI機制的運作情況嗎?對於如下
class A: public TObject { ... } ... A* p = new A;為什麼p->ClassName();就能返回類A的名字“A”呢?
其實這都是編譯器暗箱操作的結果。說白了,編譯器先在某個地方把類名寫好,到時候去取出來就行。關鍵在於,如何去取出來呢?顯然有指針指向這些數據,那麼這些指針放在什麼地方呢?
記得《阿裡巴巴和四十大盜》的故事吧?寶藏是早就存在的,如果知道口訣“芝麻,開門吧”,就可以拿到寶藏。同樣,類的相關信息是編譯器幫我們寫好了的,我們所關心的,就是如何獲取這些信息的“口訣”。
不過這一切,要從虛函數開始,我們得先復習一下C/C++的對象模型。
C語言提供了基於對象(Object-Based)的思維模型,其對象模型非常清晰。比如
struct A { int i; char c; };
C++提供了面向對象(Object-Oriented)的思維模型,其對象模型建立在C的基礎上。對於沒有虛函數的類,其模型與C中的結構(struct)完全一樣。但如果存在虛函數,一般在類實體的某個部分會存在一個指針vptr,指向虛擬函數表VFT(Virtual Function Table)的入口。顯然,對於同一個類的所有對象,這個vptr都是相同的。例如
class A { private: int i; char c; public: virtual void f1(); virtual void f2(); }; class B: public A { public: virtual void f1(); virtual void f2(); };當我們作如下調用的時候
A* p; ... p->f2();程序本身並不知道它會調用A::f還是B::f或是其它函數,只是通過類實體中的vptr,查到VFT的入口,再在入口中查詢函數地址,進行調用。由於Borland C++編譯器把vptr放在類實體的頭部,因此下面均有此假設。
為了更充分地說明問題,我們從匯編級來分析一下。假設我們采用的是Borland C++編譯器。
p->f2();這句的匯編代碼是
mov eax,[ebp-0x04] push eax mov edx,[eax] call dword ptr [edx+0x04] pop ecx
圖3 C++類實體的內存布局
第一句ebp-0x04是指針變量p的地址,第一句是把p所指向的對象的地址傳送到eax;
第二句不用管它;
第三句是把對象頭部的指針vptr傳到edx,即已取得VFT的入口;
第四句是關鍵,edx再加4(32位系統上一個指針占4個字節),也就是調用了從VFT入口算起的第二個函數指針,即B::f2;
第五句不用管它。
相信大家對VFT和C++的對象模型有一個更深刻的認識吧?對於VFT的實現,各個編譯器是不一樣的。有興趣的朋友不妨可以自行探索一下Microsft Visual C++和GCC的實現方法,比較一下它們的異同。
知道了VFT的結構,那麼想想下面這個程序的結果是什麼。
#include我想您應該能理解其中*(void**)&a吧?這是取得vptr的值,也就是a所在內存using namespace std; class A { int c; virtual void f(); public: A(int v = 0) { c = v;} }; void main() { A a, b(20); cout << *(void**)&a << endl; cout << *(void**)&b << endl; }