C++ 繼承

對於繼承,我們先不說概念。下面我還是先用C模擬並藉助彙編的方式來理解它,側重於學習繼承的內存模型。

我們先來看代碼,設計三個類,People類,Teacher類,Student類

struct Person		
{		
	int age; //年齡	
	int sex; //性別	
};		
struct Teacher		
{		
	int age; //年齡	
	int sex; //性別
	int level;//教師等級	
};		
struct Student		
{		
	int age; //年齡	
	int sex; //性別		
	int score;//分數	
};

OK,下面分別創建對應的對象進行賦值

void createPerson()
{
    Person person;
    person.age = 20;
    person.sex = 1;
}

void createTeacher()
{
    Teacher teacher;
    teacher.age = 20;
    teacher.sex = 1;
    teacher.level = 3;
}	

void createStudent()
{
    Student student;
    student.age = 20;
    student.sex = 1;
    student.score = 60;
}

寫完代碼,其實就可以發現,太多的重複了,那如何解決呢,彆着急,我們來畫一下這幾個類的內存模型,我們如何知道其內存如何排放呢,其實只要調試起來,在內存窗口觀察變化即可。

在這內存模型中我們也可以發現,其實這三個類的前面部分內存都是一樣的,也就是綠,藍,黃 三部分,下面我們再通過彙編來驗證下

26:       Person person;
27:       person.age = 20;
00401048 C7 45 F8 14 00 00 00 mov         dword ptr [ebp-8],14h ;age 也是person首地址 
28:       person.sex = 1;
0040104F C7 45 FC 01 00 00 00 mov         dword ptr [ebp-4],1 ;sex


33:       Teacher teacher;
34:       teacher.age = 20;
00401078 C7 45 F4 14 00 00 00 mov         dword ptr [ebp-0Ch],14h ;age 也是teacher首地址
35:       teacher.sex = 1;
0040107F C7 45 F8 01 00 00 00 mov         dword ptr [ebp-8],1 ;sex
36:       teacher.level = 3;
00401086 C7 45 FC 03 00 00 00 mov         dword ptr [ebp-4],3 ;level


41:       Student student;
42:       student.age = 20;
004010B8 C7 45 F4 14 00 00 00 mov         dword ptr [ebp-0Ch],14h ;age 也是student首地址
43:       student.sex = 1;
004010BF C7 45 F8 01 00 00 00 mov         dword ptr [ebp-8],1 ;sex
44:       student.score = 60;
004010C6 C7 45 FC 3C 00 00 00 mov         dword ptr [ebp-4],3Ch ;score

這裏因爲對於的結構體的大小不一樣,所以在分配空間時,其對應的對象首地址肯定也不一樣。但是從彙編上分配的大小和順序也能看出,其前面的部分內存其實是一樣的。

那麼既然一樣,我們能不能去掉重複呢,想想之前的所學知識,我們是不是可以使用包含解決,動手試試

struct Person		
{		
	int age; //年齡	
	int sex; //性別	
};		
struct Teacher		
{		
	Person person;
	int level;//教師等級	
};		
struct Student		
{		
	Person person;	
	int score;//分數	
};

在Teacher類和Student類中分別包含Person類,好像重複問題解決了。而且內存模型也是和之前一樣的,你可以想象下直接將Person的內存模型往右邊的Teacher類和Student類複製。

但是,此時你會發現新問題又來了,那就是給對象賦值需要發生變化

void createPerson()
{
    Person person;
    person.age = 20;
    person.sex = 1;
}

void createTeacher()
{
    Teacher teacher;
    teacher.person.age = 20;
    teacher.person.sex = 1;
    teacher.level = 3;
}	

void createStudent()
{
    Student student;
    student.person.age = 20;
    student.person.sex = 1;
    student.score = 60;
}

前面說了其內存模型不變,既然內存模型不變,那麼雖然賦值代碼發生了變化,彙編代碼應該還是一樣的吧,我們來看一下彙編代碼。

