C++複習四-繼承與派生

目錄

一、概述

二、C++三種繼承方式

2.1 public、protected、private 指定繼承方式

2.2 using改變訪問權限

2.3 C++繼承時的名字遮蔽問題

2.4 具體解釋一下和作用域有關

2.5 C++繼承時的對象內存模型

2.6 C++基類和派生類的構造函數與析構函數

2.7 C++多繼承(多重繼承)

1.多繼承下的構造函數

2.命名衝突

3.內存模型

2.8 【有趣】藉助指針突破訪問權限的限制,訪問private、protected屬性的成員變量

2.9 C++虛繼承和虛基類詳解

1.虛繼承(Virtual Inheritance)

2.C++虛繼承時的構造函數

3.C++虛繼承下的內存模型

2.10 C++將派生類賦值給基類(向上轉型)

將派生類指針賦值給基類指針時到底發生了什麼?


一、概述

繼承(Inheritance)可以理解爲一個類從另一個類獲取成員變量和成員函數的過程。例如類 B 繼承於類 A,那麼 B 就擁有 A 的成員變量和成員函數。

在C++中,派生(Derive和繼承是一個概念,只是站的角度不同。繼承是兒子接收父親的產業,派生是父親把產業傳承給兒子。

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

派生類除了擁有基類的成員,還可以定義自己的新成員,以增強類的功能。

以下是兩種典型的使用繼承的場景:
1) 當你創建的新類與現有的類相似,只是多出若干成員變量或成員函數時,可以使用繼承,這樣不但會減少代碼量,而且新類會擁有基類的所有功能。

2) 當你需要創建多個類,它們擁有很多相似的成員變量或成員函數時,也可以使用繼承。可以將這些類的共同成員提取出來,定義爲基類,然後從基類繼承,既可以節省代碼,也方便後續修改成員。

繼承的一般語法爲:

class 派生類名:[繼承方式] 基類名{
    派生類新增加的成員
};

繼承方式包括 public(公有的)、private(私有的)和 protected(受保護的),此項是可選的。
如果不寫,那麼默認爲 private。我們將在下節詳細講解這些不同的繼承方式。

二、C++三種繼承方式

protected 成員和 private 成員類似,也不能通過對象訪問。但是當存在繼承關係時,protected 和 private 就不一樣了:基類中的 protected 成員可以在派生類中使用,而基類中的 private 成員不能在派生類中使用。

2.1 public、protected、private 指定繼承方式

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

1) public繼承方式

  • 基類中所有 public 成員在派生類中爲 public 屬性;
  • 基類中所有 protected 成員在派生類中爲 protected 屬性;
  • 基類中所有 private 成員在派生類中不能使用。


2) protected繼承方式

  • 基類中的所有 public 成員在派生類中爲 protected 屬性;
  • 基類中的所有 protected 成員在派生類中爲 protected 屬性;
  • 基類中的所有 private 成員在派生類中不能使用。


3) private繼承方式

  • 基類中的所有 public 成員在派生類中均爲 private 屬性;
  • 基類中的所有 protected 成員在派生類中均爲 private 屬性;
  • 基類中的所有 private 成員在派生類中不能使用。

不管繼承方式如何,基類中的 private 成員在派生類中始終不能使用(不能在派生類的成員函數中訪問或調用)。

如果希望基類的成員既不向外暴露(不能通過對象訪問),還能在派生類中使用,那麼只能聲明爲 protected。

實際上,基類的 private 成員是能夠被繼承的,並且(成員變量)會佔用派生類對象的內存,它只是在派生類中不可見,導致無法使用罷了。

#include<iostream>
using namespace std;

//基類People
class People{
public:
    void setname(char *name);
    void setage(int age);
    void sethobby(char *hobby);
    char *gethobby();
protected:
    char *m_name;
    int m_age;
private:
    char *m_hobby;
};
void People::setname(char *name){ m_name = name; }
void People::setage(int age){ m_age = age; }
void People::sethobby(char *hobby){ m_hobby = hobby; }
char *People::gethobby(){ return m_hobby; }

