首頁 > 軟體

超級詳細講解C++中的多型

2022-05-31 14:00:13

多型概念引入

多型字面意思就是多種形態。

我們先來想一想在日常生活中的多型例子:買票時,成人買票全價,如果是學生那麼半價,如果是軍人,就可以優先買票。不同的人買票會有不同的實現方法,這就是多型。

1、C++中多型的實現

1.1 多型的構成條件

C++的多型必須滿足兩個條件:

1 必須通過基礎類別的指標或者參照呼叫虛擬函式

2 被呼叫的函數是虛擬函式,且必須完成對基礎類別虛擬函式的重寫

我們來看看具體實現。

class Person //成人
{
  public:
  virtual void fun()
   {
       cout << "全價票" << endl; //成人票全價
   }
};
class Student : public Person //學生
{
   public:
   virtual void fun() //子類完成對父類別虛擬函式的重寫
   {
       cout << "半價票" << endl;//學生票半價
   }
};
void BuyTicket(Person* p)
{
   p->fun();
}

int main()
{
   Student st;
   Person p;
   BuyTicket(&st);//子類物件切片過去
   BuyTicket(&p);//父類別物件傳地址
}

呼叫的兩個BuyTicket() 答案是什麼呢?

如果不滿足多型呢?

這說明了很重要的一點,如果滿足多型,編譯器會呼叫指標指向物件的虛擬函式,而與指標的型別無關。如果不滿足多型,編譯器會直接根據指標的型別去呼叫虛擬函式。

1.2 虛擬函式

用virtual修飾的關鍵字就是虛擬函式。

虛擬函式只能是類中非靜態的成員函數。

virtual void fun() //error! 在類外面的函數不能是虛擬函式
{}

1.3虛擬函式的重寫

子類和父類別中的虛擬函式擁有相同的名字,返回值,參數列,那麼稱子類中的虛擬函式重寫了父類別的虛擬函式,或者叫做覆蓋。

class Person
{
  public:
   virtual void fun()
   {
      cout << "Person->fun()" << endl;
   }
};
class Student
{
   public:
   //子類重寫的虛擬函式可以不加virtual,因為子類繼承了父類別的虛擬函式,
   //編譯器會認為你是想要重寫虛擬函式。
   //void fun() 可以直接這樣,也對,但不推薦。           
   virtual void fun()//子類重寫父類別虛擬函式
   {
     cout << "Student->fun()" << endl;
   }
};

虛擬函式重寫的兩個例外:

協變:

子類的虛擬函式和父類別的虛擬函式的返回值可以不同,也能構成過載。但需要子類的返回值是一個子類的指標或者參照,父類別的返回值是一個父類別的指標或者參照,且返回值代表的兩個類也成繼承關係。這個叫做協變。

class Person
{
  public:
   virtual Person* fun()//返回父類別指標
   {
      cout << "Person->fun()" << endl;
      return nullptr;
   }
};
class Student
{
   public:
            //返回子類指標,雖然返回值不同,也構成重寫
   virtual Student* fun()//子類重寫父類別虛擬函式
   {
     cout << "Student->fun()" << endl;
     return nullptr;
   }
};

也可以這樣,也是協變,

class A
{};
class B : public A
{};   //B繼承A
class Person
{
  public:
   virtual A* fun()//返回A類指標
   {
      return nullptr;
   }
};
class Student
{
   public:
            //返回B類指標,雖然返回值不同,也構成重寫
   virtual B* fun()//子類重寫父類別虛擬函式
   {
     return nullptr;
   }
};

2.解構函式的重寫

解構函式是否需要重寫呢?

讓我們來考慮這樣一種情況,

//B繼承了A,他們的解構函式沒有重寫。
class A
{
  public:
  ~A()
  {
     cout << "~A()" << endl;
  }
};
class B : public A
{
  public:
  ~B()
  {
    cout << "~B()" << endl;
  }
};

 A* a = new B; //把B的物件切片給A型別的指標。
 delete a; //呼叫的是誰的解構函式呢?你希望呼叫誰的呢?

顯然我們希望呼叫B的解構函式,因為我們希望解構函式的呼叫跟指標指向的物件有關,而跟指標的型別無關。這不就是多型嗎?但是結果卻呼叫了A的解構函式。

