標籤:

c++中虛析構函數如何實現多態的、內存布局如何?

如果是虛表的話,子類會覆蓋基類的析構函數,那基類的析構函數是不會調用的,基類的資源就無法釋放啊?虛析構函數放哪呢?謝謝


(1)虛函數,的多態,不是在構造和析構過程中的,總之,構造,析構過程中,因為虛函數表在動態切換,而不是從最開始就固定成最終的樣子,所以此期間,實際上沒有虛函數多態,不支持多態。(是指,構造,析構函數中,調用虛函數時,效果等價於該函數是非虛的;換一種說法,就是虛函數,子類提供的版本,在構造,析構父類時,對父類是不可見的)。

C++ 在這一點尤其可能和 C# 語言不同。

(2)一個類的構造函數和析構函數中,包含了對父類的構造函數,析構函數的調用。

所以構造和析構過程,也僅僅是調用某一個具體的構造,析構函數,而不是編譯器實現了多次調用!。所以你的理解是錯誤的。

(3)虛的析構函數,從對象實例出發,去定位到一個能夠正確析構它的析構函數。

所以主要是可以實現,可以 delete 一個父類指針,析構一個類型是子孫後代類型的實例。

即使你不知道是誰把它 new 出來的,也完全不知道它的實際類型是啥,這樣做都完全沒問題。這就是虛析構函數的帶來的收益。

如果析構函數不是 virtual 的,上面的析構就會形成,用一個父類的析構函數,去析構一個子類對象,那麼當然會出問題。

對於一個對象,具有多個父類時,父類指針和對象實例的起始地址之間,可能還會有地址偏差。如果靜態調用某個delete版本的析構函數,還會引發釋放內存時出錯。而動態調用析構函數,可以完成地址偏差調整,讓 this 指針指向正確的起始地址,然後跳轉到實際類型的析構函數。,

======

總體上就是上面我寫的比較匆忙的幾點,大概了解清楚了也就清楚了。下面我再多補充一點。

這裡先說下,函數調用的動態和靜態的概念,

(請注意我這裡沒有使用【綁定 / binding 】這個詞,因為函數綁定的概念,指的是:當 exe 載入某個 dll 時,loader 把函數地址填寫到 exe 的 IAT 中 ,這個過程稱為函數綁定 。編譯之後 exe 通常處於 IAT 未綁定狀態,因此這裡又可分為提前綁定,載入時綁定。這個過程發生在 exe 載入時刻,即進入入口點之前,在運行期間, IAT 已處於就緒狀態,成為一個不變化的常數表格。)

也就是說,如果一個函數,是編譯器在編譯你的代碼時,就認為是 fixed 的,(嚴格說是在鏈接時 fixed),那麼這個就叫做靜態。這意味著什麼呢,這意味著 call xxxx ,這裡的 xxxx 是一個「常數」,被寫到可執行文件中的。

如果函數調用,是在運行時,調用時,才推斷出具體是那個函數,叫做動態的。

這意味著 call xxxx,這個 xxxx 是從實例地址,得到 vftable,然後從 vftable [ 某個 index ] 取出來的。(vftable 相當於一個類型體現出的行為方式,每個實例,都有這樣一個「隱藏」的數據成員,指向一個表格的指針,表格裡面是一堆函數地址,即它的行為方式,只有被標記成 virtual 的函數,才會進入這個表格)。

虛函數多態就是後者(動態的)。

此外,這裡我說的構造函數和析構函數,都值得是編譯器的編譯結果版本。也就是說,程序員編寫的構造,析構函數,是內嵌於編譯器的編譯結果版本中的,是其「子集」。

注意,編譯器編譯時,會把它可以確定的信息,變成靜態的函數調用。

比如說,如果你在構造函數中,調用一個虛函數,或者被編譯器看到了你的構造函數的調用,編譯器有可能就把後續的虛函數調用,編譯成靜態調用。(即使你調用的函數是虛函數,但是因為編譯器認為,它能夠在編譯時確定,那他就沒必要走動態了)。

例如:Test() 是類 C1 的一個虛函數,如果有這樣一個函數:

void Func_01()
{
C1 c1;
c1.Test();
}

這時候,由於 c1 的構造過程是可見的,因此 c1.Test() 就可被靜態調用,體現在彙編代碼上就是:

ecx = c1; // 傳遞 this 指針給成員函數;

call C1::Test。//這裡 Test 雖然是虛函數,但是因為被編譯時靜態確定;

如果我們把這個函數修改一下,成這樣:

void Func_01(C1* pC1)
{
pC1-&>Test();
}

這時候 pC1-&>Test() 就一定是動態調用;即,從實例的虛函數表中獲取 Test 的地址。

這是因為 pC1 指向的對象,構造過程對於 Func_01 來說是未知的,所以必須走虛函數表。

所以如果你析構函數不是虛函數,那編譯器就會採用靜態調用。這樣,如果指針類型不是實際類型,當然會出問題。所以,在 c++ 中,析構函數應該一律定義成虛函數,你可以不理解它的原因,只需要把它當一條規矩套用。

