1. 程式人生 > >C++中的virtual關鍵字

C++中的virtual關鍵字

虛擬函式與執行多型

多型:
多型按字面的意思就是多種形態。當類之間存在層次結構,並且類之間是通過繼承關聯時,就會用到多型。C++ 多型意味著呼叫成員函式時,會根據呼叫函式的物件的型別來執行不同的函式。

先看最簡單的情況,也就是最普通形式的繼承,且父類和子類的方法都是一般成員方法:


class Car{
  public:
    Car(){cout<<"Car consstructor"<<endl;}
    ~Car(){cout<<"Car destructor"<<endl;}
    //  若將成員成員函式宣告為const,則該函式不允許修改類的資料成員
void start() const{cout<<"car start"<<endl;} void stop() const{cout<<"cat stop"<<endl;} }; //Benz類,單一繼承自Car class Benz : public Car{ public: Benz(){cout<<"Benz constructor"<<endl;} ~Benz(){cout<<"Benz destructor"<<endl;} void start
() const{cout<<"Benz start"<<endl;} void stop() const{cout<<"Benz stop"<<endl;} }; // Baoma類,單一繼承自Car class Baoma:public Car{ public: Baoma(){cout<<"Baoma constructor"<<endl;} ~Baoma(){cout<<"Baoma destructor"<<endl; } void start
() const{cout<<"Baoma constructor"<<endl;} void stop() const{cout<<"Baoma destructor"<<endl;} private: int speed; }; //以上三個類均具有start和stop的同名成員函式 //呼叫成員函式start和stop void carFunction(Car *car){ car->start(); car->stop(); } int main(int argc,char *argv[]){ Car *benz = new Benz(); cout<<sizeof(Benz)<<endl; carFunction(benz); Car *baoma = new Baoma(); cout<<sizeof(Baoma)<<endl; carFunction(baoma); delete benz; delete baoma; return 0; }

輸出結果如下:

Car consstructor
Benz constructor
1  //內部沒有成員變數,因此只有一個位元組的空間
car start
cat stop
Car consstructor
Baoma constructor
4  //函式是不佔用記憶體的,baoma中有一個int型別.所以sizeof為4
car start
cat stop
Car destructor
Car destructor

首先,為什麼Benz類內部明明沒有任何變數,還具有一個位元組的大小?這是因為C++編譯器不允許物件為零長度(試想一個長度為0的物件在記憶體中怎麼存放?怎麼獲取它的地址?)。為了避免這種情況,C++強制給這種類插入一個預設成員,長度為1。如果有自定義的變數,那麼變數將取代這個預設成員。

其次,Benz和Baoma都是繼承自Car類,根據 里氏替換原則 ,父類能夠出現的地方,那麼子類也一定能出現。依賴抽象而不去依賴具體,在上述的函式呼叫過程中,我們傳進去的是benz和baoma指標.但是在呼叫函式的時候,它並沒有去呼叫子類的方法,這也就是一般成員函式的侷限性,就是在編譯的時候,一般性的函式已經被靜態的編譯進去,所以在呼叫的時候不能去選擇動態呼叫.

里氏替換原則:派生類(子類)物件可以在程式中代替其基類(超類)物件

加入vitural關鍵字修飾的函式,將父類函式變為虛擬函式,看看變化:

//和上面幾乎一樣,都是一般的成員方法,只不過加上了virtual關鍵字

#include<iostream>
using namespace::std;


class Car{
  public:
    Car(){
      cout<<"Car consstructor"<<endl;
    }
    ~Car(){
      cout<<"Car destructor"<<endl;
    }
    virtual void start() {
      cout<<"car start"<<endl;
    }
    virtual void stop() {
      cout<<"cat stop"<<endl;
    }
};

class Benz : public Car{

  public:
    Benz(){
      cout<<"Benz constructor"<<endl;
    }
    ~Benz(){
      cout<<"Benz destructor"<<endl;
    }
    //子類繼承父類,如果是虛擬函式,可以寫上vitural也可以不寫
    virtual void start() {
      cout<<"Benz start"<<endl;
    }
    void stop() {
      cout<<"Benz stop"<<endl;
    }
};


class Baoma:public Car{
   public:
     Baoma(){
       cout<<"Baoma constructor"<<endl;
     }
     ~Baoma(){
       cout<<"Baoma destructor"<<endl;
     }
     void start() {
        cout<<"Baoma start"<<endl;
     }
     void stop() {
       cout<<"Baoma stop"<<endl;
     }
    private:
     int speed;
};



void carFunction(Car *car){
  car->start();
  car->stop();
}

int main(int argc,char *argv[]){
  Car *benz = new Benz();
  cout<<sizeof(Benz)<<endl;
  carFunction(benz);

  Car *baoma = new Baoma();
  cout<<sizeof(Baoma)<<endl;
  carFunction(baoma);

  delete benz;
  delete baoma;

  return 0;
}

輸出結果如下:


Car consstructor
Benz constructor
8
Benz start
Benz stop
Car consstructor
Baoma constructor
16
Baoma start
Baoma stop
Car destructor
Car destructor

