虛函數(shù)

簡介

虛函數(shù)是C++中用于實現(xiàn)多態(tài)(polymorphism)的機制。核心理念就是通過基類訪問派生類的函數(shù)。
例如下面的兩個類:

class Base {
    public: 
        virtual void func() { std::cout << "Base::func() is called" << std::endl;};
};
class Derive : public Base {
    public: 
        virtual void func() { std::cout << "Derive::func() is called" << std::endl;};
};

使用時:

    Base* base = new Derive();
    base->func();  //base雖然是指向Base的指針,但是被調(diào)用的函數(shù)func()是A的函數(shù)

此處輸出的內(nèi)容為:Derive::func() is called。這只是一個簡單的虛函數(shù)的例子,從這里能大概看出虛函數(shù)的一些樣子。此處也需要知道一點,創(chuàng)建一個基類的實例可以從派生類中new創(chuàng)建出來,但是派生類的實例不能來源于new一個基類,這也是面向?qū)ο蟮囊恍┰瓌t。另外一個方面,虛函數(shù)只能借助于指針或者引用來達(dá)到多態(tài)的效果,例如下面這樣的代碼,雖然都是虛函數(shù),但它卻不是多態(tài):

void f(void) {
  Base b;
  b.func(); // 此處輸出:"Base::func() is called"
}

多態(tài)

1.多態(tài)有什么用

C++中的虛函數(shù)的作用主要是實現(xiàn)了多態(tài)的機制。關(guān)于多態(tài),簡而言之就是用父類型的指針指向其子類的實例,然后通過父類的指針調(diào)用實際子類的成員函數(shù)。這種技術(shù)可以讓父類的指針有“多種形態(tài)”,這是一種泛型技術(shù)。所謂泛型技術(shù),說白了就是試圖使用不變的代碼來實現(xiàn)可變的算法。比如:模板技術(shù),RTTI技術(shù),虛函數(shù)技術(shù),要么是試圖做到在編譯時決議,要么試圖做到運行時決議。例如:

void f(Base* b)
{
    b->func();  // 被調(diào)用的是Derive::func() 還是Base::func()?
}

因為func()是一個虛函數(shù),只通過這一段代碼是無法判斷調(diào)用的是哪一個類的函數(shù)。從面向?qū)ο蟮牡弥?,函?shù)傳入的實例可能是Base基類,也可能是派生類Derive,所以需要在編譯時是無法確定此函數(shù)的調(diào)用,這種同一代碼可以產(chǎn)生不同效果的特點,被稱為“多態(tài)”。

動態(tài)聯(lián)編

虛函數(shù)實際上是如何被編譯器處理的呢?Lippman在深度探索C++對象模型中的不同章節(jié)講到了幾種方式,這里把“標(biāo)準(zhǔn)的”方式簡單介紹一下。
我所說的“標(biāo)準(zhǔn)”方式,也就是所謂的VTABLE(虛函數(shù)表)機制。編譯器發(fā)現(xiàn)一個類中有被聲明為virtual的函數(shù),就會為其創(chuàng)建一個虛函數(shù)表,也就是 VTABLE。VTABLE實際上是一個函數(shù)指針的數(shù)組,每個虛函數(shù)的指針占用這個數(shù)組的一個slot。一個類只有一個VTABLE,不管它有多少個實例。派生 類有自己的VTABLE,但是派生類的VTABLE與基類的VTABLE有相同的函數(shù)排列順序,同名的虛函數(shù)被放在兩個數(shù)組的相同位置上。后面將會詳細(xì)講虛函數(shù)表。

overload和override
  • overload(重載)
    overload是指一個與已有函數(shù)同名但是參數(shù)表不同的函數(shù)。例如一個函數(shù)即可以接受整型數(shù)作為參數(shù),也可以接受浮點數(shù)作為參數(shù),還可以接受string等,這樣讓編譯器來判斷使用哪一個函數(shù)來達(dá)到效率最優(yōu)。
  • override(覆蓋)
    虛函數(shù)總是在派生類中被改寫,這種改寫被稱為“override”,翻譯成覆蓋貌似比較多。override是指派生類重寫基類的虛函數(shù),就象我們前面Derive類中重寫了Base類中的func()函數(shù)。重寫的函數(shù)必須有一致的參數(shù)表和返回值,C++標(biāo)準(zhǔn)允許 返回值不同的情況,但是很少編譯器支持這個feature。目前我也沒有使用過這種特性,這會讓我的代碼變的混亂,建議大家最好也不要用到這個特性。

虛函數(shù)語法

virtual關(guān)鍵字
class Base
{
  public:
    virtual void func();
};
class Derive: public Base
{
  public:
    void func();    // 沒有virtual關(guān)鍵字
};
class Derive: public Derive  // 從Derive繼承,不是從Base繼承
{
  public:
    void func();    // 也沒有virtual關(guān)鍵字
};

例如上面的代碼,Derive::func()是虛函數(shù),Derive::func()也同樣是虛函數(shù)?;惵暶鞯奶摵瘮?shù),在派生類中也是虛函數(shù),不管是否使用virtual關(guān)鍵字。但是通常為了代碼簡潔易懂,最好在派生類中也添加上virtual關(guān)鍵字,那么一眼就知道是虛函數(shù)。

