當通過指標訪問類的成員函式時:
如果該函式是非虛函式,那麼編譯器會根據指標的型別找到該函式;也就是說,指標是哪個類的型別就呼叫哪個類的函式。
如果該函式是虛函式,並且派生類有同名的函式遮蔽它,那麼編譯器會根據指標的指向找到該函式;也就是說,指標指向的物件屬於哪個類就呼叫哪個類的函式。這就是多型。
編譯器之所以能通過指標指向的物件找到虛函式,是因為在建立物件時額外地增加了虛函式表。
如果乙個類包含了虛函式,那麼在建立該類的物件時就會額外地增加乙個陣列,陣列中的每乙個元素都是虛函式的入口位址。不過陣列和物件是分開儲存的,為了將物件和陣列關聯起來,編譯器還要在物件中安插乙個指標,指向陣列的起始位置。這裡的陣列就是虛函式表(virtual function table),簡寫為vtable。
我們以下面的繼承關係為例進行講解:
#include
#include
using
namespace std;
//people類
class
people
;people::
people
(string name,
int age)
:m_name
(name)
,m_age
(age)
void people::
display()
void people::
eating()
//student類
class
student
:public people
;student::
student
(string name,
int age,
float score)
:people
(name, age)
,m_score
(score)
void student::
display()
void student::
examing()
//senior類
class
senior
:public student
;senior::
senior
(string name,
int age,
float score,
bool hasjob)
:student
(name, age, score)
,m_hasjob
(hasjob)
void senior::
display()
else
}void senior::
partying()
intmain()
執行結果:
class people:趙紅今年29歲了。
class student:王剛今年16歲了,考了84.5分。
class senior:李智以92的成績從大學畢業了,並且順利找到了工作,ta今年22歲。
圖中左半部分是物件占用的記憶體,右半部分是虛函式表 vtable。在物件的開頭位置有乙個指標 vfptr,指向虛函式表,並且這個指標始終位於物件的開頭位置。
仔細觀察虛函式表,可以發現基類的虛函式在 vtable 中的索引(下標)是固定的,不會隨著繼承層次的增加而改變,派生類新增的虛函式放在 vtable 的最後。如果派生類有同名的虛函式遮蔽(覆蓋)了基類的虛函式,那麼將使用派生類的虛函式替換基類的虛函式,這樣具有遮蔽關係的虛函式在 vtable 中只會出現一次。
當通過指標呼叫虛函式時,先根據指標找到 vfptr,再根據 vfptr 找到虛函式的入口位址。以虛函式 display() 為例,它在 vtable 中的索引為 0,通過 p 呼叫時:
p -> display();
編譯器內部會發生類似下面的轉換:
( *( *(p+0) + 0 ) )§;
下面我們一步一步來分析這個表示式:
0是 vfptr 在物件中的偏移,p+0是 vfptr 的位址;
(p+0)是 vfptr 的值,而 vfptr 是指向 vtable 的指標,所以(p+0)也就是 vtable 的位址;
display() 在 vtable 中的索引(下標)是 0,所以( *(p+0) + 0 )也就是 display() 的位址;
知道了 display() 的位址,( *( *(p+0) + 0 ) )§也就是對 display() 的呼叫了,這裡的 p 就是傳遞的實參,它會賦值給 this 指標。
可以看到,轉換後的表示式是固定的,只要呼叫 display() 函式,不管它是哪個類的,都會使用這個表示式。換句話說,編譯器不管 p 指向**,一律轉換為相同的表示式。
轉換後的表示式沒有用到與 p 的型別有關的資訊,只要知道 p 的指向就可以呼叫函式,這跟名字編碼(name mangling)演算法有著本質上的區別。
再來看一下 eating() 函式,它在 vtable 中的索引為 1,通過 p 呼叫時:
p -> eating();
編譯器內部會發生類似下面的轉換:
( *( *(p+0) + 1 ) )§;
對於不同的虛函式,僅僅改變索引(下標)即可。
從儲存空間角度,虛函式對應乙個指向vtable虛函式表的指標,這大家都知道,可是這個指向vtable的指標其實是儲存在物件的記憶體空間的。問題出來了,如果建構函式是虛的,就需要通過 vtable來呼叫,可是物件還沒有例項化,也就是記憶體空間還沒有,怎麼找vtable呢?所以建構函式不能是虛函式。
從使用角度,虛函式主要用於在資訊不全的情況下,能使過載的函式得到對應的呼叫。建構函式本身就是要初始化例項,那使用虛函式也沒有實際意義呀。所以建構函式沒有必要是虛函式。虛函式的作用在於通過父類的指標或者引用來呼叫它的時候能夠變成呼叫子類的那個成員函式。而建構函式是在建立物件時自動呼叫的,不可能通過父類的指標或者引用去呼叫,因此也就規定建構函式不能是虛函式。
建構函式不需要是虛函式,也不允許是虛函式,因為建立乙個物件時我們總是要明確指定物件的型別,儘管我們可能通過實驗室的基類的指標或引用去訪問它但析構卻不一定,我們往往通過基類的指標來銷毀物件。這時候如果析構函式不是虛函式,就不能正確識別物件型別從而不能正確呼叫析構函式。
先看一段在建構函式中直接呼叫虛函式的**:
#include
class
base
///< 列印 1
virtual
void
foo()}
;class
derive
:public base
~derive()
virtual
void
foo(
)private
:int
* m_pdata;};
intmain()
這裡的結果將列印:1。
這表明第6行執行的的是base::foo()而不是derive::foo(),也就是說:虛函式在建構函式中「不起作用」。為什麼?
當例項化乙個派生類物件時,首先進行基類部分的構造,然後再進行派生類部分的構造。即建立derive物件時,會先呼叫base的建構函式,再呼叫derive的建構函式。
當在構造基類部分時,派生類還沒被完全建立,從某種意義上講此時它只是個基類物件。即當base::base()執行時derive物件還沒被完全建立,此時它被當成乙個base物件,而不是derive物件,因此foo繫結的是base的foo。
c++之所以這樣設計是為了減少錯誤和bug的出現。假設在建構函式中虛函式仍然「生效」,即base::base()中的foo();所呼叫的是derive::foo()。當base::base()被呼叫時派生類中的資料m_pdata還未被正確初始化,這時執行derive::foo()將導致程式對乙個未初始化的位址解引用,得到的結果是不可預料的,甚至是程式崩潰(訪問非法記憶體)。
總結來說:基類部分在派生類部分之前被構造,當基類建構函式執行時派生類中的資料成員還沒被初始化。如果基類建構函式中的虛函式呼叫被解析成呼叫派生類的虛函式,而派生類的虛函式中又訪問到未初始化的派生類資料,將導致程式出現一些未定義行為和bug。
C 多型的相關問題(1)
析構函式設定為虛函式的原因?繼承類物件經由乙個基類指標被刪除,若基類是非虛的析構函式,則只能呼叫基類的析構函式,則繼承類新增的資料部分沒有被銷毀,造成資源洩漏,敗壞資料結構。當乙個類不背當作基類 或者不具有多型性時,令其析構函式是虛函式是多餘的,浪費記憶體。建構函式不能時虛函式的原因?因為如果子類中...
C 多型繼承相關面試題
一 相關概念 類的編譯順序 類名 成員名 成員方法體 類的構造順序 成員物件 類物件 子類的構造 父類 子類 子類的析構 子類 父類 過載 函式名相同 引數列表不同 作用域相同 隱藏 子類隱藏父類中同名的成員方法 覆蓋 子類覆蓋父類中相同的許成員方法 動多型 繼承中的多型 執行時期決定的多型 靜多型...
多型的相關總結
當不同的物件呼叫相同的名稱的成員函式的,可能引起不同的行為 即執行不同的 這種現象就稱為多型。將函式呼叫鏈結相應函式體的 過程稱為函式聯編。分為動態聯編和靜態聯編。靜態聯編,不同的類可以有相同名稱的函式,這種在編譯期間進行的聯編稱為靜態聯編。靜態聯編所支援的多型性就是編譯時多型性。函式過載就屬於編譯...