C++ 多重繼承

寫在之前:

C++是支持多重繼承的,但一定要慎用,因爲很容易出現各種各樣的問題。

#include <iostream>

using namespace std;

class B1{
public:
    B1(){cout<<"B1\n";}
};
class B2{
public:
    B2(){cout<<"B2\n";}
};
class C:public B2,public B1{    //:之後稱爲類派生表,表的順序決定基類構造函數調用的順序,析構函數的調用順序正好相反
};
int main()
{
    C c;
    return 0;
}

上面算是一段最簡單的多重繼承代碼了,編譯運行是沒有錯誤的。平時絕大部分時候,我們都只使用單繼承,所爲單繼承是針對多重繼承而言的,即一個類只有一個直接父類。其實有單繼承,肯定自然而然的會想到讓一個類去繼承多個類。這也是符合現實的,比如自然界絕大部分生物都是兩性繁殖,即每一個孩子都是繼承了父母兩個人的基因的。很幸運,C++是支持多重繼承的(java通過繼承和實現接口的結合,也能實現類似的多重繼承)。

符號二義性問題

使用多重繼承, 一個不小心就可能因爲符號二義性問題而導致編譯通不過。最簡單的例子,在上面的基類B1和B2中若存在相同的符號,那麼在派生類C中或使用C的對象時,若使用這個符號時,就會使編譯器搞不清寫代碼的人是想調用B1中的那個符號還是B2中的那個符號。當然我們可以通過顯示指出要調用的是那個類中的符號來解決這個問題,而有時也可以通過在派生類C中重新定義這個符號以覆蓋基類中的符號版本,從而使編譯器能夠正常工作。至於到底使用哪種解決辦法,就得具體情況具體分析了。

符號二義性問題的舉例:

#include <iostream>

using namespace std;

class B1{
public:
    B1(){cout<<"B1\n";b=1;}
protected:
    int b;
};
class B2{
public:
    B2(){cout<<"B2\n";b=2;}
protected:
    int b;
};
class C:public B2,public B1{    //:之後稱爲類派生表,表的順序決定基類構造函數調用的順序,析構函數的調用順序正好相反
public:
    void Print(){cout<<b<<endl;}
};
int main()
{
    C c;
    c.Print();
    return 0;
}

這段代碼在Code::Blocks 13.12中編譯,會報以下錯誤:

=== Build: Debug in MultipleInheritance (compiler: GNU GCC Compiler) ===
F:\...\main.cpp      In member function 'void C::Print()':
F:\...\main.cpp  19  error: reference to 'b' is ambiguous
F:\...\main.cpp  9   error: candidates are: int B1::b
F:\...\main.cpp  15  error:                 int B2::b
        === Build failed: 3 error(s), 0 warning(s) (0 minute(s), 0 second(s)) ===

解決辦法:

修改類C如下:

class C:public B2,public B1{    //:之後稱爲類派生表,表的順序決定基類構造函數調用的順序,析構函數的調用順序正好相反
public:
    C(){cout<<"C\n";b=3;}
    void Print(){
        cout<<"B1::b = "<<B1::b<<endl;
        cout<<"B2::b = "<<B2::b<<endl;  //第一種解決辦法
        cout<<"C::b = "<<b<<endl;   //第二種解決辦法
    }
protected:
    int b;
};

運行結果如下:

image

間接基類會有多個副本

加入上面的例子中B1和B2都派生自類A,那麼在類C的對象中,就會有兩份類A的對象空間,這自然也會導致上面的符號二義性問題,當然也可以通過顯式指出使用的是由B1繼承的A的對象空間還是由B2繼承的A的對象空間。但更多時候,我們可能希望的還是在類C的對象中只保留一份類A的對象空間。這個問題可以通過虛繼承來解決。

在類C中同時存在兩份類A的對象空間:

#include <iostream>

using namespace std;
class A{
public:
    A(int a):m_a(a){};
protected:
    int m_a;
};
class B1:public A{
public:
    B1(int a):A(a){cout<<"B1\n";}
protected:
};
class B2:public A{
public:
    B2(int a):A(a){cout<<"B2\n";}
protected:
};
class C:public B2,public B1{    //:之後稱爲類派生表,表的順序決定基類構造函數調用的順序,析構函數的調用順序正好相反
public:
    C(int a1,int a2):B2(a2),B1(a1){cout<<"C\n";}
    void Print(){
        cout<<"B1::m_a = "<<B1::m_a<<endl;
        cout<<"B2::m_a = "<<B2::m_a<<endl;
        // cout<<"m_a = "<<m_a<<endl;   直接這樣調用,編譯時會報與上面類似的二義性錯誤。
    }
protected:
};
int main()
{
    C c(1,2);
    c.Print();
    return 0;
}

運行結果:

image

只存在一份類A對象空間的方法:

#include <iostream>

using namespace std;
class A{
public:
    A(){cout<<"無參構造A"<<endl;};
    A(int a):m_a(a){cout<<"有參構造A"<<endl;};
protected:
    int m_a;
};
class B1:virtual public A{  //使用virtural關鍵字實現虛繼承
public:
    B1(){cout<<"B1\n";};    //不是必須的
protected:
};
class B2:virtual public A{  //使用virtural關鍵字實現虛繼承
public:
    B2(){cout<<"B2\n";};    //也不是必須的
protected:
};
class C:public B2,public B1{    //:之後稱爲類派生表,表的順序決定基類構造函數調用的順序,析構函數的調用順序正好相反
public:
    C(int a):A(a){cout<<"C\n";}
    void Print(){
        cout<<"B1::m_a = "<<B1::m_a<<endl;
        cout<<"B2::m_a = "<<B2::m_a<<endl;
        cout<<"m_a = "<<m_a<<endl;
    }
protected:
};
int main()
{
    C c(3);
    c.Print();
    return 0;
}