純虛函數(shù)
class Base
{
  public:
    virtual void func()=0;   // =0標(biāo)志一個虛函數(shù)為純虛函數(shù)
};

一個函數(shù)聲明為純虛后,純虛函數(shù)的意思是:我是一個抽象類,不可以把我實例化。純虛函數(shù)用來規(guī)范派生類的行為,實際上就是所謂的“接口”,在java中就是interface。它告訴使用者,我的派生類都會有這個函數(shù),并且需要實現(xiàn)它,否則就是一個指向為空的指針,沒有可以執(zhí)行的函數(shù)。事實是怎樣的呢,如下:

class Derive : public Base {
  public: 
};

int main() {
  Derive* derive = new Derive();
  derive->func();
}

此代碼的在gcc version 6.5.0 (MacPorts gcc6 6.5.0_1)下編譯,事實結(jié)果如下:


1563933633906.jpg

編譯都沒有通過,所以虛函數(shù)是必須要實現(xiàn)。

虛析構(gòu)函數(shù)

首先我們看如下的代碼:

class Base {
  public:
    Base(){};
    ~Base(){std::cout << "Base::~Base() is called!" << std::endl;}
};
class Derive : public Base {
  public:
    Derive(){};
    ~Derive(){std::cout << "Derive::~Derive() is called!" << std::endl;}
};

int main(void) {
    Base* base = new Derive;
    delete base;
    return 0;
}

結(jié)果可能跟你想的不一樣,只有Base類的析鉤函數(shù)被調(diào)用了。

1564019256607.jpg

所以想要達(dá)到你想要的效果,需要把基類的析構(gòu)函數(shù)加上關(guān)鍵字virtual。結(jié)果下圖:

image.png

所以我們在定義析構(gòu)函數(shù)時,需要給他加上virtual關(guān)鍵字,一般情況下不需要定義成純虛函數(shù)。只有在希望將一個類變成抽象類(不能實例化的類),而這個類又沒有合適的函數(shù)可以被純虛化的時候,可以使用純虛的析構(gòu)函數(shù)。另外一點如果上面的代碼,Base類中虛函數(shù)甚至都沒有定義,main函數(shù)中delete base時,什么都不會發(fā)生,也不會調(diào)用派生類A的析構(gòu)函數(shù)。

虛函數(shù)表

虛函數(shù)表

使用C++的人都應(yīng)該知道虛函數(shù)Virtual Function,它是通過一張?zhí)摵瘮?shù)表Virtual Table來實現(xiàn),簡稱為V-Table,前文所說的標(biāo)準(zhǔn)方式,其他方式不討論。在這個表中,主是要一個類的虛函數(shù)的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應(yīng)實際類中的函數(shù)。這樣,在有虛函數(shù)的類的實例中這個表被分配在了這個實例的內(nèi)存中。所以,當(dāng)我們用父類的指針來操作一個子類的時候,這張?zhí)摵瘮?shù)表就顯得非常重要了,它就像一個地圖一樣,指明了實際所應(yīng)該調(diào)用的函數(shù)。
這里我們著重看一下這張?zhí)摵瘮?shù)表。C++的編譯器應(yīng)該是保證虛函數(shù)表的指針存在于對象實例中最前面的位置(這是為了保證取到虛函數(shù)表的有最高的性能,如果有多層繼承或是多重繼承的情況下)。 這意味著我們通過對象實例的地址得到這張?zhí)摵瘮?shù)表,然后就可以遍歷其中函數(shù)指針,并調(diào)用相應(yīng)的函數(shù)。如下代碼通過實例地址來獲取,編譯環(huán)境(gcc version 6.5.0 (MacPorts gcc6 6.5.0_1)):

class Base {
  public:
    virtual void func1() { std::cout << "Base::func1" << std::endl; }
    virtual void func2() { std::cout << "Base::func2" << std::endl; }
    virtual void func3() { std::cout << "Base::func3" << std::endl; }
};
typedef void (*Func)(void);
int main(int argc, char** argv){
  Base b;
  Func func = nullptr; //c++11的空指針
  std::cout << "vtable pointer address:"<< (long*)(&b) << std::endl;
  std::cout << "vtable address:"<< *(long*)(&b) << std::endl;
  std::cout << "vtable - first function pointer address:"<< (long*)*(long*)(&b) << std::endl;
  std::cout << "vtable - first function address:"<< *(long*)*(long*)(&b) << std::endl;
  func = (Func)*((long*)*(long*)(&b));  // Invoke the first virtual function
  func();
  return 0;
}
int main(int argc, char** argv) {
  Base b;
  Func func1 = nullptr;
  Func func2 = nullptr;
  Func func3 = nullptr;
  func1 = (Func)*((long*)*(long*)(&b) + 0);
  func2 = (Func)*((long*)*(long*)(&b) + 1);
  func3 = (Func)*((long*)*(long*)(&b) + 2);
  func1();
  func2();
  func3();
}

