C++ 基礎系列——繼承與派生


1. 繼承和派生入門

繼承可以理解為一個類在另一個類的成員變量和成員函數上繼續拓展的過程。這個過程站的角度不同,概念也會不同,繼承是兒子接收父親,派生是父親傳承給兒子。

被繼承的類稱為父類或基類,繼承的類稱為子類或派生類。“子類”和“父類”通常放在一起稱呼,“基類” 和“派生類”通常放在一起稱呼。

// 基類 People
class People
{
public:
    void set_name(string name);
    void set_age(int age);
    string get_name();
    int get_age();
private:
    string m_name;
    int m_age;
};

// 派生類 Studeng
// public 表示公有繼承,下一節會講繼承方式問題
class Student:public People
{
public:
    void set_score(float score);
    float get_score();
private:
    float m_score;
};

int main()
{
    Student stu;
    stu.set_name("小明");   // 繼承基類
    stu.set_score(99.9);
    return 0;
}

繼承方式有 public、private、protected,如果不寫,默認 private。(結構struct 默認繼承方式是 public)

2. 三種繼承方式

繼承方式限定了基類成員在派生類中的訪問權限,三種方式分別是:public、private、protected。

類的public 成員可以通過對象來訪問,private 成員不能通過對象和派生類訪問,而 protected 也不能通過對象訪問,但基類的 protected 成員可以在派生類中訪問。

不同的繼承方式會影響基類成員在派生類中的訪問權限:

  • public 繼承
    • 基類中 public、protected 成員在派生類保持基類的屬性。(基類private 成員不能在派生類中使用)
  • protected 繼承
    • 基類的 public、protected 成員在派生類中均為 protected 屬性。
  • private 繼承
    • 基類的 public、protected 成員在派生類中均為 private 屬性。

對於基類中既不向外暴露(不能通過對象訪問),還能在派生類中使用的成員,只能聲明為 protected。

注意,基類的 private 成員是能夠被繼承的,並且成員變量一樣會占用派生類的內存,只是在派生類中不可見。

public 成員 protected 成員 private 成員
public 繼承 public protected 不可見
protected 繼承 protected protected 不可見
private 繼承 private private 不可見

由於 private 和 protected 繼承方式會改變基類成員在派生類的訪問權限,導致繼承關系復雜,實際開發中通常使用 public。

派生類中訪問基類 private 成員的唯一方法是借助基類的非 private 成員函數。如果基類未提供,則派生類中無法訪問。

改變訪問權限

使用 using 關鍵字可以改變基類成員在派生類的訪問權限。只能改變基類中 public 和 protected 成員的訪問權限。

// 基類
class People
{
public:
    void show();
protected:
    string m_name;
    int m_age;
};

// 派生類
class Student: public People
{
public:
    using People::m_name;  // 將 protected 改為 public
    float m_score;
private:
    using People::m_age; // 將 protected 改為 private
    using People::show;  // 將 public 改為 private
};

3. 繼承時名字遮蔽問題

名字遮蔽指的是,當派生類成員與基類成員重名時,派生類使用的是該派生類新增的成員,基類的成員會被遮蔽

對於成員函數來說,只要派生類成員函數與基類名字一樣,就會造成遮蔽,遮蔽與參數無關。也就是說,基類的成員函數與派生類成員函數不會構成函數重載

// 基類
class People
{
public:
    void show();
protected:
    string m_name;
    int m_age;
};

class Student: public People
{
public:
    Student(string name, int age, float score);
    void show();  // 基類 show 函數遮蔽
private:
    float m_score;
};


int main()
{
    Student stu("小明",16,99,9);
    stu.show();     // 派生類 show
    stu.People::show(); // 基類 show
}

如果派生類要訪問基類中被遮蔽的函數,需要加上類名和域名解析符。

4. 繼承時作用域的嵌套

類其實也是一種作用域,每個類都有自己的作用域,在這個作用域內再定義類的成員。當存在繼承關系時,派生類的作用域嵌套在基類的作用域之內

當派生類對象訪問成員時,會在作用域鏈中尋找最匹配的成員。對於成員變量,會直接查找,但是對於成員函數,編譯器僅僅根據函數名字來查找,當內層作用域有同名函數時,不管有幾個,編譯器都不會再到外層作用域中查找,而是將這些同名函數作為一組重載候選函數。

// 基類
class Base
{
public:
    void func();
    void func(int);
};

// 派生類
class Derived: public Base
{
public:
    void func(string);
    void func(bool);
};

