C++中的繼承&&多態博客

前言

是因爲有了繼承纔有了多態的存在,我們明白繼承的原理和特性才能去理解多態

文章目錄

繼承

什麼叫做繼承

繼承機制是面向對象程序設計中實現代碼複用的手段,它是我們能在原有的類的基礎上,實現一個新的派生類,並擴展其他的功能和屬性。

繼承的定義

派生類繼承基類有三中繼承方式:公有繼承(public),保護基礎(protected),私有繼承(private)

class Person
{
    public:
    string name;
    protected:
    int age;
    private:
    int weight;
};

class Student:public Person
{
   public:
   string id;
};

Student類公有繼承了Person類

繼承方式導致訪問方式的變化

基類類成員/繼承方式 public繼承 protectd繼承 private繼承
基類的公有成員 成爲派生類公有成員 成爲派生類保護成員 成爲派生類私有成員
基類的保護成員 成爲派生類保護成員 成爲派生類保護成員 成爲派生類私有成員
基類的私有成員 在派生類中不可見 在派生類中不可見 在派生類中不可見

總結

(1) 基類的私有成員無論什麼方式繼承在派生類中都是不可見的,但是在基類中依然是有內存的,派生類對象類內和內外都不能訪問它。

(2) protectd繼承其實是爲了繼承而出現的,基類的private成員在派生類中是不能被訪問的,如果基類成員不想在類外直接被訪問,但需要在派生類中能訪問,就定義爲protected。

(3) 其實有一個這樣的關係 public>protected>private,基類的其他成員在子類的訪問方式==Min(成員在基類的訪問限定符,繼承方式)

(4) class默認的繼承方式是private,struct默認的繼承方式是public

(5) 其實protected/private繼承真的甚少用,因爲這樣的繼承維護性和擴展性不強

切片-基類和派生類對象賦值轉換

派生類對象可以賦值給基類的對象/基類的指針/基類的引用。其實就是派生類中父類的部分切來賦值過去。

基類的對象不能賦值給派生類對象

基類的指針可以通過強制類型轉換複製給派生類指針,但是基類的指向派生類對象的時候纔是安全的,不然就會出現內存訪問錯誤,可以使用RTTI 來進行識別後進行安全轉換。

繼承中的作用域(同名問題)

同名的情況一定要少出現

父子都是獨立作用域

(1) 在繼承體系中的基類和派生類都是獨立的作用域

同名會被隱藏,可以顯示訪問

(2) 子類和父類有同名成員,子類成員將屏蔽對父類成員的直接訪問,這叫做隱藏(重定義),注意隱藏是它還可以存在,如果想訪問被隱藏的成員,可以顯式訪問: 基類::基類成員

函數同名就構成隱藏

(3) 對於成員函數,只要同名就構成隱藏了

派生類的默認成員函數

默認成員函數是指我們不寫就給我默認生產的函數

構造函數

派生類的構造函數必須調用基類的構造函數初始化基類的成員,如果基類沒有默認構造函數,則必須在派生類構造的初始化列表階段顯示調用。

拷貝構造函數

派生類的拷貝構造函數必須調用基類的拷貝構造函數完成拷貝初始化

operator=

派生類的operator=必須要調用基類的operator=完成基類的複製

析構函數

派生類的析構函數會在被調用完成後自動調用基類的析構函數,這樣才能保證派生類對象先清理派生類成員在清理基類成員的順序。

const對象取地址函數

const對象不能被修改,所以可以重載這個函數,返回一個假地址來保護對象

普通對象取地址函數

實現不能被繼承的類

傳統寫法

思路

將基類的構造函數私有化,因爲派生類初始化對象的時候需要先調用基類的構造函數,而如果將基類構造函數設置爲私有,派生類無法執行這個過程

這樣寫的話,要爲基類提供一個靜態成員函數結構,實現創建一個對象功能。

實現

class A
{
  public:
  static A GetInstance()
  {
      return A();
  }
  private:
  A();
};

C++11

思路

C++11提供了final關鍵字 加在類後面表示最終類,無法被繼承

實現

class A final
{}

友元出現在繼承中

友元關係是不能被繼承的,你爸爸的朋友不一定是你的朋友

靜態成員出現在繼承中

基類定義了static靜態成員,則整個繼承體系裏面只有一個這樣的成員。無論派生出多少個子類,都只有一個static成員實例。

複雜的繼承(菱形繼承和菱形虛擬繼承)

單繼承

很簡單就是一個派生類繼承了一個基類

多繼承

A繼承了B,同時也繼承了C

class B
{};
class C
{};

class A:public B,public C
{};