24:       Person person;
25:       person.age = 20;
00401048 C7 45 F8 14 00 00 00 mov         dword ptr [ebp-8],14h
26:       person.sex = 1;
0040104F C7 45 FC 01 00 00 00 mov         dword ptr [ebp-4],1


31:       Teacher teacher;
32:       teacher.person.age = 20;
00401078 C7 45 F4 14 00 00 00 mov         dword ptr [ebp-0Ch],14h
33:       teacher.person.sex = 1;
0040107F C7 45 F8 01 00 00 00 mov         dword ptr [ebp-8],1
34:       teacher.level = 3;
00401086 C7 45 FC 03 00 00 00 mov         dword ptr [ebp-4],3


39:       Student student;
40:       student.person.age = 20;
004010B8 C7 45 F4 14 00 00 00 mov         dword ptr [ebp-0Ch],14h
41:       student.person.sex = 1;
004010BF C7 45 F8 01 00 00 00 mov         dword ptr [ebp-8],1
42:       student.score = 60;
004010C6 C7 45 FC 3C 00 00 00 mov         dword ptr [ebp-4],3Ch

和前面的彙編代碼是一模一樣,但是我們又不是寫彙編代碼,我們是寫C++代碼,雖然其底層的實現是一樣的,但是對於我們開發人員來說是不一樣的,哪不一樣呢,邏輯上不一樣

就拿Student類來說,單獨考慮這麼一個類,裏面有age,sex,score成員,很符合邏輯。但是現在呢,有個person成員,在這個person成員裏面才包含了age和sex,給人的感覺就是age和sex並不是學生身上的屬性,邏輯上來說是很彆扭。

所以,繼承就出現了,來看一下繼承的語法

struct Person		
{		
	int age; //年齡	
	int sex; //性別	
};
		
//使用 : 表示繼承,後面跟需繼承的類名
struct Teacher:	Person	
{		
	int level;//教師等級	
};	

//使用 : 表示繼承,後面跟需繼承的類名	
struct Student:	Person	
{			
	int score;//分數	
};

void createPerson()
{
    Person person;
    person.age = 20;
    person.sex = 1;
}

void createTeacher()
{
    Teacher teacher;
    teacher.age = 20;
    teacher.sex = 1;
    teacher.level = 3;
}	

void createStudent()
{
    Student student;
    student.age = 20;
    student.sex = 1;
    student.score = 60;
}

好了,現在既解決了重複的問題,也解決了使用包含時的邏輯上問題。那麼這個繼承的內存模型還是一樣麼?是的,內存模型還是沒有發生任何變化,我們還是從彙編的角度再來對比下代碼

26:       Person person;
27:       person.age = 20;
00401048 C7 45 F8 14 00 00 00 mov         dword ptr [ebp-8],14h
28:       person.sex = 1;
0040104F C7 45 FC 01 00 00 00 mov         dword ptr [ebp-4],1


33:       Teacher teacher;
34:       teacher.age = 20;
00401078 C7 45 F4 14 00 00 00 mov         dword ptr [ebp-0Ch],14h
35:       teacher.sex = 1;
0040107F C7 45 F8 01 00 00 00 mov         dword ptr [ebp-8],1
36:       teacher.level = 3;
00401086 C7 45 FC 03 00 00 00 mov         dword ptr [ebp-4],3


41:       Student student;
42:       student.age = 20;
004010B8 C7 45 F4 14 00 00 00 mov         dword ptr [ebp-0Ch],14h
43:       student.sex = 1;
004010BF C7 45 F8 01 00 00 00 mov         dword ptr [ebp-8],1
44:       student.score = 60;
004010C6 C7 45 FC 3C 00 00 00 mov         dword ptr [ebp-4],3Ch

會發現其彙編代碼和之前的是一摸一樣,所以呢,其實對於繼承,還闊以這麼簡單的理解,繼承屬於一種特殊的包含(包含父類對象在其首部)。

