C++:13.多態、虛函數

多態與虛函數:

什麼是虛函數:

用virtual關鍵字聲明的函數都是虛函數。虛函數存在的唯一目的,就是爲了實現多態(動態綁定/運行時綁定)。

virtual 關鍵字只在類定義中的成員函數聲明處使用,不能在類外部寫成員函數體時使用。所有派生類中具有覆蓋關係的同名函數都將自動成爲虛函數。(派生類轉化爲的虛函數,最好也寫上virtual,清晰麼。)

靜態成員函數不能是虛函數。

再說簡單點:有virtual聲明的函數都是虛函數。如果沒有virtual,那麼派生類中的同名函數會把基類中的同名函數隱藏了。如果有,那麼派生類的同名函數(同參同返回)會在虛函數表中將基類的同名函數覆蓋掉。

什麼是多態:

        多態性可以簡單概括爲“一個接口,多種行爲”。

動態綁定(運行階段)是多態的基礎。

基類指針或引用,指向一系列的派生類對象, 調用派生類對象的同名覆蓋方法(也就是那個與基類虛函數同名同參同返回的函數),指針指向誰,就會調用誰的方法。

多態分爲兩種:

        (1)編譯時多態(也叫靜態的多態):主要通過函數的重載和模板來實現。

        (2)運行時多態(也叫動態的多態):主要通過虛函數來實現。

覆蓋:

       基類的某個成員函數爲虛函數,派生類又定義一個成員函數,函數名、形參、返回類型都與基類的成員函數相同。那麼就會用派生類的函數覆蓋掉基類的虛函數


說一下多態是如何實現的:

在代碼編譯階段產生一張虛函數表vftable。運行的時候,會載入內存,加載到數據段(rodata段,只讀),在程序的整個聲生命週期都存在。一個類的虛函數表中列出了該類的全部虛函數地址。

如果成員裏有虛函數,在成員變量裏只會多一個虛函數指針(對象的前4個字節),指向虛函數表vftable()存放虛函數地址。虛函數的個數只會影響表的大小。不會影響對象的大小。

基類有虛函數,派生類如果有同名同參數列表同返回值的函數,派生類的函數會自動變成虛函數,在虛函數表中派生類會覆蓋掉原有的函數。

補充:在虛函數表中還有一項RTTI(運行時類型) 指針。

打印類型  #include <typeinfo>
cout << typeid(p).name() << endl;   該函數打印p的類型,因爲有RTTI才能在繼承派生中實現

舉個栗子:

    #include <iostream>
    using namespace std;
    class A
    {
    public:
        int i;
        virtual void func() {}
        virtual void func2() {}
    };
    class B : public A
    {
        int j;
        void func() {}
    };
    int main()
    {
        cout << sizeof(A) << ", " << sizeof(B);
        return 0;
    }

 設 pa 的類型是 A*,則 pa->func() 這條語句的執行過程如下:

1) 取出 pa 指針所指位置的前 4 個字節,也就是虛函數指針。如果 pa 指向的是類 A 的對象,則這個地址就是類 A 的虛函數表的地址;類 B 同

2) 根據虛函數指針找到虛函數表,在其中查找要調用的虛函數的地址。

如果 pa 指向的是類 A 的對象,自然就會在類 A 的虛函數表中查出 A::func 的地址;類 B 同

類 B 沒有自己的 func2 函數,因此在類 B 的虛函數表中保存的是 A::func2 的地址,這樣,即便 pa 指向類 B 的對象,pa->func2();這條語句在執行過程中也能在類 B 的虛函數表中找到 A::func2 的地址。

3) 根據找到的虛函數的地址調用虛函數。

由以上過程可以看出,只要是通過基類指針或基類引用調用虛函數的語句,就一定是多態的,也一定會執行上面的查表過程,哪怕這個虛函數僅在基類中有,在派生類中沒有。

在虛函數表中還有一個:RTTI  運行時的類型信息
Base *p =  new Derive();
cout<<typeid(*p).name()<<endl;


動態綁定與靜態綁定:

綁定就是函數調用。

