第四章 繼承與派生

一、三種繼承的方式

繼承方式/基類成員 public成員 protected成員 private成員
public繼承 public protected 不可見
protected繼承 protected protected 不可見
private繼承 private private 不可見

注意:

  1. 基類成員在派生類中的訪問權限不得高於繼承方式中指定的權限。

  2. 不管繼承方式如何,基類中的 private 成員在派生類中始終不能使用(不能在派生類的成員函數中訪問或調用)。但是基類的 private 成員不能在派生類中使用,並沒有說基類的 private 成員不能被繼承。實際上,基類的 private 成員是能夠被繼承的,並且(成員變量)會佔用派生類對象的內存,它只是在派生類中不可見,導致無法使用罷了。private 成員的這種特性,能夠很好的對派生類隱藏基類的實現,以體現面向對象的封裝性。

  3. 在派生類中訪問基類 private 成員的唯一方法就是藉助基類的非 private 成員函數,如果基類沒有非 private 成員函數,那麼該成員在派生類中將無法訪問。

  4. 使用 using 關鍵字可以改變基類成員在派生類中的訪問權限,但是using 只能改變基類中 public 和 protected 成員的訪問權限,不能改變 private 成員的訪問權限,因爲基類中 private 成員在派生類中是不可見的,根本不能使用,所以基類中的 private 成員在派生類中無論如何都不能訪問。代碼如下:

     #include<iostream>
     using namespace std;
     
     //基類People
     class People {
     public:
         void show();
     protected:
         char *m_name;
         int m_age;
     };
     void People::show() {
         cout << m_name << "的年齡是" << m_age << endl;
     }
     
     //派生類Student
     class Student : public People {
     public:
         void learning();
     public:
         using People::m_name;  //將protected改爲public
         using People::m_age;  //將protected改爲public
         float m_score;
     private:
         using People::show;  //將public改爲private
     };
     void Student::learning() {
         cout << "我是" << m_name << ",今年" << m_age << "歲,這次考了" << m_score << "分!" << endl;
     }
     
     int main() {
         Student stu;
         stu.m_name = "小明";
         stu.m_age = 16;
         stu.m_score = 99.5f;
         stu.show();  //compile error
         stu.learning();
     
         return 0;
     }
    

二、繼承的對象內存模型

沒有繼承關係時

成員變量和成員函數會分開存儲:

  • 對象的內存中只包含成員變量,存儲在棧區或堆區(使用 new 創建對象);
  • 成員函數與對象內存分離,存儲在代碼區。

存在單繼承關係時

派生類的內存模型可以看成是基類成員變量和新增成員變量的總和,而所有成員函數仍然存儲在另外一個區域——代碼區,由所有對象共享。

若存在A、B、C三個類,C繼承於B,B繼承於A:

  • 無成員變量遮蔽時的內存分佈

  • 存在成員變量遮蔽時的內存分佈

    在派生類的對象模型中,會包含所有基類的成員變量。這種設計方案的優點是訪問效率高,能夠在派生類對象中直接訪問基類變量,無需經過好幾層間接計算。

存在多繼承關係時

A、B 是基類,C 是派生類,假設 obj_c 的起始地址是 0X1000,那麼 obj_c 的內存分佈如下圖所示:

由此可知:基類對象的排列順序和繼承時聲明的順序相同。

存在虛繼承關係時

對於普通繼承,基類子對象始終位於派生類對象的前面(也即基類成員變量始終在派生類成員變量的前面),而且不管繼承層次有多深,它相對於派生類對象頂部的偏移量是固定的。請看下面的例子:

class A{
protected:
    int m_a1;
    int m_a2;
};

class B: public A{
protected:
    int b1;
    int b2;
};

class C: public B{
protected:
    int c1;
    int c2;
};

class D: public C{
protected:
    int d1;
    int d2;
};

int main(){
    A obj_a;
    B obj_b;
    C obj_c;
    D obj_d;

    return 0;
}