既然引出了繼承,那麼想必大家應該也明白了,所謂的繼承,就是一種代碼重用機制,其本質就是數據的複製。使用繼承,可以減少我們重複代碼的編寫(編譯器幫我們幹)

OK,在上一篇中,有提過struct和class唯一的區別就是權限不一樣,編譯器默認class中的成員爲private 而struct中的成員爲public。那麼現在在繼承中,還有個區別哦,那就是編譯器默認class繼承方式爲private,而struct中繼承方式爲public。

對於C++而言,在編譯器這塊下了更多的功夫,儘可能的藏起來。

下面,我們來看一下這個private繼承,首先,我們先來觀察下父類成員私有後能不能被繼承到子類

class Person
{
private:
    int m_age; //年齡	
    int m_sex; //性別	
};
class Teacher : public Person //公有繼承
{
private:
    int m_level;//教師等級
public:
    Teacher()
    {
        //m_age = 10; err 成員 "Person::m_age" (已聲明 所在行數 : 7) 不可訪問
    }
};

報錯了,說明當父類成員私有後,是不能繼承到子類的,類似於老爸的私房錢,對於任何人來說都一直是私有的。

那麼現在問題來了,在上一篇權限中說過,一般來說需要吧成員變量設置爲私有,那麼如果繼承後訪問不到,這個繼承還有用麼?其實還是一樣的解決方案,給個公開的成員方法。

class Person
{
private:
    int m_age; //年齡	
    int m_sex; //性別	
public:
    void setData(int age,int sex)
    {
        m_age = age;
        m_sex = sex;
    }
};
class Teacher : public Person //公有繼承
{
private:
    int m_level;//教師等級
public:
    Teacher()
    {
        setData(4,5);
        m_level = 7;
    }
};

OK,下面我們來探討下 private成員是真的沒有被繼承嗎?

首先,先打印下佔用空間的大小

    cout << sizeof(Teacher) << endl; //12

打印出了12,從這個結果可以猜測,Teacher類裏面應該是有Person中的兩個私有數據的,下面我們觀察下內存分佈來驗證下

從內存分佈來看,其實也可以看出來,父類中的私有成員是會被繼承的,那麼說明之前的報錯都只是編譯器不允許我們直接這樣訪問,那麼,這時候強大的指針就該上場了,我們使用指針來訪問私有父類數據

class Teacher : public Person //公有繼承
{
private:
    int m_level;//教師等級
public:
    Teacher()
    {
        //setData(4,5);
        int *tmpThis = (int*)this;
        *tmpThis = 4; //修改m_age
        *(tmpThis+1) = 5; //修改m_sex
        m_level = 7;
    }
};

其實爲了子類訪問父類的成員變量,C++還提供了一個protected的權限,我們來看一下作用

class Person
{
protected:
    int m_age; //年齡	
    int m_sex; //性別	
public:
    void setData(int age,int sex)
    {
        m_age = age;
        m_sex = sex;
    }
};
class Teacher : public Person //公有繼承
{
private:
    int m_level;//教師等級
public:
    Teacher()
    {
        m_age = 4; //ok 子類可訪問
        m_sex = 5;// ok 子類可訪問
        m_level = 7;
    }
};

int main()
{
    Person person;
    //protected 外部不可直接訪問
    //person.m_age = 0; //err 成員 "Person::m_age" (已聲明 所在行數 : 7) 不可訪問	
    system("pause");
}

從實驗得出的結果看來,其實protected關鍵字相當於是給了子類一個直接訪問的權限,對於外部來說還是一樣的。

OK,講完私有成員的繼承後,我們該來看看這個私有的繼承方式了,看如下代碼

class Person
{
public:
    int m_age; //年齡	
protected:
    int m_sex; //性別	
public:
    void setData(int age,int sex)
    {
        m_age = age;
        m_sex = sex;
    }
};
class Teacher : Person //默認會私有繼承
{
private:
    int m_level;//教師等級
public:
    Teacher()
    {
        m_age = 4; //ok 可訪問
        m_sex = 5;// ok 可訪問
        m_level = 7;
    }
};