所以解構函式要實現多型。But,解構函式名字天生不一樣,怎麼實現多型?

實際上,解構函式被編譯器全部換成了Destructor,所以我們加上virtual就可以。

只要父類別的解構函式用virtual修飾,無論子類是否有virtual,都構成解構。

這也解釋了為什麼子類不寫virtual可以構成重寫,因為編譯器怕你忘記解構。

class A
{
  public:
 virtual  ~A()
  {
     cout << "~A()" << endl;
  }
};
class B : public A
{
  public:
  virtual ~B()
  {
    cout << "~B()" << endl;
  }
};

1.4 C++11 override && final

C++11新增了兩個關鍵字。用final修飾的虛擬函式無法重寫。用final修飾的類無法被繼承。final像這個單詞的意思一樣,這就是最終的版本,不用再更新了。

class A final //A類無法被繼承
{
public:
  virtual void fun() final //fun函數無法被重寫
  {}
};

class B : public A //error
{
  public:
    virtual void fun() //error
    {
     cout << endl;
    }
};

被override修飾的虛擬函式,編譯器會檢查這個虛擬函式是否重寫。如果沒有重寫,編譯器會報錯。

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

class B : public A 
{
  public:
  //這裡我想重寫fun,但寫成了fun1,因為有override,編譯器會報錯。
    virtual void fun1() override
    {
     cout << endl;
    }
};

1.5 過載,覆蓋(重寫),重定義(隱藏)

這裡我們來理一理這三個概念。

1.過載:過載函數處在同一作用域。

函數名相同,函數列表必須不同。

2.覆蓋:必須是虛擬函式,且處在父類別和子類中。

返回值,參數列,函數名必須完全相同(協變除外)。

3.重定義:子類和父類別的成員變數相同或者函數名相同,

子類隱藏父類別的對應成員。

子類和父類別的同名函數不是重定義就是重寫。

2、抽象類

2.1 抽象類的概念

再虛擬函式的後面加上=0就是純虛擬函式,有純虛擬函式的類就是抽象類,也叫做介面類。抽象類無法範例化出物件。抽象類的子類也無法範例化出物件,除非重寫父類別的虛擬函式。

class Car
{
 public:
    virtual void fun() = 0; //不用實現,只寫介面就行。
}

這並不意味著純虛擬函式不能寫實現,只是我們大部分情況下不寫。

那麼虛擬函式有什麼用呢?

1,強制子類重寫虛擬函式,完成多型。

2,表示某些抽象類。

2.2 介面繼承和實現繼承

普通函數的繼承就是實現繼承,虛擬函式的繼承就是介面繼承。子類繼承了函數的實現,可以直接使用。虛擬函式重寫後只會繼承介面,重寫實現。所以如果不用多型,不要把函數寫成虛擬函式。

純虛擬函式就體現了介面繼承。下面我們來一道題,展現一下介面繼承。

class A
{
   public:
   virtual void fun(int val = 0)//父類別虛擬函式
   {
     cout <<"A->val = "<< val << endl;
   }
   void Fun()
   {
      fun();//傳過來一個子類指標呼叫fun()
   }
};
class B: public A
{
   public:
    virtual void fun(int val = 1)//子類虛擬函式
    {
       cout << "B->val = " << val << endl;
    }
};

B b;
A* a = &b;
a->Fun();

結果是什麼呢?

B->val = 0

子類物件切片給父類別指標,傳給Fun函數,滿足多型,會去呼叫子類的fun函數,但是子類的虛擬函式繼承了父類別的介面,所以val是父類別的0.

3、 多型的原理

3.1 虛擬函式表

多型是怎樣實現的呢?

先來一道題目,

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

sizeof(A)是多少?是4嗎?NO,NO,NO!

答案是8個位元組。

我們定義一個A型別的物件a,開啟偵錯視窗,發現a的內容如下

我們發現除了成員變數_a以外,還多了一個指標。這個指標是不準確的,實際上應該是_vftptr(virtual function table pointer),即虛擬函式表指標,簡稱虛表指標。在計算類大小的時候要加上這個指標的大小。那麼虛表是什麼呢?虛表就是存放虛擬函式的地址地方。每當我們去呼叫虛擬函式,編譯器就會通過虛表指標去虛表裡面查詢。

下面我們用一個小栗子來說明虛擬函式的使用會用指標。