int main()
{
    Derived d;
    d.func("test"); // 派生類 Derived 域中匹配
    d.func(true);   // 派生類 Derived 域中匹配
    d.func();   // 編譯錯誤,在派生類中找到了同名函數,因此不會再去基類匹配,但派生類中無法匹配
    d.func(10); // 編譯錯誤,在派生類中找到了同名函數,因此不會再去基類匹配,但派生類中無法匹配
    d.Base::func();
    d.Base::func(100);
    return 0;
}

5. 繼承時的對象內存模型

  • 對於沒有繼承時的對象內存模型很簡單,成員變量和成員函數會分開存儲:對象的內存中只包含成員變量,存儲在棧區或堆區(new),成員函數與對象內存分離,存儲在代碼區。

  • 有繼承關系時,派生類的內存模型可以看成是基類成員變量和新增成員變量的總和,所有成員函數仍存儲在代碼區,由所有對象共享。

  • 在派生類的對象模型中,會包含所有基類的成員變量,這種設計方案的優點是訪問效率高,能直接訪問。當存在遮蔽問題時,被遮蔽的成員變量仍然會留在內存中,只是對於存在遮蔽問題的成員變量,會增加類名和域名解析符::。

6. 基類和派生類的構造函數

類的構造函數不能被繼承,並且對於繼承過來的基類的成員變量的初始化,需要派生類的構造函數完成,通常是通過調用基類的構造函數完成。

class People
{
public:
    People(string, int);
protected:
    string m_name;
    int m_age;
};

class Student:public People
{
public:
    Student(string name, int age, float score);
private:
    float m_score;
};

// 派生類構造函數,調用基類的構造函數完成基類成員變量的初始化
// 基類構造函數的調用只能放在函數頭部,不能放在函數體中。
Student::Student(string name, int age, float score): People(name, age), m_score(score){ }

構造函數調用順序

在創建派生類對象時,會先調用基類構造函數, 再調用派生類構造函數。構造函數的調用順序是按照繼承的層次自頂向下、從基類再到派生類的。

派生類構造函數中只能調用直接基類的構造函數,不能調用間接基類的。

基類構造函數調用規則

通過派生類創建對象時必須調用基類構造函數,如果沒有指明基類構造函數,會調用基類的默認構造函數,如果基類默認構造函數不存在,會編譯錯誤。

7. 基類和派生類的析構函數

析構函數也不能被繼承。並且在派生類的析構函數中不用顯式調用基類的析構函數,因為每個類只有一個析構函數。

此外析構函數的執行順序和構造函數的執行順序也剛好相反:

創建派生類時,構造函數調用順序自頂向下、從基類到派生類;
銷毀派生類時,析構函數執行順序是自下向頂,從派生類到基類。

8. 多繼承

多繼承語法:

class D : public A, private B, protected C {
//
};

多繼承形式下的構造函數和單繼承形式基本相同,只是要在派生類的構造函數中調用多個基類的構造函數。

D(形參列表): A(實參列表), B(實參列表), C(實參列表){ 
    //其他操作
}

基類構造函數的調用順序和它們在派生類構造函數的出現順序無關,只和聲明派生類時基類出現的順序相同。

當多個基類擁有同名的成員時,派生類調用時需要加上類名和域解析符::。

9. 指針突破訪問權限的限制

C++不能通過對象來訪問 private、protected 屬性的成員變量,但是通過指針,能夠突破這種限制。

class A{ 
public:

private:
    int m_a;
    int m_b;
    int m_c;
}; 

A::A(int a, int b, int c): m_a(a), m_b(b), m_c(c){ } 

int main(){
    A obj(10, 20, 30);
    int a = obj.m_a;  // 編譯錯誤,無法訪問 protected、private 成員
    A *p = new A(40, 50, 60);
    int b = p -> m_b;  // 編譯錯誤
    return 0;
}

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

一旦知道了對象的起始地址,再加上偏移就能求得成員變量的地址,如果知道了成員變量的類型,就能輕易獲得其值。

實際上,通過對象訪問成員變量時,編譯器也是通過這種方式來取得它的值:(假設 m_b 成員變量此時為 public)

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*類型;開頭的*用來獲取地址上的數據。