int main()
{
    Teacher teacher;
    // “Person::m_age”不可訪問,因爲“Teacher”使用“private”從“Person”繼承
    //teacher.m_age = 10; 報錯 不可訪問
    Person person;
    person.m_age = 10; // ok
    system("pause");
}

從實驗結果來看,所謂的繼承方式其實是對於外部而言的,因爲在Teacher內部,使用其父類的成員變量完全無區別,使用私有繼承後,原本public的權限在外部卻不能使用了。

下面總結下權限和繼承的關係

     派 生                      基   類
繼承方式\成員權限     private    protected    public
    private           xx/xx      ok/xx       ok/xx
    protected         xx/xx      ok/xx       ok/xx
    public            xx/xx      ok/xx       ok/ok

其結果意思的:內部是否可訪問/外部是否可訪問,ok表示可訪問,xx表示不可訪問。

通過上面的實驗,其實所謂的權限啥的,都是編譯器層面做的限制,所以下面的例子方便起見我就都使用公有數據進行講解了。

下面,我們看一下這個問題:爲什麼父類指針可以執行指向子類對象?先看代碼

class Person
{
public:
    int m_age; //年齡	
    int m_sex; //性別
};
class Teacher : public Person
{
public:
    int m_level;//教師等級
};

int main()
{
    Teacher teacher;
    teacher.m_age = 1;
    teacher.m_sex = 2;
    teacher.m_level = 3;
    
    //父類指針指向子類對象
    Person *person = &teacher;
    person->m_age = 5;
    person->m_sex = 6;
    //person->m_level = 7; err “m_level” : 不是“Person”的成員
    system("pause");
}

首先,來調試看一下內存數據

來看一下person修改的數據

從這個內存數據的結果來看,雖然使用了父類指針,但是其修改和直接使用teacher修改的效果是一樣的,我們來可以回顧下之前的內存模型。其實繼承後,子類對象裏面相當於包含了父類成員,父類對象複製過來的時候放在子類的最上面,看下圖

注意,圖中的範圍訪問限制都是編譯器做的哦,所以person對m_sex數據纔不能修改。所以,父類指針指向子類對象,不管咋樣,都只是會操作頭部數據,所以是安全的。如果反過來呢?那可就不行了,因爲父類的真實數據只有前兩個,而當轉成子類的指針時,訪問的範圍會擴大到3個,這樣子就不安全了。

下面,再來看一個問題,基類的構造/析構順序?

其實,這個問題已經可以直接回答了,因爲上面有說過,繼承,其實可以看成子類包含父類,而且該父類會位於全部成員的第一項。那麼這個順序其實可以轉化爲對象成員的初始化和析構順序了,這個順序在前面的析構/構造篇中可以找到,所以下面就直接給結論了

構造順序:
    基類
    成員對象
    自己
析構順序:
    自己
    成員對象
    基類

下面再來看一個問題,對於資源的分配和釋放,我們是在子類構造/析構中寫呢,還是父類構造/析構中寫呢?

首先,對於良好的設計而言,根本不會出現這個問題,爲什麼呢?因爲一般的成員數據都是定義成私有的,所以子類是沒有辦法進行訪問(開發角度)。從類的封裝角度上考慮,就算是沒有繼承,其單獨的類(父類和子類)也是可以使用的,所以對於資源問題,我們只需保證在自己的類中對自己的成員數據資源進行分配和釋放即可(自己的事情自己做)。

假設這樣的情況,父類裏的資源是公有的,所以子類是可以訪問的,所以我在子類的構造和析構中對該資源進行了操作,那麼會發現上面的構造和析構順序就沒什麼用處了,因爲此時你根本就不需要父類的構造和析構,雖然實現上是可行的,但是很容易造成資源的重複分配/釋放問題。觀察如下代碼進行對比即可,正好對上面的順序做一個回顧

