C++多型(上)——虛擬函式、虛表

pg_dog發表於2017-04-18

OOP的核心思想是多型性(polymorphism)。其含義是“多種形態”。我們把具有繼承關係的多個型別稱為多型型別。引用或指標的靜態型別動態型別不同這一事實正是C++語言支援多型性的根本所在。

多型性:當用於物件導向程式設計的範疇時,多型性的含義是指程式能通過引用或指標的動態型別來獲取型別特定行為的能力。

多型性在C++中是通過虛擬函式來實現的。

首先我們先來強調幾組概念:
靜態多型和動態多型、靜態聯編和動態聯編、靜態型別和動態型別

1. 靜態型別和動態型別

靜態型別——在編譯時是已知的,它是在變數宣告時的型別或表示式生成的型別。
動態型別——物件在執行時的型別。引用所引物件或指標所指物件的動態型別可能與該引用或指標的靜態型別不同。基類的指標或引用可以指向一個派生類物件,在這種情況下,靜態型別是基類的引用(或指標),而動態型別是派生類的引用(或指標)。

Devese d;
Base *pb = &d;  //靜態型別是基類的指標,動態型別是派生類的指標

2. 靜態聯編和動態聯編

靜態聯編——也稱靜態繫結或早期聯編,比如之前提到的函式過載,運算子過載。它是在編譯過程彙總進行的聯編。
動態聯編——也稱動態繫結,直到執行時才知道到底呼叫函式的哪個版本。在C++語言中,動態繫結的意思是在執行時根據引用或指標所繫結物件的實際型別來選擇執行虛擬函式的某一個版本。

3. 靜態多型和動態多型

(下圖中“多型多型”應該是“動態多型”,實在抱歉)

這裡寫圖片描述

虛擬函式

虛擬函式——用於定義型別特定行為的成員函式。通過指標或引用對虛擬函式的呼叫直到執行時才被解析,依據是引用或指標所繫結物件的實際型別。

虛擬函式的用法格式:
virtual 函式返回型別 函式名 (引數列表) {函式體}

當我們使用基類的指標或引用基類中定義的一個函式時,我們並不知道該函式真正作用的物件是什麼型別,因為它可能是一個基類的物件也可能是一個派生類的物件。如果該函式是虛擬函式,則直到執行時才會決定到底執行哪個版本,判斷的依據是引用或指標所繫結物件的實際型別;反之,如果是非虛擬函式,對非虛擬函式的呼叫在編譯時進行繫結。
類似的,通過物件的函式呼叫也在編譯時繫結。物件的型別是確定不變的,我們無論如何都不可能令物件的靜態型別和動態型別不一致。因此,通過物件進行的函式呼叫將在編譯時被繫結到該物件所屬類中的函式版本上。

——摘自《C++ Primer》

看下面一段程式碼:

class Father
{
public:
    void fun()
    {
        cout << "Father::fun()" << endl;
    }
protected:
    int _f;
};

class Child  :public Father
{
public:
    void fun()
    {
        cout << "Child::fun()" << endl;
    }
protected:
    int _c;
}; 

int main()
{
    Child c;
    Father f;
    Father *pf = &c;
    pf->fun();
    system("pause");
    return 0;
}

輸出的結果是:
這裡寫圖片描述
上面程式中fun函式是非虛擬函式,所以呼叫函式在編譯時進行繫結,所以呼叫基類中的fun()函式。

下面我們將fun()函式設為虛擬函式,看結果有何不同?

class Father
{
public:
    virtual void fun()  //定義為虛擬函式
    {
        cout << "Father::fun()" << endl;
    }
protected:
    int _f;
};

class Child  :public Father
{
public:
    virtual void fun()  //定義為虛擬函式
    {
        cout << "Child::fun()" << endl;
    }
protected:
    int _c;
}; 

int main()
{
    Child c;
    Father f;
    Father *pf = &c;
    pf->fun();
    system("pause");
    return 0;

}

執行結果:
這裡寫圖片描述

兩次的結果明顯不同,這次呼叫的是派生類的fun()函式,因為是虛擬函式呼叫在執行時才知道呼叫函式哪個版本。
Father *pf = &c; //指標pf的靜態型別是基類指標,但是動態型別是派生類指標,而pf指標所繫結的物件的真是型別是派生類指標。所以呼叫派生類類中的fun()函式。

再看下面一段程式碼:

lass Father
{
public:
    virtual void fun() //虛擬函式
    {
        cout << "Father::fun()" << endl;
    }
protected:
    int _f;
};

class Child  :public Father
{
public:
    virtual void fun()   //虛擬函式
    {
        cout << "Child::fun()" << endl;
    }
protected:
    int _c;
}; 

int main()
{
    Child c;
    Father f;
    f.fun();
    c.fun();
    system("pause");
    return 0;

}