// 通過指針突破訪問權限限制訪問 private 成員
int main(){
    A obj(10, 20, 30); 
    int a1 = *(int*)&obj;    // 10
    int b = *(int*)( (int)&obj + sizeof(int) );  // 20

    A *p = new A(40, 50, 60); 
    int a2 = *(int*)p;    // 40 
    int c = *(int*)( (int)p + sizeof(int)*2 );    // 60

    cout << "a1=" << a1 << ", a2=" << a2 << ", b=" << b << ", c=" << c << endl;
    return 0;

C++ 的成員訪問權限僅僅是語法層面上的,是指訪問權限僅對取成員運算符. 和 -> 起作用,而無法 防止直接通過指針來訪問。

10. 虛繼承和虛基類

在多繼承中,很容易產生命名沖突,例如典型的菱形繼承:

為了解決多繼承時的命名沖突和冗余數據問題,C++ 提出了虛繼承,使得在派生類中只保留一份間接基類的成員

在繼承方式前面加上 virtual 關鍵字就是虛繼承

// 基類
class A
{
protected:
    int m_a;
};

// 直接基類 B
class B: virtual public A
{
protected:
    int m_b;
};

// 直接基類 C
class C: virtual public A
{
protected:
    int m_c;
};

// 派生類 D
class D : public B : public C
{
public:
    void seta(int a){ m_a = a; } //正確 
    void setb(int b){ m_b = b; } //正確 
    void setc(int c){ m_c = c; } //正確
    void setd(int d){ m_d = d; } //正確
private:
    int m_d;
};

這段代碼使用虛繼承重新實現了上圖所示的菱形繼承,這樣在派生類 D 中就只保留了一份成員變量 m_a,直接訪問就不會再有歧義了。

  • 虛繼承的目的是讓某個類做出聲明,承諾願意共享它的基類。其中,這個被共享的基類就稱為虛基類(Virtual Base Class)
  • 虛繼承的一個不太直觀的特征:必須在虛派生的真實需求出現前就已經完成虛派生的操作。在上例中,當定義 D 類時才出現了對虛派生的需求,但是如果 B 類和 C 類不是從 A 類虛派生得到的,那么 D 類還是會保留 A 類的兩份成員。
  • 虛派生只影響從指定了虛基類的派生類中進一步派生出來的類,它不會影響派生類本身。
  • 在實際開發中,位於中間層次的基類將其繼承聲明為虛繼承一般不會帶來什么問題。

虛基類成員的可見性

虛繼承的最終派生類中會只保留了一份虛基類的成員,所以該成員可以被直接訪問,不會產生二義性。

如果虛基類的成員只被一條派生路徑覆蓋,那么仍然可以直接訪問這個被覆蓋的成員。但是如果該成員被兩條或多條路徑覆蓋了,那就不能直接訪問了,此時必須指明該成員屬於哪個類。

假設 A 定義了一個名為 x 的成員變量,當我們在 D 中直接訪問 x 時,會有三種可能性:

  • 如果 B 和 C 中都沒有 x 的定義,那么 x 將被解析為 A 的成員,此時不存在二義性。
  • 如果 B 或 C 其中的一個類定義了 x,也不會有二義性,派生類的 x 比虛基類的 x 優先級更高。
  • 如果 B 和 C 中都定義了 x,那么直接訪問 x 將產生二義性問題。

不提倡在程序中使用多繼承,只有在比較簡單和不易出現二義性的情況或實在必要時才使用多繼承,能用單一繼承解決的問題就不要使用多繼承。

11. 虛繼承的構造函數

對於普通繼承,派生類構造函數只能調用直接基類的構造函數,不能調用間接基類的。

虛繼承中,虛基類由最終的派生類初始化,且必須調用,對於最終派生類來說,虛基類是間接基類。

因為虛繼承中,如果由中間基類初始化虛基類的成員變量,那么在最終派生類中,將因為不同路徑問題,出現歧義。

// 基類
class A
{
public:
    A(int a);
protected:
    int m_a;
};
A::A(int a): m_a(a){ }

// 直接基類 B
class B: virtual public A
{
public:
    B(int a, int b);
protected:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }

// 直接基類 C
class C: virtual public A
{
public:
    C(int a, int c);
protected:
    int m_c;
};
C::C(int a, int c): A(a), m_c(c){ }

// 派生類 D
class D : public B : public C
{
public:
    D(int a, int b, int c, int d);
private:
    int m_d;
};
D::D(int a, int b, int c, int d): A(a), B(90, b), C(100, c), m_d(d){ }

C++ 規定必須由最終的派生類 D 來初始化虛基類 A,直接派生類 B 和 C 對 A 的構造函數的調用是無效的。

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

因此上述派生類 D 的構造函數中,即使將 A 的構造函數放置最后,也會最先調用。

上述代碼構造函數調用順序:A -> B -> C;

12. 虛繼承下內存模型

對於普通繼承,基類子對象始終位於派生類對象的前面。而對於虛繼承,和普通繼承相反,大部分編譯器會把基類成員變量放在派生類成員變量的后面。

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

  • 不帶陰影的一部分偏移量固定,不會隨着繼承層次的增加而改變,稱為固定部分;
  • 帶有陰影的一部分是虛基類的子對象,偏移量會隨着繼承層次的增加而改變,稱為共享部分。
    如何計算共享部分的偏移,沒有統一標准。

虛基類表

如果某個派生類有一個或多個虛基類,編譯器就會在派生類對象中安插一個指針,指向虛 基類表。虛基類表其實就是一個數組,數組中的元素存放的是各個虛基類的偏移。
假設 A 是 B 的虛基類,同時 B 又是 C 的虛基類,那么各對象的內存模型如下圖所示:

虛繼承表中保存的是所有虛基類(包括直接繼承和間接繼承到的)相對於當前對象的偏移,這樣通過派生類指 針訪問虛基類的成員變量時,不管繼承層次都多深,只需要一次間接轉換就可以。

另外,這種方案還可以避免有多個虛基類時讓派生類對象額外背負過多的指針,只會存在一個指向虛基類表的指針。

13. 派生類賦值給基類(向上轉型)

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

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

將派生類對象賦值給基類對象時,會舍棄派生類新增的成員,這種轉換關系是不可逆的,只能用派生類對象給基類對象賦值,而不能用基類對象給派生類對象賦值。

class A
{
    // ...
};

class B : public A
{
    // ...
};

int main()
{
    A a(10);
    B b(66, 99);
    a.display();
    b.display();

    a = b;  // 向上轉型
    a.display();    // a 此時僅保留 b 中屬於基類 A 的成員變量
    b.dispaly();
}

將派生類指針賦值給基類指針(對象指針之間的賦值)

// 基類
class A
{
public:
    A();
    void display();
protected:
    int m_a;
};

A::A(int a): m_a(a){ }

void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}