先來看錯誤的代碼示例

//-----------------------------錯誤代碼示例---------------------------
class A
{
public:
    char *str;
    A()
    {
        //此處若分配會造成內存泄露
    }
    ~A()
    {
        //此處若釋放會找出重複釋放
    }
};

class B : public A
{
public:
    B()
    {   //A::A() 此處編譯器會幫我們調用A構造
        str = new char[10]; 
    }
    ~B()
    {
        delete[] str;
    }
    //A::~A() 此處編譯器會幫我們調用A析構
};

正確的代碼示例

//----------------正確代碼示例-----------------
class A
{
public:
    char *str;
    A()
    {
        str = new char[10];
    }
    ~A()
    {
        delete[] str;
    }
};

class B : public A
{
public:
    B()
    {   //A::A() 此處編譯器會幫我們調用A構造
        //此處不做任何處理,因爲B中無資源
    }
    ~B()
    {
        //此處不做任何處理,因爲B中無資源
    }
    //A::~A() 此處編譯器會幫我們調用A析構
};

記住一點即可,每一個類都是獨立可使用的,也就是封裝。

下面再來看一下最後這個例子

class A
{
public:
    A()
    {
        m_a = 1;
        m_b = 2;
    }
    void test(){ cout << "A::test()" <<endl; }
    void test(int){ cout << "A::test(int)" << endl; }

public:
    int m_a;
    int m_b;
};

class B : public A
{
public:
    B()
    {
        m_a = 3;
    }
    void test() { cout << "B::test()" << endl; }
public:
    int m_a;
};

int main()
{
    B bObj;
    cout << bObj.m_a << endl;
    bObj.test();
    //bObj.test(1); err 編譯錯誤
    system("pause");
}

此時,你會發現上面的成員有重名的情況,那麼當調用的時候會調用誰的呢?

根據結果可以判斷出來,調用的都是B類的,下面我們來看一下內存結構

你會發現,分配的空間和普通的是一樣的。雖然有名字重複,但是父類的數據也都一樣被繼承下來了。所以對於底層來說,沒有任何區別,編譯完都是二進制,這邊所謂的名字,都是給編譯器看的而已,所以此時如果想調用基類的,那麼需要明確作用域,如下代碼:

    B bObj;
    cout << bObj.A::m_a << endl;
    bObj.A::test();
    bObj.A::test(1); //ok

此時,你會發現原先調用test(int)是編譯報錯的,現在加上父類的作用域後是OK了,這個是爲什麼呢?

之前學了重載,現在需要知道一個新的東西,叫做隱藏(編譯器限制)

重載:作用域相同,同名,參數個數/類型/順序不同
隱藏: 作用域不同,只要同名就構成隱藏

所以,之前的編譯報錯,其實是因爲A::test和A::test(int)都被隱藏了。

當存在繼承關係時,派生類的作用域嵌套在其基類的作用域之內,如果一個名字在派生類的作用域內無法正確解析,則編譯器將繼續在外層的基類作用域中尋找該名字的定義。

那麼C++爲什麼要有名稱隱藏這種東西呢,按上述的案例,如果可以調用test(int)就比較舒服了?

名稱解析規則表示,名稱查找將在找到匹配名稱的第一個範圍內停止。此時,重載解析規則開始尋找可用函數的最佳匹配。重載解析通過從一組候選函數中選擇最佳函數來工作,通過將參數的類型與參數的類型匹配來實現的。

如果沒有名稱隱藏,那麼繼承的重載函數集與當前設置給定類的重載函數集會混合,此時調用函數很容易產生二義性問題。有了名稱隱藏,此時重載解析就將會按照你的預期進行。

所以名稱隱藏機制雖然有時會感覺不太舒服(像上述案例,因爲違反了類之間的is-a關係),但是也能規避掉大部分的錯誤情況。

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