C++深入理解虛擬函式
為什麼使用虛擬函式?什麼是虛擬函式?虛擬函式是為了解決什麼問題?
面向物件的三大特徵:
- 封裝
- 多型
- 繼承
- 普通虛擬函式
- 虛解構函式
- 純虛擬函式
- 抽象類
- 介面類
- 隱藏 vs 覆蓋
- 隱藏與覆蓋之間的關係
- 早繫結和晚繫結
- 虛擬函式表
什麼是多型?
相同物件收到不同訊息或不同物件收到相同訊息時產生的不同的動作。
靜態多型 vs 動態多型
[-:>靜態多型也叫做早繫結
class Rect //矩形類 { public: int calcArea(int width); int calcArea(int width,int height); };
如上面的程式碼,他們函式名相同,引數個數不同,一看就是互為過載的兩個函式
1 int main()
2 {
3 Rect.rect;
4 rect.calcArea(10);
5 rect.calcArea(10,20);
6 return 0;
7 }
程式在編譯階段根據引數個數確定呼叫哪個函式。這種情況叫做靜態多型(早繫結)
[-:>動態多型也叫做晚繫結
比如計算面積 當給圓形計算面積時使用圓形面積的計算公式,給矩形計算面積時使用矩形面積的計算公式。也就是說有一個計算面積的形狀基類,圓形和矩形類派生自形狀類,圓形與矩形的類各有自己的計算面積的方法。可見動態多型是以封裝和繼承為基礎的。
1 class Shape//形狀類 2 { 3 public: 4 double calcArea() 5 { 6 cout<<"calcArea"<<endl; 7 return 0; 8 } 9 }; 10 class Circle:public Shape //公有繼承自形狀類的圓形類 11 { 12 public: 13 Circle(double r); 14 double calcArea(); 15 private: 16 double m_dR; 17 }; 18 double Circle::calcArea() 19 { 20 return 3.14*m_dR*m_dR; 21 } 22 class Rect:public Shape //公有繼承自形狀類的矩形類 23 { 24 public: 25 Rect(double width,double height); 26 double calArea(); 27 private: 28 double m_dWidth; 29 double m_dHeight; 30 }; 31 double Rect::calcArea() 32 { 33 return m_dWidth*m_dHeight; 34 } 35 int main() 36 { 37 Shape *shape1=new Circle(4.0); 38 Shape *shape2=new Rect(3.0,5.0); 39 shape1->calcArea(); 40 shape2->calcArea(); 41 ....... 42 return 0; 43 }
如果列印結果的話,以上程式結果會列印兩行"calcArea",因為呼叫到的都是父類的calcArea函式,並不是我們想要的那樣去分別呼叫各自的計算面積的函式。如果要想實現動態多型則必須使用虛擬函式
關鍵字 virtual ->虛擬函式
用virtual去修飾成員函式使其成為虛擬函式
所以以上函式的修改部分如下
class Shape
{
public:
virtual double calcArea(){...}//虛擬函式
.... //其他部分
private:
....
};
....
class Circle:public Shape
{
public:
Circle(double r);
virtual double calcArea();//此處的virtual不是必須的,如果不加,系統會自動加
//上,如果加上則會在後續的時候看的比較明顯(推薦加上)
....
private:
....
};
....
class Rect:public Shape
{
Rect(double width,double height);
virtual double calcArea();
private
....
};
....
這樣就可以達到預期的結果了
多型中存在的問題
[-:>記憶體洩漏,一個很嚴重的問題
例如上面的程式中,如果在圓形的類中定義一個圓心的座標,並且座標是在堆中申請的記憶體,則在mian函式中通過父類指標操作子類物件的成員函式的時候是沒有問題的,可是在銷燬物件記憶體的時候則只是執行了父類的解構函式,子類的解構函式卻沒有執行,這會導致記憶體洩漏。部分程式碼如下(想去借助父類指標去銷燬子類物件的時候去不能去銷燬子類物件)
如果delete後邊跟父類的指標則只會執行父類的解構函式,如果delete後面跟的是子類的指標,那麼它即會執行子類的解構函式,也會執行父類的解構函式
class Circle:public Shape
{
public:
Circle(int x,int y,double r);
~Circle();
virtual double calcArea();
....
private:
double m_dR;
Coordinate *m_pCenter; //座標類指標
....
};
Circle::Circle(int x,int y,double r)
{
m_pCenter=new Coordinate(x,y);
m_dR=r;
}
Circle::~Circle()
{
delete m_pCenter;
m_pCenter-NULL;
}
....
int main()
{
Shape *shape1=new Circle(3,5,4.0);
shape1->calcArea();
delete shape1;
shape1=NULL;
return 0;
}
可見我們必須要去解決這個問題,不解決這個問題當使用的時候都會造成記憶體洩漏。面對這種情況則需要引入虛解構函式
虛解構函式
關鍵字 virtual ->解構函式
之前是使用virtual去修飾成員函式,這裡使用virtual去修飾解構函式,部分程式碼如下
1 class Shape
2 {
3 public:
4 ....
5 virtual ~Shape();
6 private:
7 ....
8 };
9 class Circle:public Shape
10 {
11 public:
12 virtual ~Circle();//與虛擬函式相同,此處virtual可以不寫,系統將會自動新增,建議寫上
13 ....
14 };
15 ....
這樣父類指標指向的是哪個物件,哪個物件的建構函式就會先執行,然後執行父類的建構函式。銷燬的時候子類的解構函式也會執行。
virtual關鍵字可以修飾普通的成員函式,也可以修飾解構函式,但並不是沒有限制
virtual在函式中的使用限制
- 普通函式不能是虛擬函式,也就是說這個函式必須是某一個類的成員函式,不可以是一個全域性函式,否則會導致編譯錯誤。
- 靜態成員函式不能是虛擬函式 static成員函式是和類同生共處的,他不屬於任何物件,使用virtual也將導致錯誤。
- 行內函數不能是虛擬函式 如果修飾行內函數 如果行內函數被virtual修飾,計算機會忽略inline使它變成存粹的虛擬函式。
- 建構函式不能是虛擬函式,否則會出現編譯錯誤。
虛擬函式實現原理
【:-》首先:什麼是函式指標?
指標指向物件稱為物件指標,指標除了指向物件還可以指向函式,函式的本質就是一段二進位制程式碼,我們可以通過指標指向這段程式碼的開頭,計算機就會從這個開頭一直往下執行,直到函式結束,並且通過指令返回回來。函式的指標與普通的指標本質上是一樣的,也是由四個基本的記憶體單元組成,儲存著記憶體的地址,這個地址就是函式的首地址。
【:-》多型的實現原理
虛擬函式表指標:類中除了定義的函式成員,還有一個成員是虛擬函式表指標(佔四個基本記憶體單元),這個指標指向一個虛擬函式表的起始位置,這個表會與類的定義同時出現,這個表存放著該類的虛擬函式指標,呼叫的時候可以找到該類的虛擬函式表指標,通過虛擬函式表指標找到虛擬函式表,通過虛擬函式表的偏移找到函式的入口地址,從而找到要使用的虛擬函式。
當例項化一個該類的子類物件的時候,(如果)該類的子類並沒有定義虛擬函式,但是卻從父類中繼承了虛擬函式,所以在例項化該類子類物件的時候也會產生一個虛擬函式表,這個虛擬函式表是子類的虛擬函式表,但是記錄的子類的虛擬函式地址卻是與父類的是一樣的。所以通過子類物件的虛擬函式表指標找到自己的虛擬函式表,在自己的虛擬函式表找到的要執行的函式指標也是父類的相應函式入口的地址。
如果我們在子類中定義了從父類繼承來的虛擬函式,對於父類來說情況是不變的,對於子類來說它的虛擬函式表與之前的虛擬函式表是一樣的,但是此時子類定義了自己的(從父類那繼承來的)相應函式,所以它的虛擬函式表當中管於這個函式的指標就會覆蓋掉原有的指向父類函式的指標的值,換句話說就是指向了自己定義的相應函式,這樣如果用父類的指標,指向子類的物件,就會通過子類物件當中的虛擬函式表指標找到子類的虛擬函式表,從而通過子類的虛擬函式表找到子類的相應虛擬函式地址,而此時的地址已經是該函式自己定義的虛擬函式入口地址,而不是父類的相應虛擬函式入口地址,所以執行的將會是子類當中的虛擬函式。這就是多型的原理。
函式的覆蓋和隱藏
父類和子類出現同名函式稱為隱藏。
- 父類物件.函式函式名(...); //呼叫父類的函式
- 子類物件.函式名(...); //呼叫子類的函式
- 子類物件.父類名::函式名(...);//子類呼叫從父類繼承來的函式。
父類和子類出現同名虛擬函式稱為覆蓋
- 父類指標=new 子類名(...);父類指標->函式名(...);//呼叫子類的虛擬函式。
虛解構函式的實現原理
[:->虛解構函式的特點:
- 當我們在父類中通過virtual修飾解構函式之後,通過父類指標指向子類物件,通過delete接父類指標就可以釋放掉子類物件
[:->理論前提:
- 執行完子類的解構函式就會執行父類的解構函式
原理:
如果父類當中定義了虛解構函式,那麼父類的虛擬函式表當中就會有一個父類的虛解構函式的入口指標,指向的是父類的虛解構函式,子類虛擬函式表當中也會產生一個子類的虛解構函式的入口指標,指向的是子類的虛解構函式,這個時候使用父類的指標指向子類的物件,delete接父類指標,就會通過指向的子類的物件找到子類的虛擬函式表指標,從而找到虛擬函式表,再虛擬函式表中找到子類的虛解構函式,從而使得子類的解構函式得以執行,子類的解構函式執行之後系統會自動執行父類的虛解構函式。這個是虛解構函式的實現原理。
純虛擬函式:
純虛擬函式的定義
1 class Shape
2 {
3 public:
4 virtual double calcArea()//虛擬函式
5 {....}
6 virtual double calcPerimeter()=0;//純虛擬函式
7 ....
8 };
純虛擬函式沒有函式體,同時在定義的時候函式名後面要加“=0”。
純虛擬函式的實現原理:
在虛擬函式原理的基礎上,虛擬函式表中,虛擬函式的地址是一個有意義的值,如果是純虛擬函式就實實在在的寫一個0。
含有純虛擬函式的類被稱為抽象類
含有純虛擬函式的類被稱為抽象類,比如上面程式碼中的類就是一個抽象類,包含一個計算周長的純虛擬函式。哪怕只有一個純虛擬函式,那麼這個類也是一個抽象類,純虛擬函式沒有函式體,所以抽象類不允許例項化物件,抽象類的子類也可以是一個抽象類。抽象類子類只有把抽象類當中的所有的純虛擬函式都做了實現才可以例項化物件。
對於抽象的類來說,我們往往不希望它能例項化,因為例項化之後也沒什麼用,而對於一些具體的類來說,我們要求必須實現那些要求(純虛擬函式),使之成為有具體動作的類。
近含有純虛擬函式的類稱為介面類
如果在抽象類當中僅含有純虛擬函式而不含其他任何東西,我們稱之為介面類。
- 沒有任何資料成員
- 僅有成員函式
- 成員函式都是純虛擬函式
class Shape
{
virtual double calcArea()=0//計算面積
virtual double calcPerimeter()=0//計算周長
};
實際的工作中介面類更多的表達一種能力或協議
比如
1 class Flyable//會飛
2 {
3 public:
4 virtual void takeoff()=0;//起飛
5 virtual void land()=0;//降落
6 };
7 class Bird:public Flyable
8 {
9 public:
10 ....
11 virtual void takeoff(){....}
12 virtual void land(){....}
13 private:
14 ....
15 };
16 void flyMatch(Flyable *a,Flyable *b)//飛行比賽
17 //要求傳入一個會飛物件的指標,此時鳥類的物件指標可以傳入進來
18 {
19 ....
20 a->takeoff();
21 b->takeoff();
22 a->land();
23 b->land();
24 }
例如上面的程式碼,定義一個會飛的介面,凡是實現這個介面的都是會飛的,飛行比賽要求會飛的來參加,鳥實現了會飛的介面,所以鳥可以參加飛行比賽,如果複雜點定義一個能夠射擊的介面,那麼實現射擊介面的類就可以參加戰爭之類需要會射擊的物件,有一個戰鬥機類通過多繼承實現會飛的介面和射擊的介面還可以參加空中作戰的函式呢