多繼承情況下的切片和多繼承不一樣,多繼承的切片基類A的指針會指向A的部分,B基類的指針會指向B的部分,而不是都指向地址的開始位置。同理對於基類的引用/賦值給基類對象也是一個道理。

菱形繼承

繼承的關係就像一個菱形一樣
A,B,C,D四個類,

B繼承了D , C也繼承了D, A繼承了B和C。

class D
{
    public:
    int d;
};

class B:public D
{};
class C:public D
{};

class A:public B,public C
{};

這種繼承是明顯有數據冗餘和數據的二義性問題的。A對象中D的成員會有兩份。

如果要訪問D中的d必須要通過顯式訪問

int main()
{
    A a;
    a.B::d;
    a.C::d;
    return 0;
}

虛擬菱形繼承

虛擬繼承作用和使用

虛擬繼承可以解決菱形的二義性和數據冗餘問題

上面的例子我們使用虛擬繼承就不會出現二義性問題

class D
{
    public:
    int d;
};

class B:virtual public D
{};
class C:virtual public D
{};

class A:public B,public C
{};

因爲是虛擬繼承D中的成員只有一份
訪問數據就不會出現二義性了,也不會有數據冗餘了。

int main()
{
    A a;
    a.d;
}

虛擬繼承的實現原理

虛擬繼承的公共對象會被放到對象的最後面

虛擬繼承基類的派生類裏面會多出一個指針變量,這個指針變量指向一個表,這個表中存放了找到公共成員位置的偏移量。

image

繼承和組合的討論

繼承和組合的區別

繼承是一種is-a的關係,就是說每個派生類對象都是基類的對象

組合是has-a的關係,假設B組合了A,每個對象都有一個A的對象

推薦使用組合

繼承允許你根據基類的實現來定於派生類的實現,這種通過派生類的複用通常被稱爲白箱複用(對可視性而言),在繼承方式中,基類的內部細對子類是可見的,在一定程度上這破壞了基類的封裝,基類的改變也勢必導致派生類的改變,耦合度太高,依賴關係強

組合也稱爲黑箱複用,對象組合要求被組合的對象具有良好的定義接口,這種複用風格被稱爲黑箱複用,因爲對象的內部細節不可見,組合類之間沒有很強的依賴關係,耦合度低,優先使用對象組合有助於保持每個類被封裝,

要實現多態就必須要用繼承

一般情況下能用組合就用組合,多態的情況就需要是用繼承了

繼承和組合對比

繼承 高耦合,破壞基類封裝,依賴關係強

組合 低耦合,保護了封裝,但是需要被組合的類有良好的接口

吐個槽

C ++ 就不應該出現菱形繼承,爲了解決的它,有虛擬菱形繼承,但是這導致在性能上很差,這個反而是C++的缺陷,其他OO語言(面向對象)就沒有多繼承,比如java

多態

多態存在的意義

我們買票的時候,針對不同的人,比如說學生和成人買票,票的價格是不一樣的,這其實就是一種多態

多態的定義和實現

多態的實現要藉助虛函數,實現對函數的覆蓋(重寫)

虛函數

成員函數前加上virtual,這個成員函數就是一個虛函數

虛函數的重寫條件

一般情況下,函數的名字,類型,返回值都必須相同,如果只是函數名相同就變成了隱藏了(繼承中講到的),兩個函數必須是虛函數。

當然也有特殊情況,下面介紹不同種情況下的重載。

正常重寫

class Person {
public:
virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "買票-半價" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}

重寫的例外協變

這是一個例外,當返回值一個是基類指針一個是派生類指針的時候,可以構成重寫

class Person {
public:
virtual A* f() {return new A;}
};
class Student : public Person {
public:
virtual B* f() {return new B;}
};

不規範的重寫

派生類的函數可以不加virtual,基類加virtual,這樣也可以構成重寫,但是這樣非常不規範,我們平時不要這樣使用。

class Person {
public:
virtual void BuyTicket() {cout << "買票-全價" << endl;}
};
class Student : public Person {
public:
void BuyTicket() {cout << "買票-半價" << endl;}
};

爲什麼把析構函數也定義爲虛函數

看如下例子即可明白

class Person {
public:
~Person() {cout << "~Person()" << endl;}
};
class Student : public Person {
public:
~Student() { cout << "~Student()" << endl; }
};

int main()
{
    Person* p = new Student();
    delete p;
    return 0;
}

因爲p調用析構函數只與類型有關,p是Person類型的指針,delete就只會析構基類部分,只調用Person

雖然析構函數的名字不一樣,但是其實編譯後的名稱都是destructor,這裏可以理解爲特殊處理,所以也算重寫

接口繼承和實現繼承