上面兩段代碼的運行結(jié)果如下圖,圖1輸出依次是虛函數(shù)表的指針地址,對指針地址取值后是虛函數(shù)表的地址,然后是第一個虛函數(shù)的指針地址,取值后為虛函數(shù)的地址。類型轉(zhuǎn)化后,運行就知,指向的是第一個虛函數(shù)。通過后面對地址的增加可以取到第二個,第三個虛函數(shù)。虛函數(shù)是按照申明時的順序排列,對地址按順序加,就可以按順序得到虛函數(shù),如圖2。


1.png
2.png
內(nèi)存中的結(jié)構(gòu)

如果看了上面還是有些不懂,下面畫出大概的內(nèi)存中的示例圖來表示:

  • 一般繼承 - 無虛函數(shù)覆蓋
    覆蓋父類的虛函數(shù)是很顯然的事情,不然,虛函數(shù)就變得毫無意義。如下的示意圖:

    image.png

    在這個繼承關(guān)系中,子類沒有覆蓋或者繼承任何的父類函數(shù),那么對于一個實例Derive d;,虛函數(shù)表示意如下:
    image.png

    我們可以看到下面兩點:

    1. 虛函數(shù)按照其聲明順序放于表中。
    2. 父類的虛函數(shù)在子類的虛函數(shù)前面。

  • 一般繼承 - 有虛函數(shù)覆蓋
    覆蓋父類的虛函數(shù)是很顯然的事情,不然,虛函數(shù)就變得毫無意義。子類中有虛函數(shù)重載了父類的虛函數(shù):


    image.png
image.png

我們從表中可以看到下面兩點:

  1. 覆蓋的f()函數(shù)被放到了虛表中原來父類虛函數(shù)的位置
  2. 沒有被覆蓋的函數(shù)依舊

  • 多重繼承 - 無虛函數(shù)覆蓋
    子類并沒有覆蓋父類的函數(shù):


    image.png
image.png

我們可以看到下面兩點:

  1. 每個父類都有自己的虛表
  2. 子類的成員函數(shù)被放到了第一個父類的表中(第一個父類是按照聲明順序來判斷)

這樣做就是為了解決不同的父類類型的指針指向同一個子類實例,而能夠調(diào)用到實際的函數(shù)。

  • 多重繼承 - 有虛函數(shù)覆蓋
    虛函數(shù)覆蓋的情況:


    image.png

image.png

我們可以看見,兩個父類虛函數(shù)表中的f()的位置被替換成了子類的函數(shù)指針。這樣,我們就可以任一靜態(tài)類型的父類來指向子類,并調(diào)用子類的f()。如下的示例:

int main(int argc, char ** argv) {
    Derive d;
    Base1 *b1 = &d;
    Base2 *b2 = &d;

    b1->f(); //Derive::f()
    b2->f(); //Derive::f()
 
    b1->g(); //Base1::g()
    b2->g(); //Base2::g()
}
安全性

我們知道,子類沒有重載父類的虛函數(shù)是一件毫無意義的事情。因為多態(tài)也是要基于函數(shù)重載。任何妄圖使用父類指針想調(diào)用子類中的未覆蓋父類的成員函數(shù)的行為都會被編譯器視為非法,所以,這樣的程序根本無法編譯通過。但在運行時,我們可以通過指針的方式訪問虛函數(shù)表來達(dá)到違反C++語義的行為。如下代碼:

class Base {
  public:
    virtual void func1() { std::cout << "Base::func1() is called!" << std::endl; }
    virtual void func2() { std::cout << "Base::func2() is called!" << std::endl; }
};
class Derive : public Base {
  public:
    virtual void func1() { std::cout << "Derive::func1() is called!" << std::endl; }
    virtual void func3() { std::cout << "Derive::func3() is called!" << std::endl; }
};

Base* b = new Derive();
b->func3(); //編譯失敗

Derive* d = new Derive();
d->func2(); //輸出 Base::func2() is called!

Base* b = new Derive();
b->func1(); //輸出 Derive::func1() is called!

第二種情況是訪問non-public的虛函數(shù)。如果父類的虛函數(shù)是privateprotected,但這些非public的虛函數(shù)同樣會存在于虛函數(shù)表中,所以,我們同樣可以使用訪問虛函數(shù)表的方式來訪問這些non-public的虛函數(shù),這很容易做到。如下:

class Base {
  private:
    virtual void func1() { std::cout << "Base::func1() is called!" << std::endl; }
};
class Derive : public Base {
};
typedef void (*Func)(void);
int main(int argc, char** argv) {
    Derive d;
    Func  func = (Func)*((long*)*(long*)(&d)+0);
    func();
    return 0;
}

image.png

這種不安全性不僅僅是在虛函數(shù)表會有,其他地方也會有。只要指針能訪問內(nèi)存,那么就可以做到許多意想不到的事,編寫代碼時需要自己注意這些,不是特別的需要還是不要通過的這樣的方式訪問。因為直接通過訪問內(nèi)存的方式不好理解,同時也容易出錯。但是拋開來說訪問內(nèi)存也是C/C++語言的魅力所在!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容