1. 程式人生 > 其它 >C++繼承與派生詳解

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

向上轉型後通過基類的物件、指標、引用只能訪問從基類繼承過去的成員(包括成員變數 和成員函式),不能訪問派生類新增的成員。