C++的多態與虛函數(面試常見)

面向對象

首先看一下C++面向對象的三大特性:繼承、多態、封裝

所謂封裝 就是把客觀事物封裝成抽象的類,並且類可以把自己的數據和方法只讓可信的類或者對象操作,對不可信的進行信息隱藏.封裝是面向對象的特徵之一,是對象和類概念的主要特性. 簡單的說,一個類就是一個封裝了數據以及操作這些數據的代碼的邏輯實體。在一個對象內部,某些代碼或某些數據可以是私有的,不能被外界訪問。通過這種方式,對象對內部數據提供了不同級別的保護,以防止程序中無關的部分意外的改變或錯誤的使用了對象的私有部分.

所謂繼承 是指可以讓某個類型的對象獲得另一個類型的對象的屬性的方法。它支持按級分類的概念。繼承是指這樣一種能力:它可以使用現有類的所有功能,並在無需重新編寫原來的類的情況下對這些功能進行擴展. 通過繼承創建的新類稱爲“子類”或“派生類”,被繼承的類稱爲“基類”、“父類”或“超類”。繼承的過程,就是從一般到特殊的過程。要實現繼承,可以通過“繼承”(Inheritance)和“組合”(Composition)來實現.繼承概念的實現方式有二類:實現繼承與接口繼承.實現繼承是指直接使用基類的屬性和方法而無需額外編碼的能力;接口繼承是指僅使用屬性和方法的名稱、但是子類必須提供實現的能力;

所謂多態 就是向不同對象發生同一個消息,不同的對象在接收時會產生不同的行爲(即方法).


多態

在三大特徵當中,多態的描述最簡單,但是實現起來也最刁鑽。如果沒有多態的話,那麼這門語言只能稱作是基於對象,而不能叫做面向對象。

在C++中,多態的實習主要依靠虛函數。本文將重點介紹虛函數。

靜態多態 vs 動態多態

靜態多態也叫做早綁定

class Rect       //矩形類
{
public:
    int calcArea(int width);
    int calcArea(int width,int height);
};

如上面的代碼,他們函數名相同,參數個數不同,一看就是互爲重載的兩個函數

class Rect       //矩形類
int main()
{
    Rect.rect;
    rect.calcArea(10);
    rect.calcArea(10,20);
    return 0;
}

程序在編譯階段根據參數個數確定調用哪個函數。這種情況叫做靜態多態(早綁定)

動態多態也叫做晚綁定(late-binding)

比如計算面積 當給圓形計算面積時使用圓形面積的計算公式,給矩形計算面積時使用矩形面積的計算公式。也就是說有一個計算面積的形狀基類,圓形和矩形類派生自形狀類,圓形與矩形的類各有自己的計算面積的方法。可見動態多態是以封裝和繼承爲基礎的。

class Shape//形狀類
{
public:
    double calcArea()
    {
        cout<<"calcArea"<<endl;
        return 0;
    }
};
class Circle:public Shape      //公有繼承自形狀類的圓形類
{
public:
    Circle(double r);
    double calcArea();
private:
    double m_dR;
};
double Circle::calcArea()
{
    return 3.14*m_dR*m_dR;
}
class Rect:public Shape       //公有繼承自形狀類的矩形類
{
public:
    Rect(double width,double height);
    double calArea();
private:
    double m_dWidth;
    double m_dHeight;
};
double Rect::calcArea()
{
    return m_dWidth*m_dHeight;
}
int main()
{
    Shape *shape1=new Circle(4.0);
    Shape *shape2=new Rect(3.0,5.0);
    shape1->calcArea();
    shape2->calcArea();
    .......
    return 0;
}

如果打印結果的話,以上程序結果會打印兩行”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去修飾析構函數,部分代碼如下

class Shape
{
public:
    ....
    virtual ~Shape();
private:
    ....
};
class Circle:public Shape
{
public:
    virtual ~Circle();//與虛函數相同,此處virtual可以不寫,系統將會自動添加,建議寫上
    ....
};
....

這樣父類指針指向的是哪個對象,哪個對象的構造函數就會先執行,然後執行父類的構造函數。銷燬的時候子類的析構函數也會執行。

virtual關鍵字可以修飾普通的成員函數,也可以修飾析構函數,但並不是沒有限制

virtual在函數中的使用限制
  • 普通函數不能是虛函數,也就是說這個函數必須是某一個類的成員函數,不可以是一個全局函數,否則會導致編譯錯誤。
  • 靜態成員函數不能是虛函數 static成員函數是和類同生共處的,他不屬於任何對象,使用virtual也將導致錯誤。
  • 內聯函數不能是虛函數 如果修飾內聯函數 如果內聯函數被virtual修飾,計算機會忽略inline使它變成存粹的虛函數。
  • 構造函數不能是虛函數,否則會出現編譯錯誤。

虛函數實現原理

首先:什麼是函數指針?

  指針指向對象稱爲對象指針,指針除了指向對象還可以指向函數,函數的本質就是一段二進制代碼,我們可以通過指針指向這段代碼的開頭,計算機就會從這個開頭一直往下執行,直到函數結束,並且通過指令返回回來。函數的指針與普通的指針本質上是一樣的,也是由四個基本的內存單元組成,存儲着內存的地址,這個地址就是函數的首地址。

多態的實現原理

