有學友謝靈兵列一問題。有以下代碼,我刪除部分無關內容。
Code:
- #include <iostream>
- using namespace std;
- class base
- {
- public:
- virtual void funb1()
- {
- cout << "funb1 base called." << endl;
- }
-
- void funb2()
- {
- cout << "funb2 base called." << endl;
- }
- };
-
-
- class der : public base
- {
- public:
- void funb1()
- {
- cout << "funb1 dev called." << endl;
- }
-
- virtual //個人認為,這個virtual要去掉
- void funb2()
- {
- cout << "funb2 dev called." << endl;
- }
- };
-
-
- int main()
- {
- base b;
-
- der * pder = (der *)&b; //把一個基類對象,硬生生轉換為衍生類別對象
-
- pder->funb1();
- pder->funb2();
-
- return 0;
- }
說這是一面試題,問什麼呢?我沒看到,估計是:“上面的程式會有什麼行為?”
這個問題回答起來還真的會很累!
我說個以下幾點,請綜合考慮,但其中第一點是牢騷,建議跳過。
第一、將基類對象,強制轉換為衍生類別,在C++標準中,其行為是“未定義/undefined”,在C++中,什麼問題最可怕?就是未定義的行為最可怕。
所以,如果你非要說這是一道“筆試題”,那麼你得寫出,這道題問的是什麼啊? 只抄來一堆代碼不抄來問題,不少人會這樣,其實問題比代碼重要。如果這是我出的筆試題,則我的預留的答案就是三個字,誰能在答案上寫上“未定義”,就說明他平常看的C++書不少。這是我自己會的二等答案,一等答案是:根據常見編譯器及版本,詳細給出不同結果,這自然是牛人們才有本事的。
第二,說完標準,來說現實,
“現實中,誰會寫這樣不合情理的代碼呀?”這是你說的,不一定噢,那是你還沒碰上變態的項目……實話,在C++中是這類用法我感覺是比較少,但在Delphi,尤其是Delphi.net,這類用法可真不少。有時叫做“Helper”類。為什嗎?我們還是以C++來分析。
2.1 首先,請把第26行,der類中,funb2 前面的“virtual”刪除掉---我認為你可能是抄錯了吧?原因後面會說。
2.2 我們先來簡單說一下“虛表”的概念。首先看base,它沒有成員資料,所以似乎它可以被認為是C語言所喜歡的POD,但其實它是一個POD嗎?換句話說,你可以把一個base的對象,直接傳給純C的函數嗎?當然不行,因為它含有虛函數,而虛函數會在對象中插入一個“虛函數地址跳轉表/vtable”。
這就是C++單一的虛函數機製為人詬病的地方:只能犧牲空間,以贏得時間,不像類似delphi等語言,你可以選擇要空間還是要時間,這樣說沒冤枉C++標準,因為它根本沒規定虛函數該如何?。還好眾多編譯器都選擇了當前我們熟悉的vtable方式,並且“不約而同地”選擇了犧牲空間,贏得時間的方案,即:全都把vtable地址放到對象裡去了,這樣,手裡有一個對象的地址(比如:this)以後,要取得某個虛函數,只需加一次位移。要說省空間費時間的方法,是把虛表放在類裡,然後對象保留一個指向類的指標,對於C++,如果不想違反C++標準的天條,那麼,就只好想辦法在基類裡偷偷加待用資料了,就像MFC乾的一樣。這是另話,打住。
2.3 現在我們知道base其實是有個資料的。插一句話,如果base 真的有一個成員資料,那麼,虛表資料是放在成員資料之前還是之後?這太重要的,但可惜不同C++編譯器在這點上,這回不同了!C++ 帶有匯出類的動態庫,為什麼不存在二進位相容介面?為什麼VC寫的C++風格的DLL,BC調用不了?而C風格的DLL卻能通用?這就是罪責之一。
回到主題,由於base類並沒有成員資料,所以VTAB在前或在後,結果一個樣(就一個人在排隊,你說它是排在最前還是排在最後?)。不過還是要說一下,現在常見的編譯器,VTAB是放在實際成員資料前面的,注意,我說了“通常”。
虛表大致是一個“數組指標”的指標(沒認真推敲,所以大致),即在對象裡存一個地址再指向一張表(數組),這個地址。我們可以做這樣一個實驗:寫一個既有虛表,又有成員資料的類:
Code:
- struct Coo
- {
- int d; //特意將d放在最前面,但其實它前面還有vtable
-
- Coo ()
- : d(100)
- {}
-
- virtual void foo();
- };
-
- void Coo::foo()
- {}
-
- int main()
- {
- Coo o;
- int *p = (int *)&o;
-
- cout << *p << endl
- << o.d << endl;
-
- return 0;
- }
如果Coo::foo函數不是virtual,那麼螢幕輸出肯定是兩個100 (即d的值)。但現在呢?不是了。*p是一個大大的數字,其實就是vtable的記憶體位址。
說了半天,好像還沒有說到正題。別急,馬上來了:也就是說,當程式運行時,對於非虛成員函數的調用,是直接定址的(call XXXX),對於虛成員函數的調用,則需要先找到“vtable”地址,然後再從表中找到真正函數的地址(call XXXX1+XXXX2).對應到本題的base類的對象,則funb1函數需找到vtable後再進行跳轉,而funb2是直接跳轉到手上的地址。說到這裡,你應該能想到答案了,那你不用看後面了,後面還長啊。
2.4 前面說了,vtab其實是一個指標,它指向一個數組,並且數組裡的每個元素都是一個函數的地址。好,我們可以對Coo類的對象,“hack”得更徹底點:
Code:
- Coo o;
- int *p = (int *)&o;
-
- int addr = *(int *)(*p);
-
- cout << addr << endl
- << o.d << endl;
現在,螢幕輸出的addr,就是真真實實地,Coo::foo的地址了,如果你不信,你可以定義一個函數指標,再把addr(強制轉換以後)賦值給它,然後調用函數指標,就會執行(當成作業吧)。你說不可能,因為沒有this指標,如何調用成員函數?沒關係,死不了,只要foo函數裡,沒有碰到成員資料,它就和一個靜態成員函數,沒多大區別。如果foo內用到了成員怎麼辦?也可以實現,定義函數指標時,額外添加一個Coo* 參數:
Code:
- typedef void (* PFUNC)(Coo*);
我在本文最後面,再給出完整答案吧,免得節外生枝,我後悔回答這個問題了。
2.5 接下來,是派生的類的問題vtable問題。簡單地說:
首先,它會把基類的虛表完整地複製一份,就像派生普通的成員資料。(眾多編譯器作者再次高唱:我們的目標是:費空間省時間)。
假設我們有一個Coo2類,它派生自Coo,但它不增加任何新資料。(這裡任何是指:成員資料和虛表),則可以在前述代碼上,再做以下測試:
Code:
- struct Coo2 : public Coo
- {
- };
-
- Coo2 o2;
- p = (int *)(&o2);
- addr = *(int *)(*p);
- cout << addr << endl;
再次輸出的addr,和前面輸出的addr,完全一樣,它們都是Coo::foo的地址嘛!
(p是強制將o的地址轉換為一個整數指標,addr是將p所指向的內容,再強制當作是一個整數指標,然後再取出該指標指向的內容,就是函數的地址)
其次,如果衍生類別對基類某個虛函數,有重新實現。比如例中的der之於base,則有這下面的關係:
base 的虛表:
[base::funb1的地址]
der 的虛表:
[der::funb1的地址][base::funb1的地址]
可見,在der虛表中,der::funb1擠佔了基類同名函數的地址!就這就是答案了。
請看這行代碼:
Code:
- der * pder = (der *)&b;
pder被強制“看作”是一個衍生類別,但這個強制轉換有可能改變它所指向的b其實是一個base對象的事實嗎?有可能真了改變了b的記憶體布局嗎?當然都不可能。下面,我們把*pder的虛表真實記憶體布局和被“誤以為”的記憶體布局,排在一起:
真實的虛表:[base::funb1的地址]
愣裝的虛表:[der::funb1的地址][base::funb1的地址]
一切水落石出,當調用: pder->funb1(); 由於funb1是虛函數,所以到虛表裡去找,它找到了愣裝的“[der::funb1的地址]”,它很高興,但其實它找到的那個數字是“[base::funb1的地址]”。所以當然就是調用基類的函數了。
要不要再寫下去,可說的還很多,比如:
如果在der中寫一個函數,然後它調用base::funb1()會怎樣?答:會死!為什嗎?
如果如原題,沒有去掉der::funb2的virtual修飾,我們已經它會死,但為什麼會死?
如果base中含有成員資料呢?如果衍生類別中也含有成員資料呢?大家當成作業想吧。
最後說一句,這樣做法,有何用呢?只是在筆試時玩酷?
先問一下各位,如果手頭有類的定義及實現庫,但沒有實現的原始碼,有沒有辦法在類之外,訪問到它的私人成員(資料或函數),針對具體編譯器,肯定有辦法啊!就是那樣取對象地址,然後強制轉換,就可以訪問了嘛。
好,就是為了強制訪問私人資料嗎?也不一定,如果你正確回答了前面的三個如果,你就可以知道,我們可以通過這種方法來擴充一個類----擴充一個類難道不是“派生”嗎?當然是派生,但有候,會派生不了啊~~~因為一個對象已經原類庫中產生,並且new出該對象的代碼,我們已經無法個修改了,在這種“非法”需求下,C++程式員們就創造了這種方法,並把它叫做是“hacker/駭客類”。咦,前面不是說,Delphi裡,把它叫做“Helper/助手類”嗎?怎麼兩種語言的叫法差別這麼大?沒辦法,Delphi的程式員,通常比較善良(包括熊貓燒香的作者),而C++的程式員的文化,一直比較“黑”。
第三,也是最後,給出前面所問的,後面所說的“黑”的完整例子:
Code:
- #include <iostream>
-
- using namespace std;
-
- class Coo
- {
- public:
- Coo ()
- : d(100)
- {}
-
- private:
- virtual void foo(); //私人的!
- int d;
- };
-
- void Coo::foo()
- {
- cout << d << "~~~!!!!~~~~" << endl;
- }
-
- int main()
- {
- Coo o;
- int *p = (int *)(&o);
- int addr = *(int *)(*p);
-
- typedef void (* PFUNC)(Coo*);
- PFUNC pfunc = (PFUNC)(addr);
- pfunc(&o); //hack! 調用了私人成員函數
-
- return 0;
- }
半夜看到問題,半夜回答,文字或表述或有錯亂。代碼簡單地通過gcc編譯測試,無其它更多編譯器測試。
補充幾句:正如《白話C++》所說,學好C++語言,包括熟悉它的標準,那是“童子功”,但分析本題會發現:回答這個筆試題,幾乎不靠"C++標準"的知識,而是需要瞭解C++幕後的實現。我總覺得這像“旁門左道”,或在某些時候有特殊功力,但基本上以初學者不要去糾纏它。我特意看了謝同學的原貼,看到他果然是在費力地想用“C++標準”去委婉解釋。不客氣一點,他是在犯一個“有趣”的錯誤: 在知道了“答案”之後,然後開始用“標準”去套這個“答案”(這對於標準理解,有時會是反作用)。
-------------------------------------
如果您想與我交流,請點擊如下連結成為我的好友:
http://student.csdn.net/invite.php?u=112600&c=f635b3cf130f350c