1. 程式人生 > >C++ 一篇搞懂多型的實現原理

C++ 一篇搞懂多型的實現原理

虛擬函式和多型

01 虛擬函式

  • 在類的定義中,前面有 virtual 關鍵字的成員函式稱為虛擬函式;
  • virtual 關鍵字只用在類定義裡的函式宣告中,寫函式體時不用。
class Base 
{
    virtual int Fun() ; // 虛擬函式
};

int Base::Fun() // virtual 欄位不用在函式體時定義
{ }

02 多型的表現形式一

  • 「派生類的指標」可以賦給「基類指標」;
  • 通過基類指標呼叫基類和派生類中的同名「虛擬函式」時:
    1. 若該指標指向一個基類的物件,那麼被呼叫是
      基類的虛擬函式;
    2. 若該指標指向一個派生類的物件,那麼被呼叫
      的是派生類的虛擬函式。

這種機制就叫做“多型”,說白點就是呼叫哪個虛擬函式,取決於指標物件指向哪種型別的物件。

// 基類
class CFather 
{
public:
    virtual void Fun() { } // 虛擬函式
};

// 派生類
class CSon : public CFather 
{ 
public :
    virtual void Fun() { }
};

int main() 
{
    CSon son;
    CFather *p = &son;
    p->Fun(); //呼叫哪個虛擬函式取決於 p 指向哪種型別的物件
    return 0;
}

上例子中的 p 指標物件指向的是 CSon 類物件,所以 p->Fun() 呼叫的是 CSon

類裡的 Fun 成員函式。

03 多型的表現形式二

  • 派生類的物件可以賦給基類「引用」
  • 通過基類引用呼叫基類和派生類中的同名「虛擬函式」時:
    1. 若該引用引用的是一個基類的物件,那麼被調
      用是基類的虛擬函式;
    2. 若該引用引用的是一個派生類的物件,那麼被
      呼叫的是派生類的虛擬函式。

這種機制也叫做“多型”,說白點就是呼叫哪個虛擬函式,取決於引用的物件是哪種型別的物件。

// 基類
class CFather 
{
public:
    virtual void Fun() { } // 虛擬函式
};

// 派生類
class CSon : public CFather 
{ 
public :
    virtual void Fun() { }
};

int main() 
{
    CSon son;
    CFather &r = son;
    r.Fun(); //呼叫哪個虛擬函式取決於 r 引用哪種型別的物件
    return 0;
}
}

上例子中的 r 引用的物件是 CSon 類物件,所以 r.Fun() 呼叫的是 CSon 類裡的 Fun 成員函式。

04 多型的簡單示例

class A 
{
public :
    virtual void Print() { cout << "A::Print"<<endl ; }
};

// 繼承A類
class B: public A 
{
public :
    virtual void Print() { cout << "B::Print" <<endl; }
};

// 繼承A類
class D: public A 
{
public:
    virtual void Print() { cout << "D::Print" << endl ; }
};

// 繼承B類
class E: public B 
{
    virtual void Print() { cout << "E::Print" << endl ; }
};

A類、B類、E類、D類的關係如下圖:

int main() 
{
    A a; B b; E e; D d;
    
    A * pa = &a; 
    B * pb = &b;
    D * pd = &d; 
    E * pe = &e;
    
    pa->Print();  // a.Print()被呼叫,輸出:A::Print
    
    pa = pb;
    pa -> Print(); // b.Print()被呼叫,輸出:B::Print
    
    pa = pd;
    pa -> Print(); // d.Print()被呼叫,輸出:D::Print
    
    pa = pe;
    pa -> Print(); // e.Print()被呼叫,輸出:E::Print
    
    return 0;
}

05 多型作用

在面向物件的程式設計中使用「多型」,能夠增強程式的可擴充性,即程式需要修改或增加功能的時候,需要改動和增加的程式碼較少。


LOL 英雄聯盟遊戲例子

下面我們用設計 LOL 英雄聯盟遊戲的英雄的例子,說明多型為什麼可以在修改或增加功能的時候,可以較少的改動程式碼。

LOL 英雄聯盟是 5v5 競技遊戲,遊戲中有很多英雄,每種英雄都有一個「類」與之對應,每個英雄就是一個「物件」。

英雄之間能夠互相攻擊,攻擊敵人和被攻擊時都有相應的動作,動作是通過物件的成員函式實現的。

下面挑了五個英雄:

  • 探險家 CEzreal
  • 蓋樓 CGaren
  • 盲僧 CLeesin
  • 無極劍聖 CYi
  • 瑞茲 CRyze

基本思路:

  1. 為每個英雄類編寫 AttackFightBackHurted 成員函式。
  • Attack 函式表示攻擊動作;
  • FightBack 函式表示反擊動作;
  • Hurted 函式表示減少自身生命值,並表現受傷動作。
  1. 設定基類CHero,每個英雄類都繼承此基類

02 非多型的實現方法

// 基類
class CHero 
{
protected:  
    int m_nPower ; //代表攻擊力
    int m_nLifeValue ; //代表生命值
};


// 無極劍聖類
class CYi : public CHero 
{
public:
    // 攻擊蓋倫的攻擊函式
    void Attack(CGaren * pGaren) 
    {
        .... // 表現攻擊動作的程式碼
        pGaren->Hurted(m_nPower);
        pGaren->FightBack(this);
    }

    // 攻擊瑞茲的攻擊函式
    void Attack(CRyze * pRyze) 
    {
        .... // 表現攻擊動作的程式碼
        pRyze->Hurted(m_nPower);
        pRyze->FightBack( this);
    }
    
    // 減少自身生命值
    void Hurted(int nPower) 
    {
        ... // 表現受傷動作的程式碼
        m_nLifeValue -= nPower;
    }
    
    // 反擊蓋倫的反擊函式
    void FightBack(CGaren * pGaren) 
    {
        ....// 表現反擊動作的程式碼
        pGaren->Hurted(m_nPower/2);
    }
    
    // 反擊瑞茲的反擊函式
    void FightBack(CRyze * pRyze) 
    {
        ....// 表現反擊動作的程式碼
        pRyze->Hurted(m_nPower/2);
    }
};

有 n 種英雄,CYi 類中就會有 n 個 Attack 成員函式,以及 n 個 FightBack
成員函式。對於其他類也如此。

如果遊戲版本升級,增加了新的英雄寒冰艾希 CAshe,則程式改動較大。所有的類都需要增加兩個成員函式:

void Attack(CAshe * pAshe);
void FightBack(CAshe * pAshe);

這樣工作量是非常大的!!非常的不人性,所以這種設計方式是非常的不好!

03 多型的實現方式

用多型的方式去實現,就能得知多型的優勢了,那麼上面的栗子改成多型的方式如下:

// 基類
class CHero 
{
public:
    virtual void Attack(CHero *pHero){}
    virtual voidFightBack(CHero *pHero){}
    virtual void Hurted(int nPower){}

protected:  
    int m_nPower ; //代表攻擊力
    int m_nLifeValue ; //代表生命值
};

// 派生類 CYi:
class CYi : public CHero {
public:
    // 攻擊函式
    void Attack(CHero * pHero) 
    {
        .... // 表現攻擊動作的程式碼
        pHero->Hurted(m_nPower); // 多型
        pHero->FightBack(this);  // 多型
    }
    
    // 減少自身生命值
    void Hurted(int nPower) 
    {
        ... // 表現受傷動作的程式碼
        m_nLifeValue -= nPower;
    }
    
    // 反擊函式
    void FightBack(CHero * pHero) 
    {
        ....// 表現反擊動作的程式碼
        pHero->Hurted(m_nPower/2); // 多型
    }
};

如果增加了新的英雄寒冰艾希 CAshe,只需要編寫新類CAshe,不再需要在已有的類裡專門為新英雄增加:

void Attack( CAshe * pAshe) ;
void FightBack(CAshe * pAshe) ;

所以已有的類可以原封不動,那麼使用多型的特性新增英雄的時候,可見改動量是非常少的。

多型使用方式:

void CYi::Attack(CHero * pHero) 
{
    pHero->Hurted(m_nPower); // 多型
    pHero->FightBack(this);  // 多型
}

CYi yi; 
CGaren garen; 
CLeesin leesin; 
CEzreal ezreal;

yi.Attack( &garen );  //(1)
yi.Attack( &leesin ); //(2)
yi.Attack( &ezreal ); //(3)

根據多型的規則,上面的(1),(2),(3)進入到 CYi::Attack 函式後
,分別呼叫:

CGaren::Hurted
CLeesin::Hurted
CEzreal::Hurted

多型的又一例子

出一道題考考大家,看大家是否理解到了多型的特性,下面的程式碼,pBase->fun1()輸出結果是什麼呢?

class Base 
{
public:
    void fun1() 
    { 
        fun2(); 
    }
    
    virtual void fun2()  // 虛擬函式
    { 
        cout << "Base::fun2()" << endl; 
    }
};

class Derived : public Base 
{
public:
    virtual void fun2()  // 虛擬函式
    { 
        cout << "Derived:fun2()" << endl; 
    }
};

int main() 
{
    Derived d;
    Base * pBase = & d;
    pBase->fun1();
    return 0;
}

是不是大家覺得 pBase 指標物件雖然指向的是派生類物件,但是派生類裡沒有 fun1 成員函式,則就呼叫基類的 fun1 成員函式,Base::fun1() 裡又會呼叫基類的 fun2 成員函式,所以輸出結果是Base::fun2()

假設我把上面的程式碼轉換一下, 大家還覺得輸出的是 Base::fun2() 嗎?

class Base 
{
public:
    void fun1() 
    { 
        this->fun2();  // this是基類指標,fun2是虛擬函式,所以是多型
    }
}

this 指標的作用就是指向成員函式所作用的物件, 所以非靜態成員函式中可以直接使用 this 來代表指向該函式作用的物件的指標。

pBase 指標物件指向的是派生類物件,派生類裡沒有 fun1 成員函式,所以就會呼叫基類的 fun1 成員函式,在Base::fun1() 成員函式體裡執行 this->fun2() 時,實際上指向的是派生類物件的 fun2 成員函式。

所以正確的輸出結果是:

Derived:fun2()

所以我們需要注意:

在非建構函式,非解構函式的成員函式中呼叫「虛擬函式」,是多型!!!

建構函式和解構函式中存在多型嗎?

在建構函式和解構函式中呼叫「虛擬函式」,不是多型。編譯時即可確定,呼叫的函式是自己的類或基類中定義的函式,不會等到執行時才決定呼叫自己的還是派生類的函式。

我們看如下的程式碼例子,來說明:

// 基類
class CFather 
{
public:
    virtual void hello() // 虛擬函式
    {
        cout<<"hello from father"<<endl; 
    }
    
    virtual void bye() // 虛擬函式
    {
        cout<<"bye from father"<<endl; 
    }
};

// 派生類
class CSon : public CFather
{ 
public:
    CSon() // 建構函式
    { 
        hello(); 
    }
    
    ~CSon()  // 解構函式
    { 
        bye();
    }

    virtual void hello() // 虛擬函式
    { 
        cout<<"hello from son"<<endl;
    }
};

int main()
{
    CSon son;
    CFather *pfather;
    pfather = & son;
    pfather->hello(); //多型
    return 0;
}

輸出結果:

hello from son  // 構造son物件時執行的建構函式
hello from son  // 多型
bye from father // son物件析構時,由於CSon類沒有bye成員函式,所以呼叫了基類的bye成員函式

多型的實現原理

「多型」的關鍵在於通過基類指標或引用呼叫一個虛擬函式時,編譯時不能確定到底呼叫的是基類還是派生類的函式,執行時才能確定。

我們用 sizeof 來運算有有虛擬函式的類和沒虛擬函式的類的大小,會是什麼結果呢?

class A 
{
public:
    int i;
    virtual void Print() { } // 虛擬函式
};

class B
{
public:
    int n;
    void Print() { } 
};

int main() 
{
    cout << sizeof(A) << ","<< sizeof(B);
    return 0;
}

在64位機子,執行的結果:

16,4

從上面的結果,可以發現有虛擬函式的類,多出了 8 個位元組,在 64 位機子上指標型別大小正好是 8 個位元組,這多出 8 個位元組的指標有什麼作用呢?

01 虛擬函式表

每一個有「虛擬函式」的類(或有虛擬函式的類的派生類)都有一個「虛擬函式表」,該類的任何物件中都放著虛擬函式表的指標。「虛擬函式表」中列出了該類的「虛擬函式」地址。

多出來的 8 個位元組就是用來放「虛擬函式表」的地址。

// 基類
class Base 
{
public:
    int i;
    virtual void Print() { } // 虛擬函式
};

// 派生類
class Derived : public Base
{
public:
    int n;
    virtual void Print() { } // 虛擬函式
};

上面 Derived 類繼承了 Base類,兩個類都有「虛擬函式」,那麼它「虛擬函式表」的形式可以理解成下圖:

多型的函式呼叫語句被編譯成一系列根據基類指標所指向的(或基類引用所引用的)物件中存放的虛擬函式表的地址,在虛擬函式表中查詢虛擬函式地址,並呼叫虛擬函式的指令。

02 證明虛擬函式表指標的作用

在上面我們用 sizeof 運算子計算了有虛擬函式的類的大小,發現是多出了 8 位元組大小(64位系統),這多出來的 8 個位元組就是指向「虛擬函式表的指標」。「虛擬函式表」中列出了該類的「虛擬函式」地址。

下面用程式碼的例子,來證明「虛擬函式表指標」的作用:

// 基類
class A 
{
public: 
    virtual void Func()  // 虛擬函式
    { 
        cout << "A::Func" << endl; 
    }
};

// 派生類
class B : public A 
{
public: 
    virtual void Func()  // 虛擬函式
    { 
        cout << "B::Func" << endl;
    }
};

int main() 
{
    A a;
    
    A * pa = new B();
    pa->Func(); // 多型
    
    // 64位程式指標為8位元組
    int * p1 = (int *) & a;
    int * p2 = (int *) pa;
    
    * p2 = * p1;
    pa->Func();
    
    return 0;
}

輸出結果:

B::Func
A::Func
  • 第 25-26 行程式碼中的 pa 指標指向的是 B 類物件,所以 pa->Func() 呼叫的是 B 類物件的虛擬函式 Func(),輸出內容是 B::Func
  • 第 29-30 行程式碼的目的是把 A 類的頭 8 個位元組的「虛擬函式表指標」存放到 p1 指標和把 B 類的頭 8 個位元組的「虛擬函式表指標」存放到 p2 指標;
  • 第 32 行程式碼目的是把 A 類的「虛擬函式表指標」 賦值給 B 類的「虛擬函式表指標」,所以相當於把 B 類的「虛擬函式表指標」 替換 成了 A 類的「虛擬函式表指標」;
  • 由於第 32 行的作用,把 B 類的「虛擬函式表指標」 替換 成了 A 類的「虛擬函式表指標」,所以第 33 行呼叫的是 A 類的虛擬函式 Func(),輸出內容是 A::Func

通過上述的程式碼和講解,可以有效的證明了「虛擬函式表的指標」的作用,「虛擬函式表的指標」指向的是「虛擬函式表」,「虛擬函式表」裡存放的是類裡的「虛擬函式」地址,那麼在呼叫過程中,就能實現多型的特性。


虛解構函式

解構函式是在刪除物件或退出程式的時候,自動呼叫的函式,其目的是做一些資源釋放。

那麼在多型的情景下,通過基類的指標刪除派生類物件時,通常情況下只調用基類的解構函式,這就會存在派生類物件的解構函式沒有呼叫到,存在資源洩露的情況。

看如下的例子:

// 基類
class A 
{
public: 
    A()  // 建構函式
    {
        cout << "construct A" << endl;
    }
    
    ~A() // 解構函式
    {
        cout << "Destructor A" << endl;
    }
};

// 派生類
class B : public A 
{
public: 
    B()  // 建構函式
    {
        cout << "construct B" << endl;
    }
    
    ~B()// 解構函式
    {
        cout << "Destructor B" << endl;
    }
};

int main() 
{
    A *pa = new B();
    delete pa;
    
    return 0;
}

輸出結果:

construct A
construct B
Destructor A

從上面的輸出結果可以看到,在刪除 pa指標物件時,B 類的解構函式沒有被呼叫。

解決辦法:把基類的解構函式宣告為virtual

  • 派生類的解構函式可以 virtual 不進行宣告;
  • 通過基類的指標刪除派生類物件時,首先呼叫派生類的解構函式,然後呼叫基類的解構函式,還是遵循「先構造,後虛構」的規則。

將上述的程式碼中的基類的解構函式,定義成「虛解構函式」:

// 基類
class A 
{
public: 
    A()  
    {
        cout << "construct A" << endl;
    }
    
    virtual ~A() // 虛解構函式
    {
        cout << "Destructor A" << endl;
    }
};

輸出結果:

construct A
construct B
Destructor B
Destructor A

所以要養成好習慣:

  • 一個類如果定義了虛擬函式,則應該將解構函式也定義成虛擬函式;
  • 或者,一個類打算作為基類使用,也應該將解構函式定義成虛擬函式。
  • 注意:不允許建構函式不能定義成虛建構函式。

純虛擬函式和抽象類

純虛擬函式: 沒有函式體的虛擬函式

class A 
{

public:
    virtual void Print( ) = 0 ; //純虛擬函式
private: 
    int a;
};

包含純虛擬函式的類叫抽象類

  • 抽象類只能作為基類來派生新類使用,不能建立抽象類的物件
  • 抽象類的指標和引用可以指向由抽象類派生出來的類的物件
A a;         // 錯,A 是抽象類,不能建立物件
A * pa ;     // ok,可以定義抽象類的指標和引用
pa = new A ; // 錯誤, A 是抽象類,不能建立物件

推薦閱讀:

C++ this指標的理解和作用

C++ 一篇搞懂繼承的常見特性

C++ 賦值運算子'='的過載(淺拷貝、深拷貝)

C++ 手把手教你實現可變長的陣列

相關推薦

C++ 實現原理