//派生類Student
class Student: public People{
public:
    void setscore(float score);
protected:
    float m_score;
};
void Student::setscore(float score){ m_score = score; }

//派生類Pupil
class Pupil: public Student{
public:
    void setranking(int ranking);
    void display();
private:
    int m_ranking;
};
void Pupil::setranking(int ranking){ m_ranking = ranking; }
void Pupil::display(){
    cout<<m_name<<"的年齡是"<<m_age<<",考試成績爲"<<m_score<<"分,班級排名第"<<m_ranking<<",TA喜歡"<<gethobby()<<"。"<<endl;
}

int main(){
    Pupil pup;
    pup.setname("小明");
    pup.setage(15);
    pup.setscore(92.5f);
    pup.setranking(4);
    pup.sethobby("乒乓球");
    pup.display();

    return 0;
}

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

2.2 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;
}

因爲 show() 函數是 private 屬性的,所以代碼第 36 行會報錯。把該行註釋掉,程序輸出結果爲:

我是小明,今年16歲,這次考了99.5分!

2.3 C++繼承時的名字遮蔽問題

基類成員和派生類成員的名字一樣時會造成遮蔽,這句話對於成員變量很好理解,對於成員函數要引起注意,不管函數的參數如何,只要名字一樣就會造成遮蔽。換句話說,基類成員函數和派生類成員函數不會構成重載,因爲函數所在的作用域不同。如果派生類有同名函數,那麼就會遮蔽基類中的所有同名函數,不管它們的參數是否一樣。

#include<iostream>
using namespace std;

//基類Base
class Base{
public:
    void func();
    void func(int);
};
void Base::func(){ cout<<"Base::func()"<<endl; }
void Base::func(int a){ cout<<"Base::func(int)"<<endl; }

//派生類Derived
class Derived: public Base{
public:
    void func(char *);
    void func(bool);
};
void Derived::func(char *str){ cout<<"Derived::func(char *)"<<endl; }
void Derived::func(bool is){ cout<<"Derived::func(bool)"<<endl; }

int main(){
    Derived d;
    d.func("c.biancheng.net");
    d.func(true);
    d.func();  //compile error
    d.func(10);  //compile error
    d.Base::func();
    d.Base::func(100);

    return 0;
}

2.4 具體解釋一下和作用域有關

本例中,B 繼承自 A,C繼承自 B,它們作用域的嵌套關係如下圖所示:

obj 是 C 類的對象,通過 obj 訪問成員變量 n 時,在 C 類的作用域中就能夠找到了 n 這個名字。雖然 A 類和 B 類都有名字 n,但編譯器不會到它們的作用域中查找,所以是不可見的,也即派生類中的 n 遮蔽了基類中的 n。

通過 obj 訪問成員函數 func() 時,在 C 類的作用域中沒有找到 func 這個名字,編譯器繼續到 B 類的作用域(外層作用域)中查找,仍然沒有找到,再繼續到 A 類的作用域中查找,結果就發現了 func 這個名字,於是查找結束,編譯器決定調用 A 類作用域中的 func() 函數。

這個過程叫做名字查找(name lookup),也就是在作用域鏈中尋找與所用名字最匹配的聲明(或定義)的過程。

2.5 C++繼承時的對象內存模型

沒有繼承時對象內存的分佈情況,成員變量和成員函數會分開存儲:

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

當存在繼承關係時,內存模型會稍微複雜一些。

#include <cstdio>
using namespace std;

//基類A
class A{
public:
    A(int a, int b);
public:
    void display();
protected:
    int m_a;
    int m_b;
};
A::A(int a, int b): m_a(a), m_b(b){}
void A::display(){
    printf("m_a=%d, m_b=%d\n", m_a, m_b);
}

