C++語言的“駭客類”行為簡析

來源:互聯網
上載者:User

有學友謝靈兵列一問題。有以下代碼,我刪除部分無關內容。

Code:
  1. #include <iostream>  
  2. using namespace std;  
  3. class base  
  4. {  
  5. public:  
  6.     virtual void funb1()  
  7.     {  
  8.         cout << "funb1 base called." << endl;  
  9.     }  
  10.   
  11.     void funb2()  
  12.     {  
  13.         cout << "funb2 base called." << endl;  
  14.     }  
  15. };  
  16.   
  17.   
  18. class der : public base  
  19. {  
  20. public:  
  21.     void funb1()   
  22.     {  
  23.         cout << "funb1 dev called." << endl;  
  24.     }  
  25.   
  26.     virtual //個人認為,這個virtual要去掉  
  27.     void funb2()   
  28.     {  
  29.         cout << "funb2 dev called." << endl;  
  30.     }  
  31. };  
  32.   
  33.   
  34. int main()  
  35. {  
  36.     base b;  
  37.   
  38.     der * pder = (der *)&b; //把一個基類對象,硬生生轉換為衍生類別對象  
  39.   
  40.     pder->funb1();   
  41.     pder->funb2();   
  42.       
  43.     return 0;  
  44. }  

說這是一面試題,問什麼呢?我沒看到,估計是:“上面的程式會有什麼行為?”

這個問題回答起來還真的會很累!

我說個以下幾點,請綜合考慮,但其中第一點是牢騷,建議跳過。

第一、將基類對象,強制轉換為衍生類別,在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:
  1. struct Coo  
  2. {  
  3.     int d; //特意將d放在最前面,但其實它前面還有vtable  
  4.   
  5.     Coo ()  
  6.         : d(100)  
  7.     {}  
  8.       
  9.    virtual void foo();  
  10. };  
  11.   
  12. void Coo::foo()  
  13. {}  
  14.   
  15. int main()  
  16. {  
  17.     Coo o;  
  18.     int *p = (int *)&o;  
  19.       
  20.     cout << *p << endl  
  21.         << o.d << endl;  
  22.       
  23.     return 0;  
  24. }  

    如果Coo::foo函數不是virtual,那麼螢幕輸出肯定是兩個100 (即d的值)。但現在呢?不是了。*p是一個大大的數字,其實就是vtable的記憶體位址。

   說了半天,好像還沒有說到正題。別急,馬上來了:也就是說,當程式運行時,對於非虛成員函數的調用,是直接定址的(call XXXX),對於虛成員函數的調用,則需要先找到“vtable”地址,然後再從表中找到真正函數的地址(call XXXX1+XXXX2).對應到本題的base類的對象,則funb1函數需找到vtable後再進行跳轉,而funb2是直接跳轉到手上的地址。說到這裡,你應該能想到答案了,那你不用看後面了,後面還長啊。

   2.4 前面說了,vtab其實是一個指標,它指向一個數組,並且數組裡的每個元素都是一個函數的地址。好,我們可以對Coo類的對象,“hack”得更徹底點:

Code:
  1. Coo o;  
  2. int *p = (int *)&o;  
  3.   
  4. int addr = *(int *)(*p);      
  5.   
  6. cout << addr << endl  
  7.     << o.d << endl;  

   現在,螢幕輸出的addr,就是真真實實地,Coo::foo的地址了,如果你不信,你可以定義一個函數指標,再把addr(強制轉換以後)賦值給它,然後調用函數指標,就會執行(當成作業吧)。你說不可能,因為沒有this指標,如何調用成員函數?沒關係,死不了,只要foo函數裡,沒有碰到成員資料,它就和一個靜態成員函數,沒多大區別。如果foo內用到了成員怎麼辦?也可以實現,定義函數指標時,額外添加一個Coo* 參數:

Code:
  1. typedef void (* PFUNC)(Coo*);   

   我在本文最後面,再給出完整答案吧,免得節外生枝,我後悔回答這個問題了。

   2.5 接下來,是派生的類的問題vtable問題。簡單地說:
   首先,它會把基類的虛表完整地複製一份,就像派生普通的成員資料。(眾多編譯器作者再次高唱:我們的目標是:費空間省時間)。

   假設我們有一個Coo2類,它派生自Coo,但它不增加任何新資料。(這裡任何是指:成員資料和虛表),則可以在前述代碼上,再做以下測試:
 

Code:
  1. struct Coo2 : public Coo  
  2. {  
  3. };  
  4.   
  5.   Coo2 o2;  
  6.   p = (int *)(&o2);  
  7.   addr = *(int *)(*p);  
  8.   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:
  1. 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:
  1. #include <iostream>  
  2.   
  3. using namespace std;  
  4.   
  5. class Coo  
  6. {  
  7. public:  
  8.     Coo ()  
  9.         : d(100)  
  10.     {}  
  11.       
  12. private:  
  13.     virtual void foo();   //私人的!  
  14.     int d;  
  15. };  
  16.   
  17. void Coo::foo()  
  18. {  
  19.     cout << d << "~~~!!!!~~~~" << endl;  
  20. }  
  21.   
  22. int main()  
  23. {      
  24.     Coo o;  
  25.     int *p = (int *)(&o);  
  26.     int addr = *(int *)(*p);      
  27.           
  28.     typedef void (* PFUNC)(Coo*);  
  29.     PFUNC pfunc = (PFUNC)(addr);  
  30.     pfunc(&o); //hack! 調用了私人成員函數  
  31.               
  32.     return 0;  
  33. }  

   半夜看到問題,半夜回答,文字或表述或有錯亂。代碼簡單地通過gcc編譯測試,無其它更多編譯器測試。

 

   補充幾句:正如《白話C++》所說,學好C++語言,包括熟悉它的標準,那是“童子功”,但分析本題會發現:回答這個筆試題,幾乎不靠"C++標準"的知識,而是需要瞭解C++幕後的實現。我總覺得這像“旁門左道”,或在某些時候有特殊功力,但基本上以初學者不要去糾纏它。我特意看了謝同學的原貼,看到他果然是在費力地想用“C++標準”去委婉解釋。不客氣一點,他是在犯一個“有趣”的錯誤: 在知道了“答案”之後,然後開始用“標準”去套這個“答案”(這對於標準理解,有時會是反作用)。

-------------------------------------

如果您想與我交流,請點擊如下連結成為我的好友:
http://student.csdn.net/invite.php?u=112600&c=f635b3cf130f350c

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.