在使用的時候,用一個基類的指針指向了一個派生類的對象,如果調用這個虛函數,會先找虛函數指針,再找虛函數表,再再找虛函數地址。而這個綁定過程就是動態綁定(運行時期)。

如果你不用指針調用,而是用對象本身調用函數,不論是否是虛函數,都是靜態綁定(編譯時期)。

用指針調用如果是虛函數,指針識別的就是運行時期的類型;如果調用的是一般的函數,指針識別就是在編譯時期。

  • 沒有virtual -> 靜態綁定
  • 有 virtual 用引用或指針  ->   動態綁定
  • 有 virtual 但用對象調用->   動態綁定

純虛函數:

一般情況下,基類是不希望定義對象的。基類只是爲了將共有的屬性統一起來。

爲了實現這一目的:在基類提供的一個虛函數,爲所有派生類提供統一的虛函數接口,具體實現讓派生類自己去重寫的。

virtual void show() = 0;  // 在虛函數後面加   =  0    就是純虛函數,不用去實現。

純虛函數實際上是不存在的,引入純虛函數就是爲了便於實現多態。

擁有純虛函數的類叫做抽象類。抽象類不能實例化。一般基類都應該實現爲抽象類。

當不知道用哪個函數定義爲純虛函數的時候,我們可以將析構函數定義爲純虛函數,但需要注意的是,析構函數成純虛函數了,它在類內就不能實現了。當然編譯也就沒法通過了。解決辦法:類內不能實現,我類外實現啊。

還有一點,如果你基類寫了純虛函數,但在派生類中沒有寫對應基類純虛函數,那麼由於繼承的關係,會導致派生類也成爲純虛函數。


那麼問題來了:

1、基類在沒有更多方法的時候,把誰實現成純虛函數呢?-------->  析構函數

首先明確一個函數想要成爲虛函數     1、它得有地址,有地址才能放入虛函數表;2、得依賴對象,有對象纔會有指針,有指針才能找到虛函數表。

1)構造函數能不能是虛函數?

構造函數不依賴對象,構造函數執行完纔有對象,有對象纔有虛函數指針,所以不能是虛函數。

2)static成員方法能不能是虛函數?  virtual static

靜態函數也不依賴對象,可以直接使用類名調用,也不能是虛函數。

3)inline函數能不能是虛函數?  =>  virtual inline  

內聯函數直接在程序內展開,沒有地址,無法往虛函數表放。也不能是虛函數。

4)析構函數能不能實現成虛函數?

析構函數依賴對象,有地址。可以寫成虛函數。我們知道如果將一個函數寫爲虛函數,那麼其派生類會有一個同名的函數也成爲虛函數,兩者成覆蓋關係。但是析構函數的函數名是類名前加~。所以名字是不同的,但其實這是可以的,因爲一個類只會有一個虛函數。

2、什麼時候必須將析構函數定義爲虛函數呢?

當基類指針,指向堆上的派生類對象時。

int main()
{
	Base *p = new Derive(20);
	p->show();
	delete p;      如果析構函數不是虛函數的話,調用的時候由於p是基類指針,所以只會調用基類的析構函數,
                       而不會調用派生類的析構函數。導致資源泄露。所以必須將基類的析構函數聲明爲虛函數。
	return 0;
}

3、坑1

class Base
{
public:
	Base(int data) :ma(data){ cout << "Base()" << endl; }
	virtual ~Base() = 0;
	virtual void show(int i=10)
	{
		cout << "Base::show" << endl;
	}
private:
	int ma;
};
Base::~Base()
{
	cout << "~Base()" << endl;
}
///////////////////////////////////////////////////////////
class Derive : public Base
{
public:
	Derive(int data) :mb(data), Base(data)
	{
		cout << "Derive()" << endl;
	}
	~Derive(){ cout << "~Derive()" << endl; }
	void show(int i=20)
	{
		cout << "Derive::show i:" << i<<endl;
	}
private:
	int mb;
};