執行結果:
這裡寫圖片描述

class Father
{
public:
    void fun()
    {
        cout << "Father::fun()" << endl;
    }
protected:
    int _f;
};

class Child  :public Father
{
public:
    void fun()
    {
        cout << "Child::fun()" << endl;
    }
protected:
    int _c;
}; 

int main()
{
    Child c;
    Father f;
    f.fun();
    c.fun();
    system("pause");
    return 0;

}

執行結果:
這裡寫圖片描述
上面兩段程式碼說明:
通過物件呼叫函式時,與是不是虛擬函式無關,因為函式呼叫是在編譯時繫結的。繫結的是所屬類中的函式版本。

虛擬函式工作原理

我們先來了解一個概念:虛擬函式表
虛擬函式表:虛擬函式表又稱虛表(vtbl),是一塊連續的記憶體,編譯器會為每一個含有虛擬函式的類建立一個虛表,該虛表將被所有該類的所有物件共享,裡面儲存的是該類的虛擬函式的地址。虛表的大小是N*4(N個虛擬函式,一個虛擬函式佔一行,最後以0結尾)。虛擬函式的實現就是通過虛表來實現的。之前講到只有虛擬函式才能被覆蓋,就是指的是虛表中虛擬函式地址被覆蓋。
在有虛擬函式的類例項化時,編譯器分配了指向該表的指標的記憶體(虛擬函式表指標vptr),簡單一點就是便一起給每個物件新增了一個隱藏成員,這個隱藏成員中儲存了一個指向虛擬函式表的指標。這意味著可以通過類例項化的地址得到虛表,然後遍歷其中的函式指標,並呼叫相應的函式。
注:
1,基類物件含有一個指標,該指標指向基類中所有虛擬函式的地址表。派生類物件將含有一個指向獨立地址表的指標。
2,如果派生類提供了基類虛擬函式的重定義,該虛擬函式表將儲存新函式的地址。即就是虛擬函式覆蓋實際是對虛擬函式表中的虛擬函式的地址的覆蓋。
3,如果派生類定義了新的虛擬函式,則該函式的地址將被加入到虛擬函式表中。注意,無論類中是一個還是多個虛擬函式,都只需在物件中新增一個地址成員,只是表的大小不同。

下面我們通過一個例子來看虛擬函式機制記憶體佈局

class Father
{
public:
    virtual void fun1()
    {
        cout << "Father::fun1()" << endl;
    }
    virtual void fun2()
    {
        cout << "Father::fun2()" << endl;
    }

    int _f;
};

class Child  :public Father
{
public:
    int _c;
}; 

int main()
{
    Father f;
    f._f = 1;
    Child c;
    c._c = 2;
    system("pause");
    return 0;
}

這裡寫圖片描述
我們可以發現當例項化時編譯器就為物件生成了一個隱藏成員(虛表指標),也就是本例中的“1c 8c c8 00”.後面通過看虛表指標指向的虛表,可以發現有兩個地址,他們從上到下分別就是fun1(),fun2()的地址,第三行是0,虛表就是以NULL結尾的。
那我們來畫一下記憶體佈局:
這裡寫圖片描述

下面我們來在派生類中對基類中的虛擬函式fun2()進行重定義,也就是覆蓋。看看會有什麼不同。

class Father
{
public:
    virtual void fun1()
    {
        cout << "Father::fun1()" << endl;
    }
    virtual void fun2()
    {
        cout << "Father::fun2()" << endl;
    }

    int _f;
};

class Child  :public Father
{
public:
    virtual void fun2()   //覆蓋基類中函式fun2()
    {
        cout << "Child::fun2()" << endl;
    }
    int _c;
}; 

int main()
{
    Father f;
    f._f = 1;
    Child c;
    c._c = 2;
    system("pause");
    return 0;
}

這裡寫圖片描述

下面這幅圖就能證明上面這幅圖示註的正確性

這裡寫圖片描述
下面將派生類中覆蓋基類虛擬函式fun2()的函式改為虛擬函式fun3(),看看會出現什麼情況?

class Father
{
public:
    virtual void fun1()
    {
        cout << "Father::fun1()" << endl;
    }
    virtual void fun2()
    {
        cout << "Father::fun2()" << endl;
    }

    int _f;
};

class Child  :public Father
{
public:
    virtual void fun3()
    {
        cout << "Child::fun3()" << endl;
    }
    int _c;
}; 

int main()
{
    Father f;
    f._f = 1;
    Child c;
    c._c = 2;
    system("pause");
    return 0;

}

這裡寫圖片描述
看上面這個例子,說明如果派生類定義了新的虛擬函式,則該函式的地址將被加入到虛擬函式表中。
今天就講到這,大家應該對虛擬函式、虛表有個清晰的認識。這裡是面試常問的。

相關文章