obj_a、obj_b、obj_c、obj_d 的內存模型如下所示:

  1. 修改上面的代碼,使得 A 是 B 的虛基類:

     class B: virtual public A
    

    此時 obj_b、obj_c、obj_d 的內存模型就會發生變化,如下圖所示:

    不管是虛基類的直接派生類還是間接派生類,虛基類的子對象始終位於派生類對象的最後面。

  2. 再假設 A 是 B 的虛基類,B 又是 C 的虛基類,那麼各個對象的內存模型如下圖所示:

從上面的兩張圖中可以發現,虛繼承時的派生類對象被分成了兩部分:

  • 不帶陰影的一部分偏移量固定,不會隨着繼承層次的增加而改變,稱爲固定部分
  • 帶有陰影的一部分是虛基類的子對象,偏移量會隨着繼承層次的增加而改變,稱爲共享部分

三、繼承時的構造函數

單繼承

  • 類的構造函數不能被繼承,但是可以在派生類的構造函數中調用基類的構造函數,用於對繼承過來的基類中的成員變量進行初始化工作。注意只能將基類構造函數的調用放在函數頭部,不能放在函數體中。

      // 正確示範
      Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
      // 錯誤操作
      Student::Student(char *name, int age, float score){
          People(name, age);
          m_score = score;
      }
    
  • 基類構造函數總是優先被調用,並且派生類構造函數中只能調用直接基類的構造函數,不能調用間接基類的構造函數。

      假如繼承關係:A --> B --> C
      創建C類對象時構造函數的執行順序爲(自頂向下):A類構造函數 --> B類構造函數 --> C類構造函數
      調用順序爲(只能調用直接基類的構造函數):C類構造函數 --> B類構造函數,B類構造函數 --> A類構造函數
    

多繼承

  • 多繼承構造函數寫法:

      D(形參列表): A(實參列表), B(實參列表), C(實參列表){
      	    //其他操作
      }
    
  • 基類構造函數的調用順序和和它們在派生類構造函數中出現的順序無關,而是和聲明派生類時基類出現的順序相同。

虛繼承

  • 在虛繼承中,虛基類是由最終的派生類初始化的,換句話說,最終派生類的構造函數必須要調用虛基類的構造函數。

  • 虛繼承時構造函數的執行順序與普通繼承時不同:在最終派生類的構造函數調用列表中,不管各個構造函數出現的順序如何,編譯器總是先調用虛基類的構造函數,再按照出現的順序調用其他的構造函數;而對於普通繼承,就是按照構造函數出現的順序依次調用的。

四、析構函數

單繼承

  • 析構函數不能被繼承。
  • 執行析構函數的順序同構造函數執行順序相反,先執行派生類的析構函數,在執行基類的構造函數。

多繼承

  • 多繼承形式下析構函數的執行順序和構造函數的執行順序相同。

- 其他問題

① 繼承時的名字遮蔽問題以及作用域嵌套

  • 如果派生類中的成員(包括成員變量和成員函數)和基類中的成員重名,那麼就會遮蔽從基類繼承過來的成員。
  • 基類成員函數和派生類成員函數不構成重載,不管函數的參數如何,只要名字一樣就會造成遮蔽。

只有一個作用域內的同名函數才具有重載關係,不同作用域內的同名函數是會造成遮蔽,使得外層函數無效。派生類和基類擁有不同的作用域,所以它們的同名函數不具有重載關係。

  • 當存在繼承關係時,派生類的作用域嵌套在基類的作用域之內,如果一個名字在派生類的作用域內無法找到,編譯器會繼續到外層的基類作用域中查找該名字的定義。一旦在外層作用域中聲明(或者定義)了某個名字,那麼它所嵌套着的所有內層作用域中都能訪問這個名字。同時,允許在內層作用域中重新定義外層作用域中已有的名字。

② 藉助指針突破訪問權限的限制,訪問private、protected屬性的成員變量 — 使用偏移

對象的內存模型中,成員變量和對象的開頭位置會有一定的距離。以 obj 爲例,它的內存模型爲:

圖中假設 obj 對象的起始地址爲 0X1000,m_a、m_b、m_c 與對象開頭分別相距 0、4、8 個字節,我們將這段距離稱爲偏移(Offset)。一旦知道了對象的起始地址,再加上偏移就能夠求得成員變量的地址,知道了成員變量的地址和類型,也就能夠輕而易舉地知道它的值。

當通過對象指針訪問成員變量時,編譯器實際上也是使用這種方式來取得它的值。

int b = p->m_b;

此時編譯器內部會發生類似下面的轉換:

int b = *(int*)( (int)p + sizeof(int) );

p 是對象 obj 的指針,(int)p將指針轉換爲一個整數,這樣才能進行加法運算;sizeof(int)用來計算 m_b 的偏移;(int)p + sizeof(int)得到的就是 m_b 的地址,不過因爲此時是int類型,所以還需要強制轉換爲int 類型;開頭的用來獲取地址上的數據。

③ 虛繼承

爲了解決多繼承時的命名衝突和冗餘數據問題,C++ 提出了虛繼承,使得在派生類中只保留一份間接基類的成員。虛派生隻影響從指定了虛基類的派生類中進一步派生出來的類(間接派生類),它不會影響派生類本身(直接派生類)

虛繼承的目的是讓某個類做出聲明,承諾願意共享它的基類。其中,這個被共享的基類就稱爲虛基類(Virtual Base Class)。在這種機制下,不論虛基類在繼承體系中出現了多少次,在派生類中都只包含一份虛基類的成員。

④ C++向上轉型

類其實也是一種數據類型,也可以發生數據類型轉換,不過這種轉換只有在基類和派生類之間纔有意義,並且只能將派生類賦值給基類,包括將派生類對象賦值給基類對象、將派生類指針賦值給基類指針、將派生類引用賦值給基類引用,這在 C++ 中稱爲向上轉型(Upcasting)。相應地,將基類賦值給派生類稱爲向下轉型(Downcasting)

數據類型轉換的前提是,編譯器知道如何對數據進行取捨。

  • 將派生類對象賦值給基類對象

    賦值的本質是將現有的數據寫入已分配好的內存中,對象的內存只包含了成員變量,所以對象之間的賦值是成員變量的賦值,成員函數不存在賦值問題。

    將派生類對象賦值給基類對象時,會捨棄派生類新增的成員,也就是“大材小用”,如下圖所示:

    這種轉換關係是不可逆的,只能用派生類對象給基類對象賦值,而不能用基類對象給派生類對象賦值。

  • 將派生類指針賦值給基類指針

    1. 通過基類指針訪問派生類的成員

      將派生類指針賦值給基類指針時,通過基類指針只能使用派生類的成員變量,但不能使用派生類的成員函數。

      一句話:編譯器通過指針來訪問成員變量,指針指向哪個對象就使用哪個對象的數據;編譯器通過指針的類型來訪問成員函數,指針屬於哪個類的類型就使用哪個類的函數。

    2. 賦值後值不一致的情況

      將派生類的指針賦值給基類的指針時,編譯器可能會在賦值前進行處理。

      首先要明確的一點是,對象的指針必須要指向對象的起始位置。對於 A 類和 B 類來說,它們的子對象的起始地址和 D 類對象一樣,所以將 pd 賦值給 pa、pb 時不需要做任何調整,直接傳遞現有的值即可;而 C 類子對象距離 D 類對象的開頭有一定的偏移,將 pd 賦值給 pc 時要加上這個偏移,這樣 pc 才能指向 C 類子對象的起始位置。也就是說,執行pc = pd;語句時編譯器對 pd 的值進行了調整,才導致 pc、pd 的值不同。

  • 將派生類引用賦值給基類引用

    同 將派生類指針賦值給基類指針 原理一致。

注意:向上轉型後通過基類的對象、指針、引用只能訪問從基類繼承過去的成員(包括成員變量和成員函數),不能訪問派生類新增的成員。

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