C++中的虛函數的作用主要是實現了多態的機制。關于多態,簡而言之就是用父類型別的指針指向其子類的實例,然后通過父類的指針調用實際子類的成員函數。這種技術可以讓父類的指針有“多種形態”,這是一種泛型技術。所謂泛型技術,說白了就是試圖使用不變的代碼來實現可變的算法。比如:模板技術,RTTI技術,虛函數技術,要么是試圖做到在編譯時決議,要么試圖做到運行時決議。
關于虛函數的使用方法,我在這里不做過多的闡述。大家可以看看相關的C++的書籍。在這篇文章中,我只想從虛函數的實現機制上面為大家 一個清晰的剖析。
當然,相同的文章在網上也出現過一些了,但我總感覺這些文章不是很容易閱讀,大段大段的代碼,沒有圖片,沒有詳細的說明,沒有比較,沒有舉一反三。不利于學習和閱讀,所以這是我想寫下這篇文章的原因。也希望大家多給我提意見。
言歸正傳,讓我們一起進入虛函數的世界。
虛函數表
對C++ 了解的人都應該知道虛函數(Virtual Function)是通過一張虛函數表(Virtual Table)來實現的。簡稱為V-Table。 在這個表中,主是要一個類的虛函數的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應實際的函數。這樣,在有虛函數的類的實例中這個表被分配在了 這個實例的內存中,所以,當我們用父類的指針來操作一個子類的時候,這張虛函數表就顯得由為重要了,它就像一個地圖一樣,指明了實際所應該調用的函數。
這里我們著重看一下這張虛函數表。在C++的標準規格說明書中說到,編譯器必需要保證虛函數表的指針存在于對象實例中最前面的位置(這是為了保證正確取到虛函數的偏移量)。 這意味著我們通過對象實例的地址得到這張虛函數表,然后就可以遍歷其中函數指針,并調用相應的函數。
聽我扯了那么多,我可以感覺出來你現在可能比以前更加暈頭轉向了。 沒關系,下面就是實際的例子,相信聰明的你一看就明白了。
假設我們有這樣的一個類:
class Base {
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
virtual void h() { cout << "Base::h" << endl; }
};
按照上面的說法,我們可以通過Base的實例來得到虛函數表。 下面是實際例程:
typedef void(*Fun)(void);
Base b;
Fun pFun = NULL;
cout << "虛函數表地址:" << (int*)(&b) << endl;
cout << "虛函數表 — 第一個函數地址:" << (int*)*(int*)(&b) << endl;
// Invoke the first virtual function
pFun = (Fun)*((int*)*(int*)(&b));
pFun();
實際運行經果如下:(Windows XP+VS2003, Linux 2.6.22 + GCC 4.1.3)
虛函數表地址:0012FED4
虛函數表 — 第一個函數地址:0044F148
Base::f
通過這個示例,我們可以看到,我們可以通過強行把&b轉成int *,取得虛函數表的地址,然后,再次取址就可以得到第一個虛函數的地址了,也就是Base::f(),這在上面的程序中得到了驗證(把int* 強制轉成了函數指針)。通過這個示例,我們就可以知道如果要調用Base::g()和Base::h(),其代碼如下:
(Fun)*((int*)*(int*)(&b)+0); // Base::f()
(Fun)*((int*)*(int*)(&b)+1); // Base::g()
(Fun)*((int*)*(int*)(&b)+2); // Base::h()
這個時候你應該懂了吧。什么?還是有點暈。也是,這樣的代碼看著太亂了。沒問題,讓我畫個圖解釋一下。如下所示:
注意:在上面這個圖中,我在虛函數表的最后多加了一個結點,這是虛函數表的結束結點,就像字符串的結束符“\0”一樣,其標志了虛函數表的結束。這個結束標志的值在不同的編譯器下是不同的。在WinXP+VS2003下,這個值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,這個值是如果1,表示還有下一個虛函數表,如果值是0,表示是最后一個虛函數表。
下面,我將分別說明“無覆蓋”和“有覆蓋”時的虛函數表的樣子。沒有覆蓋父類的虛函數是毫無意義的。我之所以要講述沒有覆蓋的情況,主要目的是為了給一個對比。在比較之下,我們可以更加清楚地知道其內部的具體實現。
一般繼承(無虛函數覆蓋)
下面,再讓我們來看看繼承時的虛函數表是什么樣的。假設有如下所示的一個繼承關系:
請注意,在這個繼承關系中,子類沒有重載任何父類的函數。那么,在派生類的實例中,其虛函數表如下所示:
對于實例:Derive d; 的虛函數表如下:
我們可以看到下面幾點:
1)虛函數按照其聲明順序放于表中。
2)父類的虛函數在子類的虛函數前面。
我相信聰明的你一定可以參考前面的那個程序,來編寫一段程序來驗證。
一般繼承(有虛函數覆蓋)
覆蓋父類的虛函數是很顯然的事情,不然,虛函數就變得毫無意義。下面,我們來看一下,如果子類中有虛函數重載了父類的虛函數,會是一個什么樣子?假設,我們有下面這樣的一個繼承關系。
為了讓大家看到被繼承過后的效果,在這個類的設計中,我只覆蓋了父類的一個函數:f()。那么,對于派生類的實例,其虛函數表會是下面的一個樣子:
我們從表中可以看到下面幾點,
1)覆蓋的f()函數被放到了虛表中原來父類虛函數的位置。
2)沒有被覆蓋的函數依舊。
這樣,我們就可以看到對于下面這樣的程序,
Base *b = new Derive();
b->f();
由b所指的內存中的虛函數表的f()的位置已經被Derive::f()函數地址所取代,于是在實際調用發生時,是Derive::f()被調用了。這就實現了多態。
多重繼承(無虛函數覆蓋)
下面,再讓我們來看看多重繼承中的情況,假設有下面這樣一個類的繼承關系。注意:子類并沒有覆蓋父類的函數。
對于子類實例中的虛函數表,是下面這個樣子:
我們可以看到:
1) 每個父類都有自己的虛表。
2) 子類的成員函數被放到了第一個父類的表中。(所謂的第一個父類是按照聲明順序來判斷的)
這樣做就是為了解決不同的父類類型的指針指向同一個子類實例,而能夠調用到實際的函數。
多重繼承(有虛函數覆蓋)
下面我們再來看看,如果發生虛函數覆蓋的情況。
下圖中,我們在子類中覆蓋了父類的f()函數。
下面是對于子類實例中的虛函數表的圖:
我們可以看見,三個父類虛函數表中的f()的位置被替換成了子類的函數指針。這樣,我們就可以任一靜態類型的父類來指向子類,并調用子類的f()了。如:
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()
安全性
每次寫C++的文章,總免不了要批判一下C++。這篇文章也不例外。通過上面的講述,相信我們對虛函數表有一個比較細致的了解了。水可載舟,亦可覆舟。下面,讓我們來看看我們可以用虛函數表來干點什么壞事吧。
一、通過父類型的指針訪問子類自己的虛函數
我們知道,子類沒有重載父類的虛函數是一件毫無意義的事情。因為多態也是要基于函數重載的。雖然在上面的圖中我們可以看到Base1的虛表中有Derive的虛函數,但我們根本不可能使用下面的語句來調用子類的自有虛函數:
Base1 *b1 = new Derive();
b1->f1(); //編譯出錯
任何妄圖使用父類指針想調用子類中的未覆蓋父類的成員函數的行為都會被編譯器視為非法,所以,這樣的程序根本無法編譯通過。但在運行時,我們可以通過指針的方式訪問虛函數表來達到違反C++語義的行為。(關于這方面的嘗試,通過閱讀后面附錄的代碼,相信你可以做到這一點)
二、訪問non-public的虛函數
另外,如果父類的虛函數是private或是protected的,但這些非public的虛函數同樣會存在于虛函數表中,所以,我們同樣可以使用訪問虛函數表的方式來訪問這些non-public的虛函數,這是很容易做到的。
如:
class Base {
private:
virtual void f() { cout << "Base::f" << endl; }
};
class Derive : public Base{
};
typedef void(*Fun)(void);
void main() {
Derive d;
Fun pFun = (Fun)*((int*)*(int*)(&d)+0);
pFun();
}
結束語
C++這門語言是一門Magic的語言,對于程序員來說,我們似乎永遠摸不清楚這門語言背著我們在干了什么。需要熟悉這門語言,我們就必需要了解C++里面的那些東西,需要去了解C++中那些危險的東西。不然,這是一種搬起石頭砸自己腳的編程語言。
[轉載內容]
By zieckey (http://blog.chinaunix.net/u/16292/)
C++中的虛函數的作用主要是實現了多態的機制。關于多態,簡而言之就是用父類型別的指針指向其子類的實例,然后通過父類的指針調用實際子類的成員函數。這種技術可以讓父類的指針有“多種形態”,這是一種泛型技術。所謂泛型技術,說白了就是試圖使用不變的代碼來實現可變的算法。比如:模板技術,RTTI技術,虛函數技術,要么是試圖做到在編譯時決議,要么試圖做到運行時決議。
對C++ 了解的人都應該知道虛函數(Virtual Function)是通過一張虛函數表(Virtual Table)來實現的。簡稱為V-Table。在這個表中,主是要一個類的虛函數的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應實際的函數。這樣,在有虛函數的類的實例中這個表被分配在了這個實例的內存中,所以,當我們用父類的指針來操作一個子類的時候,這張虛函數表就顯得由為重要了,它就像一個地圖一樣,指明了實際所應該調用的函數。
這里我們著重看一下這張虛函數表。在C++的標準規格說明書中說到,編譯器必需要保證虛函數表的指針存在于對象實例中最前面的位置(這是為了保證正確取到虛函數的偏移量)。 這意味著我們通過對象實例的地址得到這張虛函數表,然后就可以遍歷其中函數指針,并調用相應的函數。
假設我們有這樣的一個類:
按照上面的說法,我們可以通過Base的實例來得到虛函數表。 下面是實際例程:
運行結果:
下面來解釋一下程序中比較費解的表達式。
a、printf("虛函數表地址:%p\n", (int*)( *(int*)b ) );
這一句,b是一個Base類型的指針,(int*)b把這個指針自身所在的內存地址取出來了,*(int*)b把這個地址的內容的一個4字節數據取出來了,這個4B數據本身又是一個地址,所以做了(int*)的強制轉換,就是(int*)( *(int*)b )了。
這里注意“*(int*)b” 與“*b”的不同,b是一個Base類型的指針,同時也是一個地址,那么 *b 就代表了一個Base類型的變量了,而“*(int*)b”卻只是把b這個地址的一個4字節數據取出來了。
b、printf("虛函數表第二個地址(該地址內的 內容為第二個函數的地址):%p\n", (int*)(*(int*)b) +1 );
“(int*)(*(int*)b) +1”這個有上面的解釋可知是在“(int*)(*(int*)b) ”地址基礎上,增加4B偏移量,那么很自然的該地址的內容就是第二個虛函數的首地址。
c、Fun pFun = (Fun)*( (int*)(*(int*)b)+1 );
前面typedef已經處已經給出了說明, (Fun)*( (int*)(*(int*)b)+1 )實際上是把地址 “*( (int*)(*(int*)b)+1 )”強制性轉換為一個函數的入口地址,該函數無型參返回void。
同過這幾點的解釋,這個程序看懂應該沒有問題了。
也許你不太相信程序運行的結果,沒關系,一開始我也不敢確定是否正確,這里我們可通過GDB調試看看內存就知道了:
[root@localhost src]# g++ virtualTable.cpp -g
[root@localhost src]# gdb a.out
GNU gdb Red Hat Linux (6.6-8.fc7rh)
Copyright (C) 2006 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB. Type "show warranty" for details.
This GDB was configured as "i386-redhat-linux-gnu"
Using host libthread_db library "/lib/libthread_db.so.1".
(gdb) b main
Breakpoint 1 at 0x8048566: file virtualTable.cpp, line 22.
(gdb) r
Starting program: /mnt/study/unix/document/C_CPP_Programming/src/a.out

Breakpoint 1, main () at virtualTable.cpp:22
22 Base *b = new Base();
(gdb) n
23 printf("Base對象b的地址:%p\n", b );//b為Base類的一個實例對象的首地址,此地址開始的四字節的內容存放的是虛函數表的地址
(gdb)
Base對象b的地址:0x8f6c008
25 printf("虛函數表地址:%p\n", (int*)( *(int*)b) );
(gdb)
虛函數表地址:0x80489c8
27 printf("虛函數表第一個地址(該地址內的 內容為第一個函數的地址):%p\n", (int*)(*(int*)b) );//此處實際上就是虛函數表的首地址
(gdb) x/1aw 0x8f6c008 ======>這一命令打印出對象b的首地址‘0x8f6c008’的內容,是虛函數表的地址,與上面由程序打印的虛函數表地址一致
0x8f6c008: 0x80489c8 <_ZTV4Base+8>
(gdb) n
虛函數表第一個地址(該地址內的 內容為第一個函數的地址):0x80489c8
28 printf("虛函數表第二個地址(該地址內的 內容為第二個函數的地址):%p\n", (int*)(*(int*)b) +1 );
(gdb) n
虛函數表第二個地址(該地址內的 內容為第二個函數的地址):0x80489cc
29 printf("虛函數表第三個地址(該地址內的 內容為第三個函數的地址):%p\n", (int*)(*(int*)b) +2 );
(gdb) n
虛函數表第三個地址(該地址內的 內容為第三個函數的地址):0x80489d0
31 printf("虛函數表 ——第一個函數地址:%p\n", (int*)*((int*)(*(int*)b)) );
(gdb) x/3aw 0x80489c8 ======>這一命令打印出虛函數首地址‘0x80489c8’開始的以4字節為單位的三個單位的內存里的內容,正好與下面三個虛函數的地址一致
0x80489c8 <_ZTV4Base+8>: 0x80486e8 <_ZN4Base1aEv> 0x80486d4 <_ZN4Base1bEv> 0x80486c0 <_ZN4Base1cEv>
(gdb) n
虛函數表 ——第一個函數地址:0x80486e8
32 printf("虛函數表 ——第二個函數地址:%p\n", (int*)*((int*)(*(int*)b) +1) );
(gdb)
虛函數表 ——第二個函數地址:0x80486d4
33 printf("虛函數表 ——第三個函數地址:%p\n", (int*)*((int*)(*(int*)b) +2) );
(gdb)
虛函數表 ——第三個函數地址:0x80486c0
35 Fun pFun = (Fun)*( (int*)(*(int*)b)+1 );
(gdb) c
Continuing.
Base::b() ======>這里的通過虛函數的地址,用函數指針的方式訪問虛函數,得到的結果正常,表明上述虛函數地址沒有錯誤。
Base::c()

Program exited normally.
(gdb)
|
參考文檔:
http://blog.csdn.net/haoel/archive/2007/12/18/1948051.aspx
http://blog.chinaunix.net/u/16292/showart_673980.html
補充說明:gdb命令
x/3aw 0x80489c8
表示從內存地址 0x80489c8 讀取內容,
w表示以四字節為一個單位,
3表示連續讀取三個單位,
a表示按十進制顯示
具體可以參考: http://fanqiang.chinaunix.net/program/other/2005-03-23/2993.shtml