//派生類B
class B: public A{
public:
    B(int a, int b, int c);
    void display();
private:
    int m_c;
};
B::B(int a, int b, int c): A(a, b), m_c(c){ }
void B::display(){
    printf("m_a=%d, m_b=%d, m_c=%d\n", m_a, m_b, m_c);
}

int main(){
    A obj_a(99, 10);
    B obj_b(84, 23, 95);
    obj_a.display();
    obj_b.display();

    return 0;
}

obj_a 是基類對象,obj_b 是派生類對象。假設 obj_a 的起始地址爲 0X1000,那麼它的內存分佈如下圖所示:


假設 obj_b 的起始地址爲 0X1100,那麼它的內存分佈如下圖所示:


可以發現,基類的成員變量排在前面,派生類的排在後面。

有成員變量遮蔽時的內存分佈規則仍然不變:

假設 obj_c 的起始地址爲 0X1300,那麼它的內存分佈如下圖所示:


當基類 A、B 的成員變量被遮蔽時,仍然會留在派生類對象 obj_c 的內存中,C 類新增的成員變量始終排在基類 A、B 的後面。

總結:在派生類的對象模型中,會包含所有基類的成員變量。這種設計方案的優點是訪問效率高,能夠在派生類對象中直接訪問基類變量,無需經過好幾層間接計算。從圖下往圖上即從高地址到低地址找,找到就停了,所以會遮蔽。

假設 obj_c 的起始地址爲 0X1300,那麼它的內存分佈如下圖所示:


當基類 A、B 的成員變量被遮蔽時,仍然會留在派生類對象 obj_c 的內存中,C 類新增的成員變量始終排在基類 A、B 的後面。

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

2.6 C++基類和派生類的構造函數與析構函數

類的構造函數不能被繼承。

在設計派生類時,對繼承過來的成員變量的初始化工作也要由派生類的構造函數完成,但是大部分基類都有 private 屬性的成員變量,它們在派生類中無法訪問,更不能使用派生類的構造函數來初始化。

這種矛盾在C++繼承中是普遍存在的,解決這個問題的思路是:在派生類的構造函數中調用基類的構造函數。

因爲基類構造函數不會被繼承,不能當做普通的成員函數來調用。換句話說,只能將基類構造函數的調用放在函數頭部,不能放在函數體中,即默認參數初始化列表中

派生類構造函數總是先調用基類構造函數再執行其他代碼(包括參數初始化表以及函數體中的代碼),所以初始化的二者順序無所謂。

具體看代碼:

#include<iostream>
using namespace std;

//基類People
class People{
protected:
    char *m_name;
    int m_age;
public:
    People(char*, int);
};
People::People(char *name, int age): m_name(name), m_age(age){}

//派生類Student
class Student: public People{
private:
    float m_score;
public:
    Student(char *name, int age, float score);
    void display();
};
//People(name, age)就是調用基類的構造函數
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
void Student::display(){
    cout<<m_name<<"的年齡是"<<m_age<<",成績是"<<m_score<<"。"<<endl;
}

int main(){
    Student stu("小明", 16, 90.5);
    stu.display();

    return 0;
}

也可以將基類構造函數的調用放在參數初始化表後面::

Student::Student(char *name, int age, float score): m_score(score), People(name, age){ }

這麼寫錯誤:

Student::Student(char *name, int age, float score){
    People(name, age);
    m_score = score;
}

那麼創建 C 類對象時構造函數的執行順序爲:

A類構造函數 --> B類構造函數 --> C類構造函數

派生類構造函數中只能調用直接基類的構造函數,不能調用間接基類的。以上面的 A、B、C 類爲例,C 是最終的派生類,B 就是 C 的直接基類,A 就是 C 的間接基類。