// 中間派生類 B
class B:public A
{
public:
    B(int a, int b);
    void display();
protected:
    int m_b;
};

B::B(int a, int b): A(a), m_b(b){ } 

void B::display(){
    cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}

// 基類 C
class C
{
public:
    C();
    void display();
protected:
    int m_c;
};

C::C(int c): m_c(c){ } 

void C::display(){
    cout<<"Class C: m_c="<<m_c<<endl;
}

// 最終派生類 D
class D: public B, public C{ 
public:
    D(int a, int b, int c, int d);
    void display();
private:
    int m_d;
}; 

D::D(int a, int b, int c, int d): B(a, b), C(c), m_d(d){ }

void D::display(){
    cout<<"Class D: m_a="<<m_a<<", m_b="<<m_b<<", m_c="<<m_c<<",m_d="<<m_d<<endl;
}


int main()
{
    A *pa = new A(1);   
    B *pb = new B(2, 20); 
    C *pc = new C(3);
    D *pd = new D(4, 40, 400, 4000); 
    
    // 編譯器通過指針訪問成員變量,指針指向哪個對象就用哪個對象的數據
    // 編譯器通過指針的類型訪問成員函數,指針屬於哪個類的類型就使用哪個類的函數
    pa = pd;    
    pa -> display();    // 使用 A 類的 display 函數,訪問 D 類對象的數據
    pb = pd;
    pb -> display();    // 使用 B 類的 display 函數,訪問 D 類對象的數據
    pc = pd;
    pc -> display();    // 使用 C 類的 display 函數,訪問 D 類對象的數據

    cout << "-----------------------" << endl; 
    cout << "pa=" << pa << endl; 
    cout << "pb=" << pb << endl; 
    cout << "pc=" << pc << endl;
    cout << "pd=" << pd << endl;
    return 0;
}
運行結果:
  Class A: m_a=4 
  Class B: m_a=4, m_b=40 
  Class C: m_c=400 
  -----------------------
  pa=0x9b17f8 
  pb=0x9b17f8 
  pc=0x9b1800
  pd=0x9b17f8

按理說 pa、pb、pc 都是指向同一個 D 類對象,三者應該指向同一片內存。實際上,將派生類的指針賦值給基類指針時,編譯器會在賦值前進行處理。

此時 D 類對象的內存模型:

首先明確一點,對象的指針必須指向對象的起始位置。對於 A 類和 B 類來說,它們對象的起始位置與 D 一樣,所以將 D 類對象賦值給 A、B 類對象時不需要做任何調整,直接傳遞現有的值即可;而 C 類對象起始位置與 D 有一定偏移,將 D 類對象賦值給 C 類對象時需要加上這個偏移,所以才導致 pc 對象與其他對象值不同。

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

引用本質上是通過指針的方式實現的,基類的引用也可以指向派生類的 對象,並且它的表現和指針是類似的。

int main(){
    D d(4, 40, 400, 4000);
    A &ra = d; 
    B &rb = d; 
    C &rc = d;
    
    ra.display();   // 派生類對象的數據,引用類型的成員函數
    rb.display(); 
    rc.display();
    
    return 0;

運行結果:
Class A: m_a=4
Class B: m_a=4, m_b=40
Class C: m_c=400

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM