CPP多態性與虛函數

多態性與虛函數

面向對象程序設計中的多態性

多態性(polymorphism)
調用同一個函數名,根據需要實現不同的功能。

  • 編譯時的多態性:靜態多態(函數重載、運算符重載)
  • 運行時的多態性:動態多態(虛函數)

函數重載和運算符重載實現的多態性屬於靜態多態性,在程序編譯時就能決定調用的是哪個函數。靜態多態性又稱編譯時的多態性。靜態多態是通過函數重載、運算符重載實現的。

運行時的多態性是指在程序執行之前,根據函數名和參數無法確定應該調用哪一個函數,必須在程序的執行過程中,根據具體的執行情況來動態地確定。動態多態是通過虛函數(virtual function)實現的。

派生類與基類對象之間的賦值

  • 1
    可以將派生類對象的值賦給基類對象,但只對從基類繼承來的成員賦值。
class Base{
};

class Derived:public Base{
};

Base b;
Derived d;
b = d;
  • 派生類對象可以初始化基類的引用
Derived d;
Base &b = d;
//但b只能引用從基類繼承來的成員
  • 派生類對象的地址賦給基類的指針變量

bp只能引用從基類繼承來的成員。

Base *bp;
Derived d;
bp = &d;

公有派生類對象可以被當作基類的對象使用。反之則不可。
通過基類對象名、指針只能使用從基類繼承的成員。

class A{
public:
    void show(){cout<<"A"<<endl;}
};
class AA:public A
{
public:
    void show(){cout<<"AA"<<endl;}
};
void f(A *pa){
    pa->show();
}

int main()
{
    A a;
    AA aa;
    f(&a);
    f(&aa);
    return 0;
}

函數f()的形參是類A的指針,所以調用的方法只能是基類A中的方法。

A
A

這和java的多態大不一樣。

public class Polymorphism {
    public static void main(String[] args) {
        A a = new A();
        AA aa = new AA();
        f(a);
        f(aa);
    }
    static void f(A a){
        a.show();
    }
}

class A {
    void show() {
        System.out.println("A");
    }
}
class AA extends A{
    void show()
    {
        System.out.println("AA");
    }
}
A
AA
  • 這種賦值與訪問性的關係。發現只有共有派生的子類才能向上轉型。
    在這裏插入圖片描述

虛函數

在程序運行時通過基類指針調用相同的函數名,而實現不同功能的函數稱爲虛函數。

一旦把基類的成員函數定義爲虛函數,由基類所派生出來的所有派生類中,該函數均保持虛函數的特性
在派生類中重新定義基類中的虛函數時,可以不用關鍵字virtual來修飾這個成員函數 。

虛函數的聲明
virtual關鍵字

virtual 函數類型 函數名(形參表)
{
	函數體
}

若要通過基類指針訪問派生類中相同名字的函數,必須將基類中同名函數定義爲虛函數,這樣,將各種派生類對象的地址賦給基類的指針變量後,就可以動態地根據這種賦值調用不同類中的函數

若要達成java的效果,只需
基類A的同名函數show()前加上virtual關鍵字。

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

若要通過基類引用訪問派生類中相同名字的函數,也是可以的。

class A{
public:
    virtual void show(){cout<<"A"<<endl;}
};
class AA:public A
{
public:
    void show(){cout<<"AA"<<endl;}
};
void f(A &a){
    a.show();
}

int main()
{
    A a;
    AA aa;
    f(a);
    f(aa);
    return 0;
}
A
AA

如果去掉f(A &a)的&呢?
輸出結果。

A
A

所以通過虛函數的實現的多態,必須要通過指針或引用

只有在程序的執行過程中,依據指針具體指向哪個類對象,或依據引用哪個類對象,才能確定調用哪一個版本,實現動態綁定

虛析構函數

如果要通過基類指針調用派生類對象的析構函數(裏面包含delete),就需要讓基類的析構函數成爲虛函數。

class A{
public:
    A(){cout<<"A"<<endl;}
    ~A(){cout<<"~A"<<endl;}
};
class AA:public A{
public:
    int *p;
    AA(){cout<<"AA"<<endl;p = new int;}
    ~AA(){cout<<"~AA"<<endl;delete p;}
};

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

解決辦法就是在基類的析構函數前加上關鍵字virtual

class A{
public:
    A(){cout<<"A"<<endl;}
    virtual ~A(){cout<<"~A"<<endl;}
};
class AA:public A{
public:
    int *p;
    AA(){cout<<"AA"<<endl;p = new int;}
    ~AA(){cout<<"~AA"<<endl;delete p;}
};