多態就是接口繼承,子類繼承父類的某個函數並不是爲了要父類的實現,而是要父類的接口,然後自己再重寫這個函數

繼承就是實現繼承,子類就是要繼承父類的某個函數的實現,子類就是需要已經實現好了的

重載,覆蓋,隱藏的對比

重載:

兩個函數在同一個作用域

函數的重載,操作符的重載,要求函數名必須得相同,同時參數列表必須不同

隱藏(重定義)

兩個函數分別在基類和派生類作用域

隱藏那是繼承裏面的東西,只要函數名相同並且不滿足覆蓋(重寫)的條件,派生類就會隱藏基類的同名函數。

覆蓋(重寫)

兩個函數分別在基類和派生類的作用域

函數名/參數/返回值都必須相同(協變例外)

兩個函數必須是虛函數

抽象類

概念

包含純虛函數,包含純虛函數的類叫做抽象類,抽象類不能實例化出對象,派生類繼承後也不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象,純虛函數規範了派生類必須重寫,純虛函數也更加體現了接口繼承。

定義實現

汽車必須要輪胎,但是具體使用哪個輪子由派生類實現

class Car
{
    public:
    virtual void  tyre() = 0;
};

class Benz :public Car
{
    public:
    virtual void  tyre()
    {
        cout << "Benz-越野輪胎" << endl;
    }
};

class BMW :public Car
{
    public:
    virtual void  tyre()
    {
        cout << "BMW-花紋輪胎" << endl;
    }
};

void Test()
{
    Car* pBenz = new Benz;
    pBenz-> tyre();
    Car* pBMW = new BMW;
    pBMW-> tyre();
}

C++11 override 和 final

final阻止類的進一步派生和虛函數的進一步重寫

override確保在派生類中聲明的函數跟基類的虛函數有相同的簽名

override 相當於爲重寫檢查語法

實際中我們建議多使用純虛函數+ overrid的方式來強制重寫虛函數,因爲虛函數的意義就是實現多態,如果
沒有重寫,虛函數就沒有意義

相當於爲重寫加上一個語法檢查機制

class Car{
public:
    virtual void Drive(){}
};


//加上override後相當於加上了一個重寫的語法檢查
class Benz :public Car {
public:
virtual void Drive() override {
    cout << "Benz-舒適" << endl;}
};

//這樣寫就是不正確的,因爲不滿足重寫的條件
class Benz :public Car {
public:
virtual int Drive() override {
    cout << "Benz-舒適" << endl;
    return 0;
}
};

final 禁止虛函數被重寫

修飾基類的虛函數不能被派生類重寫

class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() {cout << "Benz-舒適" << endl;}
};

上面對於Drive的重寫會報錯,因爲語法不允許

純虛函數的作用

純虛函數可以實現抽象類,而抽象類是爲了實現接口繼承,其實目的是爲了實現多態。

多態實現的原理

虛表指針

基類中定義了虛函數,派生類對象和基類的對象中會多處一個虛表指針
,這個虛表指針指向一個虛函數表

虛函數表

虛函數表本質是一個存虛函數指針的指針數組,這個數組最後面放了一個nullptr。

通過查看彙編代碼我們可以看到虛函數的調用過程

//eax獲得對象的指針
mov     eax,dword ptr [p]
//edx獲得對象的第一個字節的內容
mov     edx,dword ptr[eax]
//ecx加載this指針
mov     ecx,dword ptr[p]
//獲得對應的虛函數指針,如果對應的虛函數指針是第二個位置,就會執行 eax,dword ptr [edx+4]
mov     eax,dword ptr [edx]
//調用對應的虛函數
call    eax 

父類虛函數表和子類虛函數表關係和區別

(1) 總結一下派生類的虛表生成:a.先將基類中的虛表內容拷貝一份到派生類虛表中 b.如果派生類重寫了基
類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數,派生類自己新增加的虛函數按其在,派生類中的聲明次序增加到派生類虛表的最後。(監視窗口可能看不到)

//查看看不到的虛函數指針,可以通過打印虛函數表來判斷

(2) 覆蓋其實就是虛函數表中的函數地址被替換了

(3) 派生類首先繼承了父類的虛函數表,然後根據自己是否重寫虛函數來判斷是否要修改或者添加虛函數表中的內容

(4) 使用對象接收的時候是不會產生多態現象的,因爲在拷貝構造的時候不會拷貝虛函數指針

//實參是一個對象,不會有多態現象
void Func(Person p)
{
    p.BuyTicket();
}

int main()
{
    Student mike;
    Func(mike);
    mike.BuyTicket();
    return 0;
}

虛函數表存在哪