事實上,通過派生類創建對象時必須要調用基類的構造函數,這是語法規定。換句話說,定義派生類構造函數時最好指明基類構造函數;如果不指明,就調用基類的默認構造函數(不帶參數的構造函數);如果沒有默認構造函數,那麼編譯失敗

和構造函數類似,析構函數也不能被繼承。與構造函數不同的是,在派生類的析構函數中不用顯式地調用基類的析構函數,因爲每個類只有一個析構函數,編譯器知道如何選擇,無需程序員干涉

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

  • 創建派生類對象時,構造函數的執行順序和繼承順序相同,即先執行基類構造函數,再執行派生類構造函數。
  • 而銷燬派生類對象時,析構函數的執行順序和繼承順序相反,即先執行派生類析構函數,再執行基類析構函數

2.7 C++多繼承(多重繼承)

C++也支持多繼承(Multiple Inheritance),即一個派生類可以有兩個或多個基類。

多繼承容易讓代碼邏輯複雜、思路混亂,一直備受爭議,中小型項目中較少使用,後來的 JavaC#PHP 等乾脆取消了多繼承。

多繼承的語法也很簡單,將多個基類用逗號隔開即可。例如已聲明瞭類A、類B和類C,那麼可以這樣來聲明派生類D:

class D: public A, private B, protected C{
    //類D新增加的成員
}

D 是多繼承形式的派生類,它以公有的方式繼承 A 類,以私有的方式繼承 B 類,以保護的方式繼承 C 類。D 根據不同的繼承方式獲取 A、B、C 中的成員,確定它們在派生類中的訪問權限。

1.多繼承下的構造函數

多繼承形式下的構造函數和單繼承形式基本相同,只是要在派生類的構造函數中調用多個基類的構造函數。以上面的 A、B、C、D 類爲例,D 類構造函數的寫法爲:

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

基類構造函數的調用順序和和它們在派生類構造函數中出現的順序無關,而是和聲明派生類時基類出現的順序相同。仍然以上面的 A、B、C、D 類爲例,即使將 D 類構造函數寫作下面的形式:

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

那麼也是先調用 A 類的構造函數,再調用 B 類構造函數,最後調用 C 類構造函數。

2.命名衝突

當兩個或多個基類中有同名的成員時,如果直接訪問該成員,就會產生命名衝突,編譯器不知道使用哪個基類的成員。這個時候需要在成員名字前面加上類名和域解析符::,以顯式地指明到底使用哪個類的成員,消除二義性。

修改上面的代碼,爲 BaseA 和 BaseB 類添加 show() 函數,並將 Derived 類的 show() 函數更名爲 display():

#include <iostream>
using namespace std;

//基類
class BaseA{
public:
    BaseA(int a, int b);
    ~BaseA();
public:
    void show();
protected:
    int m_a;
    int m_b;
};
BaseA::BaseA(int a, int b): m_a(a), m_b(b){
    cout<<"BaseA constructor"<<endl;
}
BaseA::~BaseA(){
    cout<<"BaseA destructor"<<endl;
}
void BaseA::show(){
    cout<<"m_a = "<<m_a<<endl;
    cout<<"m_b = "<<m_b<<endl;
}

//基類
class BaseB{
public:
    BaseB(int c, int d);
    ~BaseB();
    void show();
protected:
    int m_c;
    int m_d;
};
BaseB::BaseB(int c, int d): m_c(c), m_d(d){
    cout<<"BaseB constructor"<<endl;
}
BaseB::~BaseB(){
    cout<<"BaseB destructor"<<endl;
}
void BaseB::show(){
    cout<<"m_c = "<<m_c<<endl;
    cout<<"m_d = "<<m_d<<endl;
}

//派生類
class Derived: public BaseA, public BaseB{
public:
    Derived(int a, int b, int c, int d, int e);
    ~Derived();
public:
    void display();
private:
    int m_e;
};
Derived::Derived(int a, int b, int c, int d, int e): BaseA(a, b), BaseB(c, d), m_e(e){
    cout<<"Derived constructor"<<endl;
}
Derived::~Derived(){
    cout<<"Derived destructor"<<endl;
}
void Derived::display(){
    BaseA::show();  //調用BaseA類的show()函數
    BaseB::show();  //調用BaseB類的show()函數
    cout<<"m_e = "<<m_e<<endl;
}

int main(){
    Derived obj(1, 2, 3, 4, 5);
    obj.display();
    return 0;
}

3.內存模型

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

C++多繼承時的對象內存模型


基類對象的排列順序和繼承時聲明的順序相同。

2.8 【有趣】藉助指針突破訪問權限的限制,訪問private、protected屬性的成員變量

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


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

當通過對象指針訪問成員變量時,編譯器實際上也是使用這種方式來取得它的值。爲了說明問題,我們不妨將上例中成員變量的訪問權限改爲 public,再來執行第 18 行的語句:

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


如果通過 p 指針訪問 m_a:

int a = p -> m_a;

那麼將被轉換爲下面的形式:

int a = * (int*) ( (int)p + 0 );

經過簡化以後爲:

int a = *(int*)p;

上述的轉換過程是編譯器自動完成的,當成員變量的訪問權限爲 private 時,我們也可以手動轉換,只要能正確計算偏移即可,這樣就突破了訪問權限的限制。

修改上例中的代碼,藉助偏移來訪問 private 屬性的成員變量:

#include <iostream>
using namespace std;

class A{
public:
    A(int a, int b, int c);
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 a1 = *(int*)&obj;
    int b = *(int*)( (int)&obj + sizeof(int) );

    A *p = new A(40, 50, 60);
    int a2 = *(int*)p;
    int c = *(int*)( (int)p + sizeof(int)*2 );
   
    cout<<"a1="<<a1<<", a2="<<a2<<", b="<<b<<", c="<<c<<endl;

    return 0;
}

運行結果:
a1=10, a2=40, b=20, c=60

前面我們說 C++ 的成員訪問權限僅僅是語法層面上的,是指訪問權限僅對取成員運算符.->起作用,而無法防止直接通過指針來訪問。你可以認爲這是指針的強大,也可以認爲是 C++ 語言設計的瑕疵。

2.9 C++虛繼承和虛基類詳解

多繼承時很容易產生命名衝突,即使我們很小心地將所有類中的成員變量和成員函數都命名爲不同的名字,命名衝突依然有可能發生,比如典型的是菱形繼承,如下圖所示:

 


圖1:菱形繼承


類 A 派生出類 B 和類 C,類 D 繼承自類 B 和類 C,這個時候類 A 中的成員變量和成員函數繼承到類 D 中變成了兩份,一份來自 A-->B-->D 這條路徑,另一份來自 A-->C-->D 這條路徑。

在一個派生類中保留間接基類的多份同名成員,雖然可以在不同的成員變量中分別存放不同的數據,但大多數情況下這是多餘的:因爲保留多份成員變量不僅佔用較多的存儲空間,還容易產生命名衝突。假如類 A 有一個成員變量 a,那麼在類 D 中直接訪問 a 就會產生歧義,編譯器不知道它究竟來自 A -->B-->D 這條路徑,還是來自 A-->C-->D 這條路徑。下面是菱形繼承的具體實現:

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

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

//直接基類C
class C: 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;
};

int main(){
    D d;
    return 0;
}

這段代碼實現了上圖所示的菱形繼承,第 25 行代碼試圖直接訪問成員變量 m_a,結果發生了錯誤,因爲類 B 和類 C 中都有成員變量 m_a(從 A 類繼承而來),編譯器不知道選用哪一個,所以產生了歧義。

爲了消除歧義,我們可以在 m_a 的前面指明它具體來自哪個類:

void seta(int a){ B::m_a = a; }

這樣表示使用 B 類的 m_a。當然也可以使用 C 類的:

void seta(int a){ C::m_a = a; }

1.虛繼承(Virtual Inheritance)

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

在繼承方式前面加上 virtual 關鍵字就是虛繼承,請看下面的例子:

//間接基類A
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;
};
int main(){
    D d;
    return 0;
}

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

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

現在讓我們重新梳理一下本例的繼承關係,如下圖所示:


圖2:使用虛繼承解決菱形繼承中的命名衝突問題


觀察這個新的繼承體系,我們會發現虛繼承的一個不太直觀的特徵:必須在虛派生的真實需求出現前就已經完成虛派生的操作。在上圖中,當定義 D 類時纔出現了對虛派生的需求,但是如果 B 類和 C 類不是從 A 類虛派生得到的,那麼 D 類還是會保留 A 類的兩份成員。

換個角度講,虛派生隻影響從指定了虛基類的派生類中進一步派生出來的類,它不會影響派生類本身。

在實際開發中,位於中間層次的基類將其繼承聲明爲虛繼承一般不會帶來什麼問題。通常情況下,使用虛繼承的類層次是由一個人或者一個項目組一次性設計完成的。對於一個獨立開發的類來說,很少需要基類中的某一個類是虛基類,況且新類的開發者也無法改變已經存在的類體系。

C++標準庫中的 iostream 類就是一個虛繼承的實際應用案例。iostream 從 istream 和 ostream 直接繼承而來,而 istream 和 ostream 又都繼承自一個共同的名爲 base_ios 的類,是典型的菱形繼承。此時 istream 和 ostream 必須採用虛繼承,否則將導致 iostream 類中保留兩份 base_ios 類的成員。


圖3:虛繼承在C++標準庫中的實際應用

2.C++虛繼承時的構造函數

在虛繼承中,虛基類是由最終的派生類初始化的,換句話說,最終派生類的構造函數必須要調用虛基類的構造函數。對最終的派生類來說,虛基類是間接基類,而不是直接基類。這跟普通繼承不同,在普通繼承中,派生類構造函數中只能調用直接基類的構造函數,不能調用間接基類的。

下面我們以菱形繼承爲例來演示構造函數的調用:

#include <iostream>
using namespace std;

//虛基類A
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);
public:
    void display();
protected:
    int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
    cout<<"m_a="<<m_a<<", m_b="<<m_b<<endl;
}

//直接派生類C
class C: virtual public A{
public:
    C(int a, int c);
public:
    void display();
protected:
    int m_c;
};
C::C(int a, int c): A(a), m_c(c){ }
void C::display(){
    cout<<"m_a="<<m_a<<", m_c="<<m_c<<endl;
}

//間接派生類D
class D: public B, public C{
public:
    D(int a, int b, int c, int d);
public:
    void display();
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){ }
void D::display(){
    cout<<"m_a="<<m_a<<", m_b="<<m_b<<", m_c="<<m_c<<", m_d="<<m_d<<endl;
}

int main(){
    B b(10, 20);
    b.display();
   
    C c(30, 40);
    c.display();

    D d(50, 60, 70, 80);
    d.display();
    return 0;
}

運行結果:
m_a=10, m_b=20
m_a=30, m_c=40
m_a=50, m_b=60, m_c=70, m_d=80

爲了避免出現這種矛盾的情況,C++ 乾脆規定必須由最終的派生類 D 來初始化虛基類 A,直接派生類 B 和 C 對 A 的構造函數的調用是無效的。在第 50 行代碼中,調用 B 的構造函數時試圖將 m_a 初始化爲 90,調用 C 的構造函數時試圖將 m_a 初始化爲 100,但是輸出結果有力地證明了這些都是無效的,m_a 最終被初始化爲 50,這正是在 D 中直接調用 A 的構造函數的結果。

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

修改本例中第 50 行代碼,改變構造函數出現的順序:

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