那麼為什麼只對析構函數,特別提出要定義成虛函數,對構造函數不提這一點呢,很簡單,因為構造函數都是確定的,靜態的調用,調用構造函數時,對象還不存在呢,壓根也沒有動態這一說。

對於構造一個對象,c++代碼中這樣寫:

比如說有一個 class C1: public class P1;

P1 *p1 = new C1;

注意,這個過程,編譯器不是分別調用了 P1(), C1();

而是:

分配內存;

調用 C1();

就完了。

而 C1 的定義中,開頭就是調用 P1。(當然,這個調用是編譯器負責插入的,對於程序員來說,沒有看到這一點。)

這就是我第二點提到的。構造,析構函數,他們只負責嵌入對他們的直接父類的構造函數和析構函數的調用。例如 C1 中調用了 P1,那麼如果 P1 還有父類(C1的爺爺)的話,那麼P1的父類呢?當然同理,由 P1 負責調用了~。析構函數同理。

最後再強調下,具體類型的虛函數表,都是靜態的,可以這樣理解,每個類型,都有一份獨立的自己的虛函數表,比如說父類和子類各有自己的虛函數表,表實體的內容可以有相同的部分,但彼此不搭嘎(所以也談不上什麼覆蓋,或者說,一定要提覆蓋,相當於可以理解成把父類的虛函數表 copy 了一個副本,然後在這個副本基礎上加工修改成子類的版本),表實體都是被寫在只讀數據段裡面的。(對象頭部用一個比如說 4 byte 大小的指針,指向表實體)(一個類型的多個實例,同享相同的虛函數表,也就是說他們的 vftable 指針的值相同)。構造函數,析構函數的開始部分,都會對 vftable 指針進行賦值。這就是我第一點裡提到的, vftable 在構造,析構過程中,在動態變化的。

========

最後講講構造函數和析構函數;

(1)構造函數 constructor:注意--不包含分配內存。包含以下:

1.1,設置虛函數表指針;

1.2,調用所有直接父類的構造函數;

1.3,構造作為成員的對象;(一個類中定義了其他類的實例,例如定義一個成員 string m_str;)

1.4,嵌入用戶編寫的構造函數;

備註:當 new 一個對象時,編譯期首先調用 operator new 分配內存,然後調用該構造函數。

(2)析構函數 destructor :注意--不包含釋放內存。包含以下:

2.1,設置虛函數表指針;

2.2,嵌入用戶編寫的析構函數;

2.3,析構作為成員的對象;

2.4,調用所有直接父類的析構函數;

備註:如果用戶在棧上創建了一個對象,因為不需要 delete,所以編譯期在離開函數前,調用該析構函數。

(3)釋放內存的析構函數 delete_destructor:一個包裝了以下調用的函數:

3.1,調用 destructor;

3.2,調用 operator delete; (釋放內存)

備註:如果用戶定義的析構函數是虛的,則這個函數,被放在虛函數表中(通常是首個元素)。

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

虛函數表的生成和「覆蓋」,

對應的是繼承樹上一個類似鏈表一樣的樹枝,每一深度上,只有一個節點。(如果具有多個父類,則同一個深度上有多個節點,每個節點,會對應於新的獨立的虛函數表。)

因此,樹枝形如:(祖先)N1 &<- N2 &<- N3 &<- ...&<- N [ i ] &<- ...(後代)

N [ i+1] 可以擴展 N [ i ] 的虛函數表,就是把 Ni 中沒有的虛函數,寫在虛函數表的尾部;

對於 N [ i + 1] 提供的 N [ i ] 已有的虛函數實現,改寫 vftable 的對應元素。

因此,對於用一個指針指向的對象來說,調用其虛函數時,例如 pC1-&>Test();

編譯時,可以不知道具體對象在上面的繼承樹枝中位於什麼深度,但是可以明確的知道 Test 是 該樹枝對應的 vftable 的例如第二個元素,則調用時,實際上是調用:

call ecx -&> vftable [ 2 ];

對於彙編代碼來說,比如說每個函數指針可能佔用 4 bytes,那麼彙編代碼就是:

根據 ecx 取到 vftable 的地址,假設 Test 是第二個元素,則把該地址 + 8,得到第二個元素,然後把該位置的值取出來調用。


看 深度探索C加加對象模型


會調用~~~虛表裡存的是析構函數地址~~虛析構函數當然放代碼區~所謂覆蓋也只是覆蓋地址~~~子類析構時當然會析構父類~與析構函數是否為虛無關


特例處理一下不就好了,比如全局地放置一個從子類到基類析構函數的鏈表。。。


推薦閱讀:

求一本或者幾本比較詳細介紹C++11的書,《C++ Primer》是否合適?
看見網上說學單片機有助於c++的學習,是這樣的嗎?

TAG:C | CC | 虛函數表 |