從上面的輸出結果中可以看到,加入了虛擬函式之後,呼叫不同指標物件指定函式的時候,這個時候都是去自動呼叫當前物件類中的具體函式形式,而不是像一般函式的呼叫一樣,只是去呼叫父類的函式.這就是virtural關鍵字的作用,因為一般函式呼叫編譯的時候是靜態編譯的時候就已經決定了,加入了virtural的函式,一個類中函式的呼叫並不是在編譯的時候決定下來的,而是在執行時候被確定的,這也就是虛擬函式.

虛擬函式就是由於在由於編寫程式碼的時候並不能確定被呼叫的是基類的函式還是哪個派生類的函式,所以被 為“虛”函式。 虛擬函式只能藉助於指標或者引用來達到多型的效果, 直接宣告的類物件無法達到多型目的。

總結: 虛擬函式的呼叫取決於指向或者引用的物件的型別,而不是指標或者引用自身的型別。

注意:

  1. C++中的虛擬函式的作用主要是實現了多型的機制。關於多型,簡而言之就是用父類型別的指標指向其子類的例項,然後通過父類的指標呼叫實際子類的成員函式。
  2. 對C++ 瞭解的人都應該知道虛擬函式(Virtual Function)是通過一張虛擬函式表(Virtual Table)來實現的。簡稱為V-Table。在這個表中,主是要一個類的虛擬函式的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應實際的函式。這樣,在有虛擬函式的類的例項中這個表被分配在了這個例項的記憶體中,所以,當我們用父類的指標來操作一個子類的時候,這張虛擬函式表就顯得由為重要了,它就像一個地圖一樣,指明瞭實際所應該呼叫的函式
  3. 帶有虛擬函式的物件自身確實插入了一些指標資訊,而且這個指標資訊並不隨著虛擬函式的增加而增大,這也就是為什麼上述增加了虛擬函式後,出現了size變大的現象

虛擬函式控制下的執行多型有什麼用?

假如我們在公司的人事管理系統中定義了一個基類 Employee(員工),裡面包含了升職、加薪等虛擬函式。 由於Manager(管理人員)和Engineer(工程人員)的加薪和晉升流程是不一樣的,因此我們需要實現一些繼承類並重寫這些函式。

有了上面這些以後,到了一年一度每個人都要加薪的時候,我們只需要一個簡單的操作就可以完成,如下所示

void globalRaiseSalary(Employee *emp[], int n){
  for (int i = 0; i < n; i++)
    emp[i]->raiseSalary();  // 會根據emp具體指向的物件型別,來選擇合適的函式行為
    // Polymorphic Call: Calls raiseSalary()
    // according to the actual object, not according to the type of pointer
}

虛擬函式使得我們可以建立一個統一的基類指標,並且呼叫不同子類的函式而無需知道子類物件究竟是什麼

虛擬函式表與虛擬函式表指標

C++中虛擬函式這種多型的性質是通過虛擬函式指標和一張虛擬函式表來實現的:

  • vtable(虛擬函式表):每一個含有虛擬函式的類都會維護一個虛擬函式表,裡面按照宣告順序記錄了虛擬函式的地址
  • vptr(虛擬函式表指標):一個指向虛擬函式表的指標,每個物件都會擁有這樣的一個指標

先看看下面這個簡單的例子:

class A
{
public:
    virtual void fun();
};

class B
{
public:
   void fun();
};

sizeof(A) > sizeof(B) // true,因為A比B多了一個虛擬函式指標

下面再來看看剛剛那個加薪的例子,其多型呼叫的形式如下圖:

通常情況下,編譯器在下面兩處地方新增額外的程式碼來維護和使用vptr:

  1. 在每個建構函式中。此處新增的程式碼會設定被建立物件的虛擬函式表指標指向對應類的虛擬函式表
  2. 在每次進行多型函式呼叫時。 無論合適呼叫了多型函式,編譯器都會首先查詢vptr指向的地址(也就是指向物件對應的類的虛擬函式表),一旦找到後,就會使用該地址記憶體儲的函式(而不是基類的函式)。

虛擬函式中的預設引數

先看下面的程式碼


#include <iostream>
using namespace std;

class Base
{
public:
    virtual void fun ( int x = 0 )
    {
        cout << "Base::fun(), x = " << x << endl;
    }
};

class Derived : public Base
{
public:
    // 這裡的virtual關鍵字可以省略,因為只要基類裡面被宣告為虛擬函式,那麼在子類中預設都是虛的
    virtual void fun ( int x )// 或者定義為 virtual void fun ( int x = 10)
    {
        cout << "Derived::fun(), x = " << x << endl;
    }
};


int main()
{
    Derived d1;
    Base *bp = &d1;
    bp->fun();
    return 0;
}

上面的程式碼輸出始終為:

Derived::fun(), x = 0