運行結果:

image

虛基類

首先,區別下虛基類抽象基類的概念,虛基類是在多重繼承中,被虛繼承的祖父類,比如上面的類A,抽象基類是在類的定義中,含有純虛成員函數(只有虛函數聲明,沒有函數體)。抽象基類是不能被實例化的,而虛基類理論上一般是可以實例化的(如果虛基類也含有純虛函數,則不可以被實例化)。而一般虛基類也不會用來實例對象的,其用法更接近於java中的接口,後面會進一步詳述。

虛基類的初始化問題

其實從上面例子中的一系列構造函數中,不難看出,這一系列構造函數確實比較奇怪。首先,虛基類A需要定義帶一個參數的構造函數來初始化成員變量m_a,這在很多時候是裏所當然的;然後在B1和B2中,根據一般單繼承的用法來說,這兩個類中都得定義一個帶一個參數的構造函數,並在初始化列表中調用A的單參構造函數,然而這裏並沒有這麼做,這是因爲我們在B1和B2中不需要做額外的初始化操作;所以,很顯然,m_a的初始化工作只能且必須交給類C來完成了,所以在類C中定義了一個單參構造函數,且在其初始化列表中直接(跨過B1和B2)調用類A的構造函數了。(在單繼承中,在派生類構造函數的初始化列表中只需調用直接基類的相應構造函數,而不需要跨越式地調用祖宗類的構造函數。)

事實上,在上面的例子中,我們還爲類B1、B2和類A分別定義了無參構造函數。其實B1和B2是不需要顯示的定義這個無參構造函數,因爲編譯器會爲我們生成一個默認的無參構造函數。而類A必須顯式的定義一個無參構造函數,客觀原因是,因爲我們已經定義了一個單參構造函數,所以編譯器不會再爲我們生成默認的無參構造函數了。主觀原因是,雖然在類C中沒有顯式地來初始化B1和B2,但畢竟類C是派生自類B1和B2,所以在構造C的對象時,必然也要初始化其中B1和B2那部分,這裏當然調用的是B1和B2的無參構造函數了,而B1和B2是派生自類A的,類B1和B2中只有無參構造函數(不考慮默認的拷貝構造函數),所以初始化B1或者B2的對象時,就必須調用類A的無參構造函數(當然m_a就得不到初始值了)。所以,綜上,在類A中必須顯式的定義一個無參構造函數,否則編譯器就不幹了(至少GCC是這樣)。可事實上又是,我們再構造類C的對象時,調用完類B1和B2的無參構造函數後,並沒有看到調用類A的無參構造函數。這也好理解,根據運行結果可以看到,由於在類C的初始化列表,最先調用的是A的單參構造函數,所以很早就對A那部分進行了初始化,那麼在初始化完B1和B2後,顯然沒必要對A那部分再次進行初始化,否則成什麼樣子,結果可以預料嗎?

話說回來,這是多麼奇葩!多麼複雜!多麼容易出錯的一個初始化過程!

可是,還有更變態的。

假若B1和B2也有自己的初始化工作要做,切都做了對虛基類A的初始化工作,會怎樣呢?看代碼,

#include <iostream>

using namespace std;
class A{
public:
    A(){cout<<"無參構造A"<<endl;};
    A(int a):m_a(a){cout<<"有參構造A"<<endl;};
protected:
    int m_a;
};
class B1:virtual public A{  //使用virtural關鍵字實現虛繼承
public:
    B1(){cout<<"B1\n";};
    B1(int a):A(a){cout<<"有參構造B1\n";}
protected:
};
class B2:virtual public A{  //使用virtural關鍵字實現虛繼承
public:
    B2(){cout<<"B2\n";};
    B2(int a):A(a){cout<<"有參構造B2\n";}
protected:
};
class C:public B2,public B1{    //:之後稱爲類派生表,表的順序決定基類構造函數調用的順序,析構函數的調用順序正好相反
public:
    C(int a):A(a){cout<<"C\n";}
    C(int a,int ba2,int ba1):A(a),B2(ba2),B1(ba1){cout<<"三參構造C\n";}
    void Print(){
        cout<<"B1::m_a = "<<B1::m_a<<endl;
        cout<<"B2::m_a = "<<B2::m_a<<endl;
        cout<<"m_a = "<<m_a<<endl;
    }
protected:
};
int main()
{
    C c1(4,5,6);
    c1.Print();
    return 0;
}

運行結果

image

其實從構造函數的調用過程來看,出現這個結果的原因與上面的分析是一樣的,而上面定義的C的三參構造函數,以及實例對象c1時,傳遞的三個常量中,5和6都是沒意義的,只有在初始化列表中用來初始化A的值會最終賦給m_a。這樣的運行結果,於這樣的初始化方法,多麼不協調啊!

總的來說,virtual base(虛基類)的初始化責任是由繼承體系中的最底層(most derived)class負責,這暗示(1)classes若派生自virtual bases 而需要初始化,必須認知其virtual bases——不論那些bases距離多遠,(2)當一個新的derived class加入繼承體系中,它必須承擔其virtual bases(不論直接或間接)的初始化責任。(引自《Effective C++ 中文版》,侯捷譯)

此外,另一個問題就是虛基類的初始化過程是很費時間的,所以通常是不在虛基類中定義成員變量的,只聲明接口函數,這就與java中接口的用法很類似。

寫在最後:

綜上種種,C++是支持多重繼承的,但一定要慎用,因爲很容易出現各種各樣的問題。

發佈了24 篇原創文章 · 獲贊 3 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章