首页 > 代码库 > C++ 多重继承

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++是支持多重继承的,但一定要慎用,因为很容易出现各种各样的问题。