class A
{
  public:
  void fun1()
  {}
  virtual void fun2()
  {}
};

A* ap = nullptr;
ap->fun1(); //呼叫成功,因為這是普通函數的呼叫
ap->fun2(); //呼叫失敗,虛擬函式需要對指標操作,無法操作空指標。

我們先來看看繼承的虛擬函式表。

class A
{
  public:
   virtual void fun1()
   {}
   virtual void fun2()
   {}
};
class B : public A
{
 public:
   virtual void fun1()//重寫父類別虛擬函式
   {}
   virtual void fun3()
   {}
};
A a;
B b; //我們通過偵錯看看物件a和b的記憶體模型。

子類跟父類別一樣有一個虛表指標。

子類的虛擬函式表一部分繼承自父類別。如果重寫了虛擬函式,那麼子類的虛擬函式會在虛表上覆蓋父類別的虛擬函式。

本質上虛擬函式表是一個虛擬函式指標陣列,最後一個元素是nullptr,代表虛表的結束。

所以,如果繼承了虛擬函式,那麼

1 子類先拷貝一份父類別虛表,然後用一個虛表指標指向這個虛表。

2 如果有虛擬函式重寫,那麼在子類的虛表上用子類的虛擬函式覆蓋。

3 子類新增的虛擬函式按其在子類中的宣告次序增加到子類虛表的最後。

下面來一道面試題:

虛擬函式存在哪裡?

虛擬函式表存在哪裡?

虛擬函式是帶有virtual的函數,虛擬函式表是存放虛擬函式地址的指標陣列,虛擬函式表指標指向這個陣列。物件中存的是虛擬函式指標,不是虛擬函式表。

虛擬函式和普通函數一樣存在程式碼段。

那麼虛擬函式表存在哪裡呢?

我們建立兩個A物件,發現他們的虛擬函式指標相同,這說明他們的虛擬函式表屬於類,不屬於物件。所以虛擬函式表應該存在共有區。

堆?堆需要動態開闢,動態銷燬,不合適。

靜態區?靜態區存放全域性變數和靜態變數不合適。

所以綜合考慮,把虛擬函式表也存放在了程式碼段。

3.2多型的原理

我們現在來看看多型的原理。

class Person //成人
{
  public:
  virtual void fun()
   {
       cout << "全價票" << endl; //成人票全價
   }
};
class Student : public Person //學生
{
   public:
   virtual void fun() //子類完成對父類別虛擬函式的重寫
   {
       cout << "半價票" << endl;//學生票半價
   }
};
void BuyTicket(Person* p)
{
   p->fun();
}

這樣就實現了不同物件去呼叫同一函數,展現出不同的形態。

滿足多型的函數呼叫是程式執行是去物件的虛表查詢的,而虛表是在編譯時確定的。

普通函數的呼叫是編譯時就確定的。

3.3動態繫結與靜態繫結

1.靜態繫結又稱為前期繫結(早繫結),在程式編譯期間確定了程式的行為,也稱為靜態多型,比如:函數過載

2.動態繫結又稱後期繫結(晚繫結),是在程式執行期間,根據具體拿到的型別確定程式的具體行為,呼叫具體的函數,也稱為動態多型。

我們說的多型一般是指動態多型。

這裡我附上一個有意思的問題:

就是在子類已經覆蓋了父類別的虛擬函式的情況下,為什麼子類還是可以呼叫“被覆蓋”的父類別的虛擬函式呢?

#include <iostream>
using namespace std;

class Base {
public:
	virtual void func() {
		cout << "Base funcn";
	}
};

class Son : public Base {
public:
	void func() {
		Base::func();
		cout << "Son funcn";
	}
};

int main()
{
	Son b;
	b.func();
	return 0;
}

輸出:Base func

Son func

這是C++提供的一個迴避虛擬函式的機制

通過加作用域(正如你所嘗試的),使得函數在編譯時就係結。

(這題來自:虛擬函式)

4 、繼承中的虛擬函式表

4.1 單繼承中的虛擬函式表

這裡DV繼承BV。

class BV
{
public:
	virtual void Fun1()
	{
		cout << "BV->Fun1()" << endl;
	}
	virtual void Fun2()
	{
		cout << "BV->Fun2()" << endl;
	}
};
class DV : public BV
{
public:
	virtual void Fun1()
	{
		cout << "DV->Fun1()" << endl;
	}
	virtual void Fun3()
	{
		cout << "DV->Fun3()" << endl;
	}
	virtual void Fun4()
	{
		cout << "DV->Fun4()" << endl;
	}
};

我們想個辦法列印虛表,

typedef void(*V_PTR)(); //typedef一下函數指標,相當於把返回值為void型的
//函數指標定義成 V_PTR.
void PrintPFTable(V_PTR* table)//列印虛擬函式表
{  //因為虛表最後一個為nllptr,我們可以利用這個列印虛表。
	for (size_t i = 0; table[i] != nullptr; ++i)
	{
		printf("table[%d] : %p->", i, table[i]);
		V_PTR f = table[i];
		f();
		cout << endl;
	}
}

BV b;
DV d;
	      // 取出b、d物件的前四個位元組,就是虛表的指標,
	      //前面我們說了虛擬函式表本質是一個存虛擬函式指標的指標陣列,
	      //這個陣列最後面放了一個nullptr
     // 1.先取b的地址,強轉成一個int*的指標
     // 2.再解除參照取值,就取到了b物件前4個位元組的值,這個值就是指向虛表的指標
     // 3.再強轉成V_PTR*,這是我們列印虛表函數的型別。
     // 4.虛表指標傳給PrintPFTable函數,列印虛表
     // 5,有時候編譯器資源釋放不完全,我們需要清理一下,不然會列印多餘結果。
	PrintPFTable((V_PTR*)(*(int*)&b));
	PrintPFTable((V_PTR*)(*(int*)&d));

結果如下:

4.2 多繼承中的虛擬函式表

我們先來看一看一道題目,

class A
{
public:
 virtual void fun1()
 {
   cout << "A->fun1()" << endl;
 }
 protected:
 int _a;
};
class B
{
public:
 virtual void fun1()
 {
   cout << "B->fun1()" << endl;
 } 
 protected:
  int _b;
};
class C : public A, public B
{
  public:
  virtual void fun1()
  {
    cout << "C->fun1()" << endl;
  }
  protected:
  int _c;
};

C c;
//sizeof(c) 是多少呢?

sizeof( c )的大小是多少呢?是16嗎?一個虛表指標,三個lnt,考慮記憶體對齊後確實是16.但是結果是20.

我們來看看記憶體模型。在VS下,c竟然有兩個虛指標

每個虛表裡都有一個fun1函數。

所以C的記憶體模型應該是這樣的,

而且如果C自己有多餘的虛擬函式,會按照繼承順序補在第一張虛表後面。

下面還有一個問題,可以看到C::fun1在兩張虛表上都覆蓋了,但是它們的地址不一樣,是不是說在程式碼段有兩段相同的C::fun1呢?

不是的。實際上兩個fun1是同一個fun1,裡面放的是跳轉指令而已。C++也會不犯這個小問題。

最後,我們來列印一下多繼承的虛表。

//Derive繼承Base1和Base2
class Base1
{
public:
	virtual void fun1()
	{
		cout << "Base1->fun1()" << endl;
	}
	virtual void fun2()
	{
		cout << "Base1->fun2()" << endl;
	}
};
class Base2
{
public:
	virtual void fun1()
	{
		cout << "Base2->fun1()" << endl;
	}
	virtual void fun2()
	{
		cout << "Base2->fun2()" << endl;
	}
};
class Derive : public Base1, public Base2
{
public:
	virtual void fun1()
	{
		cout << "Derive->fun1()" << endl;
	}
	virtual void fun3()
	{
		cout << "Derive->fun3()" << endl;
	}
};

列印的細節,從Base2繼承過來的虛表指標放在第一個虛表指標後面,我們想要拿到這個指標需要往後挪一個指標加上一個int的位元組,但是指標的大小跟作業系統的位數有關,所以我們可以用加上Base2的大小個位元組來偏移。

這裡注意要先強轉成char*,不然指標的加減會根據指標的型別來確定。

Derive d;
	PrintPFTable((V_PTR*)(*(int*)&d));
	PrintPFTable((V_PTR*)(*(int*)((char*)&d+sizeof(Base2))));

Ret:

總結

到此這篇關於C++多型的文章就介紹到這了,更多相關C++多型詳解內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com