(1) 驗證虛函數表存在哪個位置:直接打印出對象的表的值,就是對象的第一個字節,然後再打印一個函數地址比較,現象如果比較近,說明虛函數表和函數一樣,都是存放在代碼區,反之則不是。

(2) 對象的虛函數表指針是在調用拷貝構造函數的時候初始化的

動態綁定和靜態綁定

通過彙編代碼分析,可以看出,滿足多態以後的調用函數,不是在編譯時確定的,是運行起來以後通過對象的虛函數指針找到函數的地址的,而不滿足多態的函數調用時是編譯時就確定好的。

靜態綁定–早起綁定

在編譯器器件就確定的程序行爲,也叫靜態多態,比如說函數重載

動態綁定–後期綁定

在程序運行期間,根據具體拿到的類型確定程序的具體行爲,調用具體的函數,也稱爲動態多態。

父類指針和引用可以實現多態,但是父類對象不能實現多態

class Person {
public:
    virtual void BuyTicket() 
    {
        cout << "買票-全價" <<     endl;
    }
};

class Student : public Person {
    public:
    virtual void BuyTicket() {
        cout << "買票-半價"<<     endl; 
    }
};

void Func(Person p)//用父類對象來接受子類對象是不會實現多態的
//因爲父類對象拷貝了子類對象的時候,沒有拷貝虛函數表指針,所以父類依然是用的父類的虛函數表中的虛函數
{
    p.BuyTicket();
}

int main()
{
    Person Mike;
    Func(Mike);
    Student Johnson;
    Func(Johnson);
    return 0;
}

多繼承和多繼承中的虛函數表

多繼承中派生類會有兩個基類的虛函數指針,如果派生類再定義一個虛函數,新的虛函數的指針會儲存在其中一張虛函數表中,而不是兩個虛函數都添加
,記住:只在其中一個虛函數表添加,只在其中一個虛函數表中添加,只在其中一個虛函數表中添加,重要的事情說三遍!!!

#include <iostream>
#include <windows.h>
#include <stdlib.h>

using namespace std;


class A
{
public:
	virtual void fun1()
	{
		cout << "A::fun1()" << endl;
	}
	int a;
};

class B:virtual public A
{
public:
	int b;
};

class C : virtual public A
{
public:
	int c;
};

class D :public B, public C
{
public:
	int d;
};


int main()
{
	D w;
	w.a = 10;
	system("pause");
	return 0;
}

image

小結

構成多態由對象決定,對象裏面的虛函數表不一樣

不構成多態由類型決定

繼承和多態我們一定要知道

什麼是多態?

多態就是同一個行爲不同對象去做的時候產生不同的結果,就比如說同樣是人,買票的時候成人全票,學生半票。

搞清楚重載和重寫(覆蓋)和重定義(隱藏)是什麼,不要混淆

重載: 一個作用域下的兩個函數,他們的函數相同參數不相同就構成了重載

重寫: 兩個函數,一個在父類作用域,一個在子類作用域,他們的函數名,參數,返回值都相同,並且父類中的函數是個虛函數,或者兩個都是虛函數,就構成重寫,函數的重寫是實現多態的條件。

重定義: 也是兩個函數,一個父類作用域,一個是子類作用域,他們的函數名相同,參數不同,換句話說,父類子類作用域下沒有滿足重寫的條件,就是重定義。

實現多態的原理

其實實現多態的原理就是因爲有了虛函數表的存在,虛函數表中記錄了屬於對象的行爲。

inline函數可以是虛函數嗎

當然不可以是,在內斂成功的情況下,內斂函數連函數的地址都沒有,無法放到虛函數表中。

靜態成員可以是虛函數嗎

不可以,因爲靜態成員函數沒有this指針,而訪問虛函數表是需要對象的。因爲對象中存了虛函數表的地址

構造函數是可以定義爲虛函數嗎

不可以,虛函數表指針都是調用構造函數的時候初始化的,如果可以那虛函數指針都沒初始化好,怎麼調用構造函數呢。

析構函數可以是虛函數嗎

可以,並且最好定義爲虛函數,不然就可能出現因爲對象的不同,釋放用戶空間的時候,導致內存泄漏

對象的訪問普通函數快還是虛函數快

這個顯然是前者快,前者編譯的時候就確定好了,後者還要不斷去虛函數表中找。

虛函數表在什麼階段生產

當然在編譯的時候生成了,只不過對象的虛函數表指針是在調用構造函數的時候初始化

C++菱形繼承的問題

上面有講到,需要用心感悟

什麼是抽象類,抽象類的目的

首先呢抽象類是爲了強制實現重載,而重寫是爲了實現接口繼承,他們其實目的就是爲了實現多態。

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