虛函數表指針:類中除了定義的函數成員,還有一個成員是虛函數表指針(佔四個基本內存單元),這個指針指向一個虛函數表的起始位置,這個表會與類的定義同時出現,這個表存放着該類的虛函數指針,調用的時候可以找到該類的虛函數表指針,通過虛函數表指針找到虛函數表,通過虛函數表的偏移找到函數的入口地址,從而找到要使用的虛函數。

當實例化一個該類的子類對象的時候,(如果)該類的子類並沒有定義虛函數,但是卻從父類中繼承了虛函數,所以在實例化該類子類對象的時候也會產生一個虛函數表,這個虛函數表是子類的虛函數表,但是記錄的子類的虛函數地址卻是與父類的是一樣的。所以通過子類對象的虛函數表指針找到自己的虛函數表,在自己的虛函數表找到的要執行的函數指針也是父類的相應函數入口的地址。

如果我們在子類中定義了從父類繼承來的虛函數,對於父類來說情況是不變的,對於子類來說它的虛函數表與之前的虛函數表是一樣的,但是此時子類定義了自己的(從父類那繼承來的)相應函數,所以它的虛函數表當中管於這個函數的指針就會覆蓋掉原有的指向父類函數的指針的值,換句話說就是指向了自己定義的相應函數,這樣如果用父類的指針,指向子類的對象,就會通過子類對象當中的虛函數表指針找到子類的虛函數表,從而通過子類的虛函數表找到子類的相應虛函數地址,而此時的地址已經是該函數自己定義的虛函數入口地址,而不是父類的相應虛函數入口地址,所以執行的將會是子類當中的虛函數。這就是多態的原理

函數的覆蓋和隱藏

父類和子類出現同名函數稱爲隱藏

  • 父類對象.函數函數名(…); //調用父類的函數
  • 子類對象.函數名(…); //調用子類的函數
  • 子類對象.父類名::函數名(…);//子類調用從父類繼承來的函數。

父類和子類出現同名虛函數稱爲覆蓋

  • 父類指針=new 子類名(…);父類指針->函數名(…);//調用子類的虛函數。

虛析構函數的實現原理

虛析構函數的特點:
當我們在父類中通過virtual修飾析構函數之後,通過父類指針指向子類對象,通過delete接父類指針就可以釋放掉子類對象

理論前提:
執行完子類的析構函數就會執行父類的析構函數

原理:

如果父類當中定義了虛析構函數,那麼父類的虛函數表當中就會有一個父類的虛析構函數的入口指針,指向的是父類的虛析構函數,子類虛函數表當中也會產生一個子類的虛析構函數的入口指針,指向的是子類的虛析構函數,這個時候使用父類的指針指向子類的對象,delete接父類指針,就會通過指向的子類的對象找到子類的虛函數表指針,從而找到虛函數表,再虛函數表中找到子類的虛析構函數,從而使得子類的析構函數得以執行,子類的析構函數執行之後系統會自動執行父類的虛析構函數。這個是虛析構函數的實現原理。

純虛函數

定義

class Shape
{
public:
    virtual  double calcArea()//虛函數
    {....}
    virtual  double calcPerimeter()=0;//純虛函數
    ....
};

純虛函數沒有函數體,同時在定義的時候函數名後面要加“=0”。

純虛函數實現原理
在虛函數原理的基礎上,虛函數表中,虛函數的地址是一個有意義的值,如果是純虛函數就實實在在的寫一個0。

含有純虛函數的類被稱爲抽象類
含有純虛函數的類被稱爲抽象類,比如上面代碼中的類就是一個抽象類,包含一個計算周長的純虛函數。哪怕只有一個純虛函數,那麼這個類也是一個抽象類,純虛函數沒有函數體,所以抽象類不允許實例化對象,抽象類的子類也可以是一個抽象類。抽象類子類只有把抽象類當中的所有的純虛函數都做了實現纔可以實例化對象。

對於抽象的類來說,我們往往不希望它能實例化,因爲實例化之後也沒什麼用,而對於一些具體的類來說,我們要求必須實現那些要求(純虛函數),使之成爲有具體動作的類。

只含有純虛函數的類稱爲接口類
如果在抽象類當中僅含有純虛函數而不含其他任何東西,我們稱之爲接口類。

  • 沒有任何數據成員
  • 僅有成員函數
  • 成員函數都是純虛函數

實際的工作中接口類更多的表達一種能力或協議

比如

class Flyable//會飛
{
public:
    virtual void takeoff()=0;//起飛
    virtual void land()=0;//降落
};
class Bird:public Flyable
{
public:
    ....
    virtual void takeoff(){....}
    virtual void land(){....}
private:
    ....
};
void flyMatch(Flyable *a,Flyable *b)//飛行比賽
//要求傳入一個會飛對象的指針,此時鳥類的對象指針可以傳入進來
{
    ....
    a->takeoff();
    b->takeoff();
    a->land();
    b->land();
}

例如上面的代碼,定義一個會飛的接口,凡是實現這個接口的都是會飛的,飛行比賽要求會飛的來參加,鳥實現了會飛的接口,所以鳥可以參加飛行比賽,如果複雜點定義一個能夠射擊的接口,那麼實現射擊接口的類就可以參加戰爭之類需要會射擊的對象,有一個戰鬥機類通過多繼承實現會飛的接口和射擊的接口還可以參加空中作戰的函數呢。

參考

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