int main()
{
	const int a = 10;      用const進行類比,我們知道const定義的變量的值是不能修改的,直接修改會報錯。
	int *q = (int*)&a;        但通過指針修改地址的值,還是可以將其更改。
	*q = 20;     原因就是,編譯的時候確實沒有檢測出來修改,但運行的時候就可以將他改了。
        //////////////////////////////////////////////////////////////////
	Base *p = new Derive(20);  
	p->show();  
        結果:打印出來的是Derive::show i:10。誒呦奇怪了,我派生類定義的明明是20,爲啥調的是派生類的函數,打印出來的卻是10?
        解釋:由於參數的壓棧,是編譯階段確定的。具體調用哪個方法,是經過動態綁定,運行時才確定。
        所以可能導致使用的是基類的參數,而調用的是派生類的函數。(所以虛函數最好不要寫默認值,寫的話就寫成一樣的)
	delete p;
	return 0;
}

在問個問題:如何調用派生類的私有成員函數?
跟const是一樣的。由於方法的訪問權限是在編譯階段確定的。所以如果將基類對應的函數寫爲虛函數,那麼在使用方法時是動態綁定,在運行時確定使用哪個方法,所以由此可以調用派生類的私有成員函數。

4、坑2

class Animal
{
public:
	Animal(string name) :_name(name){}
	virtual void bark() = 0;
protected:
	string _name;
};

class Cat : public Animal
{
public:
	Cat(string name) :Animal(name){}
	virtual void bark()
	{
		cout << _name << " miao miao!" << endl;
	}
};

class Dog : public Animal
{
public:
	Dog(string name) :Animal(name){}
	virtual void bark()
	{
		cout << _name << " wang wang!" << endl;
	}
};
int main()
{
	Animal *p1 = new Cat("貓");
	Animal *p2 = new Dog("狗");

	int *pp1 = (int*)p1;
	int *pp2 = (int*)p2;

	int tmp = pp1[0];將兩個虛函數指針交換
	pp1[0] = pp2[0];
	pp2[0] = tmp;

	p1->bark();   貓 vfptr  ->   狗vftable
	p2->bark();   導致貓叫出了狗的聲音

	delete p1;
	delete p2;

	return 0;
}

5、在構造函數中,調用虛函數,是靜態綁定還是動態綁定?

析構函數和構造函數內部都不會發生動態綁定(多態)。前面提到了調用虛函數一定要有對象,而對象的生命週期在構造函數結束後一直到析構函數開始前。所以在構造與析構內部不會發生動態綁定。

6、在看一個題:

class Base
{
public:
	Base(int data) :ma(data)
	{ 
		// 1. 棧幀開闢  2.棧幀初始化 3.vftable=》vfptr裏面
		cout << "Base()" << endl; 
		clear();
		this->show();
	}
	virtual ~Base()
	{
		this->show();
		cout << "~Base()" << endl;
	}
	void clear()
	{ 
                memset(this, 0, sizeof(*this)); 
        }
	virtual void show(int i=10)
	{
		cout << "Base::show" << endl;
	}
private:
	int ma;
};
///////////////////////////////////////////////////////////
class Derive : public Base
{
public:
	Derive(int data) :mb(data), Base(data)
	{
		cout << "Derive()" << endl;
	}
	~Derive()
	{ 
		cout << "~Derive()" << endl; 
	}
	void show(int i=10)
	{
		cout << "Derive::show i:" << i<<endl;
	}
private:
	int mb;
};
int main()
{
        幀的開闢,棧幀的初始化  將虛函數表的地址寫入虛函數指針中。都是進入構造函數一開始時進行的,
        如果清空了虛函數指針,在動態綁定的時候指向基類虛函數對象的指針會報錯。
	Base *p1 = new Base(10);
	p1->show();
	delete p1;

	Base *p2 = new Derive(10);
	p2->show();
	delete p2;
	// 繼承結構中,每一層構造函數都會把自己類型的虛函數表的地址,寫到vfptr裏面

	return 0;
}

棧幀的開闢,棧幀的初始化  將虛函數表的地址寫入虛函數指針中。都是進入構造函數一開始時進行的,如果清空了虛函數指針,在動態綁定的時候指向基類虛函數對象的指針會報錯。

寫的有點多了,但還沒寫完,後續內容在下一篇裏。

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