虛擬函式和多型 01 虛擬函式 在類的定義中,前面有 virtual 關鍵字的成員函式稱為虛擬函式; virtual 關鍵字只用在類定義裡的函式宣告中,寫函式體時不用。 class Base { virtual int Fun() ; // 虛擬函式 }; int Base::Fun() //

Redis

1什麼是Redis? Redis 是一個基於記憶體的高效能key-value資料庫。 (有空再補充,有理解錯誤或不足歡迎指正) 2Reids有哪些特點? Redis本質上是一個Key-Value型別的記憶體資料庫,很像memcached,整個資料庫統統載入在記憶體當中進行操作,

淺談C++實現原理(虛繼承的奧祕)

大夥都知道,如果要實現C++的多型,那麼,基類中相應的函式必須被宣告為虛擬函式(或純虛擬函式)。舉個例子: class Point { public: Point(float x = 0.0, float y = 0.0) : _x(x), _y(y) { } virtual fl

C++(實現原理)函式重寫,過載,重定義

多型的實現原理:          首先介紹下函式重寫 重定義 過載的區別; 函式重寫:          發生在父類和子類之間,子類將父類中的同名函式進行了覆蓋,如果在函式前面含有virtual那麼就是重寫,如果沒有就成了覆蓋,子類的同名函式將會覆蓋(隱藏)父類的同名

C++實現原理

理論知識: 當類中宣告虛擬函式時,編譯器會在類中生成一個虛擬函式表。 虛擬函式表是一個儲存類成員函式指標的資料結構。 虛擬函式表是由編譯器自動生成與維護的。 virtual成員函式會被編譯器放入虛擬函式表中。 當存在虛擬函式時,每個物件中都有一個指向虛擬函式表的指標

反射和實現原理詳解

Table of Contents 反射和多型 多型 多型的定義和用法 多型的實現原理 反射 反射的實現原理 反射的應用 反射的弊端 反射和多型 這兩種技術並無直接聯絡,之所以把它們放在一起說,是因為Java提供讓我們在執行時識別物件和類的資訊,主要有

實現原理剖析

1. 虛擬函式表 C++的多型是通過一張虛擬函式表(virtual Table)來實現的,簡稱為V-Table,(這個表是隱式的,不需要關心其生成與釋放)在這個表中,主要是一個類的虛擬函式的地址表,這張表解決了繼承,覆寫的問題,保證其真實反應實際的函式,這樣,在有虛擬函式的類的例項中這個表被分配在了這個例項

淺析實現原理

實現過程 當我們在宣告一個類時,編譯器會自動幫我們建立一個虛擬函式表。 比如下面的這段程式碼: 編譯器為我們生成的虛擬函式表 虛擬函式表: 虛擬函式表是由編譯器自動產生的一種儲存類成員函式的一種資料結構。其中虛擬函式會被自動放入表中。 那編譯器是怎

定Java集合類原理

## Java集合類實現原理 ### 1.Iterable介面 - 定義了迭代集合的迭代方法 ```java iterator() forEach() 對1.8的Lambda表示式提供了支援 ``` ### 2. Collection介面 - 定義了集合新增的通用方法 ```java int

C/C++中指標那些事(上

一 指標變數 1.間接存取        指標變數的值為地址;普通變數的值為資料;其中“*”為指標運算子。&是地址操作符,用來引用一個記憶體地址。通過在變數名字前使用&操作符,我們可以得到該變數的記憶體地址。        針對記憶體資料的

K近鄰演算法(KNN),附帶實現案例

簡介:本文作者為 CSDN 部落格作者董安勇,江蘇泰州人,現就讀於昆明理工大學電子與通訊工程專業碩士,目前主要學習機器學習,深度學習以及大資料,主要使用python、Java程式語言。平時喜歡看書,打籃球,程式設計。學習為了進步,進步為了更好的學習! 一、KNN回顧

C#學習 小知識_的簡單實現_2018Oct

  多型的實現三步驟     1.父類   寫入方法  (抽象類必須定義抽象方法)     2.子類   繼承父類  重寫方法  (對父類抽象方法(或虛方法)進行重寫)  

全方位徹底讀<你不知道的JavaScript(上)>--六萬字的讀書筆記

前言 Q&A 1.問:為什麼要寫這麼長,有必要嗎?是不是腦子秀逗了? 答:我想這是大部分人看到這個標題都會問的問題.因為作為一個男人,我喜歡長一點,也不喜歡分割成幾個部分.一家人就要在一起,整整齊齊.好吧,正經點,其實整篇前言可以說都是在回答這個問題.你可以選擇先看完前言

Java分散式鎖,分散式鎖實現看這文章就對了

隨著微處理機技術的發展,人們只需花幾百美元就能買到一個CPU晶片,這個晶片每秒鐘執行的指令比80年代最大的大型機的處理機每秒鐘所執行的指令還多。如果你願意付出兩倍的價錢,將得到同樣的CPU,但它卻以更高的時鐘速率執行。因此,最節約成本的辦法通常是在一個系統中使用集中在一起的大量的廉價CPU。所以,傾向

C++繼承實現介面內容封裝例子

        封裝(private中的資料都通過Get與Set來訪問)可以使程式碼模組化,繼承(:)可以擴充套件已存在的程式碼,而多型的目的是為了介面重用(即相同名字的介面可能實現不同的Function功能,因為他們可能可以擴充套件成一個子類)。多型通過

Matlab畫圖那些事(上)

題記:臨時需要Matlab畫個曲線圖,突然發現有些命令竟然忘掉了,於是各種查。這裡博主整理合並關於畫圖那些命令,只為讓你輕鬆搞定Matlab畫圖這些瑣事,那麼,來吧! 說明:本博文主要是二維圖形的繪製,二維圖形是將平面座標上的資料點連線起來的平面圖形。可以採用

C++中動實現之虛擬函式與虛表指標

1、靜多型與命名傾軋,動多型與虛擬函式: (1)概述: 我們知道,C++的多型有靜多型(Static polymorphism)與動多型(Dynamic polymorphism)之分,靜多型是依靠函式過載(function overloading)實現的,

生成對抗網路(GAN)原理+tensorflow程式碼實現

作者:JASON 2017.10.15   生成對抗網路GAN(Generative adversarial networks)是最近很火的深度學習方法,要理解它可以把它分成生成模型和判別模型兩個部分,簡單來說就是:兩個人比賽,看是 A 的矛厲害,還是 B

從零開始學C++之虛擬函式與):虛擬函式表指標、虛解構函式、object slicing與虛擬函式、C++物件模型圖

#include <iostream>using namespace std;class CObject {public:     virtual void Serialize()     {         cout << "CObject::Serialize ..." <&

C語言回撥函式

什麼是回撥函式我們先來看看百度百科是如何定義回撥函式的:回撥函式就是一個通過函式指標呼叫的函式。如果你把函式的指標(地址)作為引數傳遞給另一個函式,當這個指標被用來呼叫其所指向的函式時,我們就說這是回撥函式。回撥函式不是由該函式的實現方直接呼叫,而是在特定的事件或條件發生時由