雖然我們將 A() 放在了最後,但是編譯器仍然會先調用 A(),然後再調用 B()、C(),因爲 A() 是虛基類的構造函數,比其他構造函數優先級高。如果沒有使用虛繼承的話,那麼編譯器將按照出現的順序依次調用 B()、C()、A()。

3.C++虛繼承下的內存模型

簡單的面向對象,只有單繼承或多繼承的情況下,內存模型很好理解,編譯器實現起來也容易,C++ 的效率和 C 的效率不相上下。一旦和 virtual 關鍵字扯上關係,使用到虛繼承或虛函數,內存模型就變得混亂起來,各種編譯器的實現也不一致,讓人抓狂。

這是因爲 C++ 標準僅對 C++ 的實現做了框架性的概述,並沒有規定細節如何實現,所以不同廠商的編譯器在具體實現方案上會有所差異。
修改上面的代碼,使得 A 是 B 的虛基類:

class B: virtual public A

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


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

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

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

當要訪問對象的成員變量時,需要知道對象的首地址和變量的偏移,對象的首地址很好獲得,關鍵是變量的偏移。對於固定部分,偏移是不變的,很好計算;而對於共享部分,偏移會隨着繼承層次的增加而改變,這就需要設計一種方案,在偏移不斷變化的過程中準確地計算偏移。各個編譯器正是在設計這一方案時出現了分歧,不同的編譯器設計了不同的方案來計算共享部分的偏移。

對於虛繼承,將派生類分爲固定部分和共享部分,並把共享部分放在最後,幾乎所有的編譯器都在這一點上達成了共識。主要的分歧就是如何計算共享部分的偏移,可謂是百花齊放,沒有統一標準。

2.10 C++將派生類賦值給基類(向上轉型)

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

向上轉型非常安全,可以由編譯器自動完成;向下轉型有風險,需要程序員手動干預。

賦值的本質是將現有的數據寫入已分配好的內存中,對象的內存只包含了成員變量,所以對象之間的賦值是成員變量的賦值,成員函數不存在賦值問題。運行結果也有力地證明了這一點,雖然有a=b;這樣的賦值過程,但是 a.display() 始終調用的都是 A 類的 display() 函數。換句話說,對象之間的賦值不會影響成員函數,也不會影響 this 指針。

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


可以發現,即使將派生類對象賦值給基類對象,基類對象也不會包含派生類的成員,所以依然不同通過基類對象來訪問派生類的成員。對於上面的例子,a.m_a 是正確的,但 a.m_b 就是錯誤的,因爲 a 不包含成員 m_b。

這種轉換關係是不可逆的,只能用派生類對象給基類對象賦值,而不能用基類對象給派生類對象賦值。理由很簡單,基類不包含派生類的成員變量,無法對派生類的成員變量賦值。同理,同一基類的不同派生類對象之間也不能賦值。

要理解這個問題,還得從賦值的本質入手。賦值實際上是向內存填充數據,當數據較多時很好處理,捨棄即可;本例中將 b 賦值給 a 時(執行a=b;語句),成員 m_b 是多餘的,會被直接丟掉,所以不會發生賦值錯誤。但當數據較少時,問題就很棘手,編譯器不知道如何填充剩下的內存;如果本例中有b= a;這樣的語句,編譯器就不知道該如何給變量 m_b 賦值,所以會發生錯誤。

#include <iostream>
using namespace std;

//基類
class A{
public:
    A(int a);
public:
    void display();
public:
    int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
    cout<<"Class A: m_a="<<m_a<<endl;
}

//派生類
class B: public A{
public:
    B(int a, int b);
public:
    void display();
public:
    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;
}


int main(){
    A a(10);
    B b(66, 99);
    //賦值前
    a.display();
    b.display();
    cout<<"--------------"<<endl;
    //賦值後
    a = b;
    a.display();
    b.display();

    return 0;
}

運行結果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99

將派生類指針賦值給基類指針時到底發生了什麼?

 

 

 

 

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