解釋:

  • 首先,引數的預設值是不算做函式簽名的,因此,即使基類有預設值,子類沒有,這兩個函式的函式簽名仍然被認為是相同的,所以在呼叫bp->fun();,仍然呼叫了子類的fun函式,但是因為沒有給出x的值,所以採用了基類函式給出的預設值0.
  • 當基類給出預設值0,子類給出預設值10時,返回結果仍然是預設值0,這是因為,引數的預設值是靜態繫結的,而虛擬函式是動態繫結的,因此, 預設引數的使用需要看指標或者引用本身的型別,而不是指向物件的型別。

小結:根據上面的分析,在虛擬函式中最好不要使用預設引數,否則很容易引起誤會!

靜態函式可以被宣告為虛擬函式嗎

靜態函式不可以宣告為虛擬函式,同時也不能被const和volatile關鍵字修飾。如下面的宣告都是錯誤的:

virtual static void fun(){}

static void fun() const {} // 函式不能被const修飾,但是返回值可以

原因主要有兩個方面:

  • static成員函式不屬於任何類物件或類例項,所以即使給此函式加上virtual也是沒有意義的
  • 虛擬函式依靠vptr和vtable來處理,vptr是一個指標,在類的建構函式中建立生成,並且智慧用this指標來訪問它,靜態成員函式沒有this指標,所以無法訪問vptr。

建構函式可以為虛擬函式嗎

建構函式不可以宣告為虛擬函式。同時除了inline之外,建構函式不允許使用其他任何關鍵字,原因如下:

  • 儘管虛擬函式表vtable是在編譯階段就已經建立的,但指向虛擬函式表的指標vptr是在執行階段例項化物件時才產生的。 如果類含有虛擬函式,編譯器會在建構函式中新增程式碼來建立vptr。 問題來了,如果建構函式是虛的,那麼它需要vptr來訪問vtable,可這個時候vptr還沒產生。 因此,建構函式不可以為虛擬函式。
  • 我們之所以使用虛擬函式,是因為需要在資訊不全的情況下進行多型執行。而建構函式是用來初始化例項的,例項的型別必須是明確的。 因此,建構函式沒有必要被宣告為虛擬函式。

解構函式可以為虛擬函式嗎

解構函式可以宣告為虛擬函式。如果我們需要刪除一個指向派生類的基類指標時,應該把解構函式宣告為虛擬函式。事實上,只要一個類有可能會被其他類所繼承,就應該宣告虛解構函式(哪怕該解構函式不執行任何操作)。原因可以見先秒的程式碼:

#include<iostream>

using namespace std;

class base {
  public:
    base()     
    { cout<<"Constructing base \n"; }

    // virtual ~base()
    ~base()
    { cout<<"Destructing base \n"; }     
};

class derived: public base {
  public:
    derived()     
    { cout<<"Constructing derived \n"; }
    ~derived()
    { cout<<"Destructing derived \n"; }
};

int main(void)
{
  derived *d = new derived();  
  base *b = d;
  delete b;
  return 0;
}

以上程式碼輸出:

Constructing base
Constructing derived
Destructing base

可見,繼承類的解構函式沒有被呼叫,delete時只根據指標型別呼叫了基類的解構函式。 正確的操作是,基類和繼承類的解構函式都應該被呼叫,解決方法是將基類的解構函式宣告為虛擬函式。

虛擬函式可以為私有函式嗎

虛擬函式可以被私有化,但有一些細節需要注意

#include<iostream>
using namespace std;

class Derived;

class Base {
private:
    virtual void fun() { cout << "Base Fun"; }
friend int main();
};

class Derived: public Base {
public:
    void fun() { cout << "Derived Fun"; }
};

int main()
{
   Base *ptr = new Derived;
   ptr->fun();
   return 0;
}

輸出結果為:

Derived fun()
  • 基類指標指向繼承類物件,則呼叫繼承類物件的函式
  • int main()必須宣告為Base類的友元,否則編譯失敗。編譯器報錯:ptr無法訪問私有函式。當然,把基類宣告為public,繼承類為private,該問題就不存在了。

虛擬函式可以被內聯嗎

通常類成員函式都會被編譯器考慮是否進行內聯。但通過基類指標或者引用呼叫的虛擬函式必定不能被內聯。當然,實體物件呼叫虛擬函式或者靜態呼叫時可以被內聯,虛解構函式的靜態呼叫也一定會被內聯展開。

純虛擬函式與抽象類

純虛擬函式:在基類中只宣告不定義的虛擬函式,同時要求任何派生類都要實現該虛擬函式。在基類中實現純虛擬函式的方法是在函式原型後加“=0”。

抽象類:含有純虛擬函式的類為抽象類

純虛擬函式的特點以及用途總結如下:

  • 如果不在繼承類中實現該函式,則繼承類仍為抽象類;
  • 派生類僅僅只是繼承純虛擬函式的介面,因此使用純虛擬函式可以規範介面形式
  • 抽象類無法例項化物件
  • 抽象類可以有建構函式
  • 解構函式被宣告為純虛擬函式是一種特例,允許其有具體實現。(有些時候,想要使一個類稱為抽象類,但剛好有沒有任何合適的純虛擬函式,最簡單的方法就是宣告一個純虛的解構函式)