虛函數的幾點說明:

  • 當在基類中把成員函數定義爲虛函數後,在其派生類中定義的虛函數必須與基類中的虛函數同名,參數的類型、順序、參數的個數必須一一對應,函數的返回類型也相同。
    若函數名相同,但參數的個數不同或者參數的類型不同時,則屬於函數重載而不是虛函數
    若函數名不同,顯然是不同的成員函數。

  • 虛函數必須是類的一個成員函數,不能是友元函數也不能是靜態的成員函數

  • 可把析構函數定義爲虛析構函數。但是,不能將構造函數定義爲虛函數

  • 在派生類中如果沒有重新定義虛函數時,與一般的成員函數一樣,當調用這種派生類對象的虛函數時,則調用基類中的虛函數

  • 實現動態多態性時,必須使用基類類型指針變量引用,指向不同的派生類對象,調用虛函數實現動態多態性。
    通過對象名訪問虛函數時,只能靜態綁定。即由編譯器在編譯的時候決定調用哪個函數。

  • 虛函數與一般的成員函數相比較,調用時的執行速度要慢一些。爲了實現多態性,在每一個派生類中均要保存相應虛函數的入口地址表,函數的調用機制也是間接實現的。因此,除了要編寫一些通用的程序,必須使用虛函數才能完成其功能要求外,通常不必使用虛函數。

  • 一個函數如果被定義成虛函數,則不管經歷多少次派生,仍將保持其虛特性(根據實際的類型來調用相應的函數,而不是聲明的類型),以實現“一個接口,多個形態”。

但一定要注意virtual在類派生結構中的位置:

class Base0{
public:
    void v(){cout<<"Base0\n";}
};

class Base1:public Base0{
public:
    virtual void v(){cout<<"Base1\n";}
};

class A1:public Base1{
public:
    void v(){cout<<"A1\n";}
};

class A2:public A1{
public:
    void v(){cout<<"A2\n";}
};

class B1:private Base1{
public:
    void v(){cout<<"B1\n";}
};

class B2:public B1{
public:
    void v(){cout<<"B2\n";}
};

int main()
{
    Base0 *pb0;
    A1 a1;
    (pb0=&a1)->v();
    A2 a2;
    (pb0=&a2)->v();
    
    return 0;
}

原因在於pb0接受了它的派生類的賦值,只能調用自己的函數,virtual只是修飾了它的派生類。

Base0
Base0

純虛函數(抽象類)

在基類中不對虛函數給出有意義的實現,只在派生類中有具體的意義。這時基類中的虛函數只是一個入口,具體的操作由派生類中的對象實現。這種虛函數稱爲純虛函數(pure virtual)。

至少包含一個純虛函數的類,稱爲抽象類(abstract classes)。抽象類只能作爲派生類的基類,不能用來聲明這種類的對象。

class <抽象類名>
{
	virtual <類型> <函數名>(<參數表>) =  0;
	……
}
  • 在定義純虛函數時,不能定義虛函數的實現部分。

  • 把函數名賦於0,本質上是將指向函數的指針值賦爲0。與定義空函數不一樣,空函數的函數體爲空,即調用該函數時,不執行任何動作。在沒有重新定義這種純虛函數之前,是不能調用這種函數的。

  • 因爲純虛函數沒有實現部分,所以不能產生對象。但可以定義指向抽象類的指針,即指向這種抽象基類的指針。當用這種基類指針指向其派生類的對象時,必須在派生類中給出純虛函數的實現,否則會導致程序運行錯誤。

  • 抽象類的唯一用途是爲派生類提供基類
    純虛函數是作爲派生類中的成員函數的基礎,實現動態多態性。

demo

class A{
public:
    virtual void f()=0;
};
class A1:public A{
public:
    void f(){cout<<"A1\n";}
};
class A2:public A{
public:
    void f(){cout<<"A2\n";}
};

void fun(A *pa){
    pa->f();
}

int main()
{
    A1 a1;
    A2 a2;
    fun(&a1);
    fun(&a2);
    return 0;
}

虛函數與虛基類

  • 虛基類的用處:派生類只會有唯一一份基類的拷貝。
    要注意virtual修飾的是那唯一的一份基類,在派生的時候聲明。

demo

class Base{
};
class A1:public Base{
};

class A2:public Base{
};

class B:public A1,public A2{
};

int main()
{
    B b;
    Base *base = &b; //error , Ambiguous conversion from derived class 'B' to base class 'Base':
    return 0;
}

要改爲:

class Base{
};
class A1:virtual public Base{
};

class A2:virtual public Base{
};

class B:public A1,public A2{
};

int main()
{
    B b;
    Base *base = &b;
    return 0;
}
  • 虛函數的作用

用基類的指針或引用去根據實際對象的類型進行函數的調用。

這就是virtual關鍵字在c++中主要的兩個用處。

派生類中重定義基類中的重載函數

C++中繼承層次的函數調用遵循以下四個步驟:

  1. 首先確定進行函數調用的對象、引用或指針的靜態類型
  2. 在該類中查找函數,如果找不到,就在直接基類中查找,如此循着類的繼承鏈往上找,直到找到該函數或者查找完最後一個類。如果不能在類或其相關基類中找到該名字,則調用是錯誤的。
  3. 一旦找到了該名字,就進行**常規類型檢查,**查看如果給定找到的定義,該函數調用是否合法。
  4. 假定函數調用合法,編譯器就生成代碼。如果函數是虛函數且通過引用或指針調用,則編譯器生成代碼以確定根據對象的動態類型運行哪個函數版本,否則,編譯器生成代碼直接調用函數。

下面的寫法會報錯。

class Base{
public:
    void print(){
        cout<<"print() in Base"<<endl;
    }
    
    void print(int x){
        cout<<"print(int x) in Base"<<endl;
    }
    void print(string s)
    {
        cout<<"print(string s) in Base"<<endl;
    }
    
};

class Derived:public Base{
public:
    void print(){
        cout<<"print() in Derived"<<endl;
    }
};

int main()
{
    Derived d;
    
    d.print();
    d.print("");  // error
    d.print(1);   // error
    
    return 0;
}

當僅重寫了其中的一個重載函數時,在做函數名匹配時,在本類中就可以匹配到了,就不會向其父類查找了。在派生類中,僅記錄了這個被重寫的函數的信息,沒有其他重載函數,就導致了上述錯誤的出現。即:派生類中的函數會將其父類中的同名函數屏蔽掉

  • 如果派生類想通過自身類型使用基類中重載版本,派生類最好重定義所有重載版本

解決方案1:
在派生類中通過using爲父類函數成員提供聲明,這樣,派生類不用重定義所繼承的每一個基類版本。

class Derived:public Base{
public:
    using Base::print;  // 使用這條語句即可繼承父類所有print()函數的重載
    void print(){
        cout<<"print() in Derived"<<endl;
    }
};

解決方案2:
通過基類指針調用

int main() {  
  Derived d;  
  Base* bp = &d;   
  d.print();  
  bp->print(10);    // 這裏可沒有進行虛函數,執行的還是父類中的函數。
  bp->print("");    // 
  return 0;  
} 

解決方案3:
在派生類中需要重載的那個版本在基類中聲明爲virtual實現動態綁定,就能統一的使用基類指針來調用了:

class Base{
public:
     virtual void print(){
        cout<<"print() in Base"<<endl;
    }
    
    void print(int x){
        cout<<"print(int x) in Base"<<endl;
    }
    void print(string s)
    {
        cout<<"print(string s) in Base"<<endl;
    }
    
};
class Derived:public Base{
public:
    void print(){
        cout<<"print() in Derived"<<endl;
    }
};
int main()
{
    Derived d;
    Base *pb = &d;
    pb->print(); //動態綁定到Derived的函數
    pb->print(1);
    pb->print("");
    return 0;
}

個人覺得,第三種方法最爲自然,並且最好還是自己顯式地將函數全部進行重載(不要只重載一個)。

虛函數動態綁定的實現原理

  • 多態類型與非多態類型

    • 有虛函數的類類型稱爲多態類型
    • 其它類型皆爲非多態類型
  • 二者的差異
    多態類型支持運行時類型識別;多態類型對象佔用額外的空間

  • 虛表(virtual table)
    每個多態類有一個虛表(virtual table),虛表中有當前類的各個虛函數的入口地址

  • 虛指針(virtual point)
    每個對象有一個指向當前類的虛表的指針(虛指針vptr)

  • 動態綁定的實現
    構造函數中爲對象的虛指針賦值
    通過多態類型的指針或引用調用成員函數時,通過虛指針找到虛表,進而找到所調用的虛函數的入口地址,通過該入口地址調用虛函數。

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章