目录
- 多继承的定义和用法
- 定义多继承
- 多继承中派生类对象的内存布局
- 访问基类成员
- 多继承带来的问题
- 虚继承
- 虚继承的语法
- 虚继承对象的内存布局
- 虚继承中的构造
- 虚继承的缺点
多继承的定义和用法
C++支持多继承,即一个派生类可以有多个基类。
很多时候,单继承就可以满足开发需求,但在特定的情况下就不行。比如有两个类A和B,现在要有一个类C,它同时具有A和B的属性和行为,这种情况下单继承就不能满足要求。
用鸭嘴兽来举例:
从形态学上来讲,鸭嘴兽应该属于鸟类,原因是鸭嘴兽具有扁平的、像鸭子一样的嘴巴,而且是角质的,不像哺乳动物那种肉质的口唇,关键是鸭嘴兽通过下蛋来繁殖后代,这明显是鸟类的特征。然而,鸭嘴兽也靠乳汁来哺育幼仔,浑身密布着浓褐色的短兽毛,这又是哺乳动物的重要特征。所以鸭嘴兽既是鸟类,又是哺乳动物。
如果要在程序中定义一个鸭嘴兽类,采用单继承肯定是不行的。否则,鸭嘴兽要么是鸟类,要么是哺乳动物,显然不符合实际情况。所以此时应当采用多继承,让鸭嘴兽同时继承鸟类和哺乳动物类的属性和行为。这样,一个鸭嘴兽就既是鸟类,又是哺乳动物,符合实际情况。
定义多继承
多继承的语法同单继承类似,只需要在定义类时在类名后面依次罗列继承方式和基类即可。继承方式同单继承一样,也有public,protected和private。在多继承中,针对不同的基类可以使用不同的继承方法。其语法如下:
class 派生类名 : 继承方式1 基类名1,
继承方式2 基类名2,
⋯⋯
{
派生类新增成员
};
多继承的类图如下:
例如:定义一个鸭嘴兽类,应该继承鸟类和哺乳动物类
#include <iostream>
// 鸟类
class Bird
{
public:
Bird()
{
std::cout << "鸟类的构造函数" << std::endl;
}
~Bird()
{
std::cout << "鸟类的析构函数" << std::endl;
}
};
// 哺乳动物类
class Mammal
{
public:
Mammal()
{
std::cout << "哺乳动物类的构造函数" << std::endl;
}
~Mammal()
{
std::cout << "哺乳动物类的析构函数" << std::endl;
}
};
// 鸭嘴兽类
class Duckbill
: public Bird
, public Mammal
{
public:
Duckbill()
: Mammal()
, Bird()
{
std::cout << "鸭嘴兽类的构造函数" << std::endl;
}
~Duckbill()
{
std::cout << "鸭嘴兽类的析构函数" << std::endl;
}
};
void Test()
{
Duckbill duckbill;
}
int main()
{
Test();
system("pause");
return 0;
}
vs2022下的运行结果:
鸟类的构造函数
哺乳动物类的构造函数
鸭嘴兽类的构造函数
鸭嘴兽类的析构函数
哺乳动物类的析构函数
鸟类的析构函数
同定义单继承派生类的构造函数一样,定义多继承派生类时也要注意基类的初始化。如果基类没有默认的构造函数,那么在派生类构造函数的初始化列表里就要依次调用各个基类的构造函数。无论开发者如何安排,基类构造函数的调用次序总是按照其定义时的次序。
我们在初始化列表中先调用哺乳动物的构造函数,再调用鸟类的构造函数,运行结果按照定义时的顺序进行,析构函数相反。
多继承派生类对象在析构时按照与构造相反的顺序进行,即先调用派生类自己的析构函数,再析构各个数据成员,然后按照相反的顺序,依次调用各个基类的析构函数
多继承中派生类对象的内存布局
同单继承一样,通过多继承派生类将拥有基类所有的属性和行为。在多继承派生类的对象中,将依次排列各个基类的非静态数据成员以及派生类新增的数据成员。派生类对象内存中的数据是按照定义时的顺序排列的。也就是说,在定义派生类时,排在前面的基类,其数据在派生类对象中也排在前面。
一个多继承类图如下:
它的派生类的内存布局如下:
举例说明:
#include <iostream>
// 鸟类
class Bird
{
public:
Bird()
{
std::cout << "鸟类的构造函数" << std::endl;
}
~Bird()
{
std::cout << "鸟类的析构函数" << std::endl;
}
char a;
int b;
char c;
};
// 哺乳动物类
class Mammal
{
public:
Mammal()
{
std::cout << "哺乳动物类的构造函数" << std::endl;
}
~Mammal()
{
std::cout << "哺乳动物类的析构函数" << std::endl;
}
private:
char a;
char c;
char b;
};
// 鸭嘴兽类
class Duckbill
: public Bird
, public Mammal
{
public:
Duckbill()
: Mammal()
, Bird()
{
std::cout << "鸭嘴兽类的构造函数" << std::endl;
}
~Duckbill()
{
std::cout << "鸭嘴兽类的析构函数" << std::endl;
}
};
void Test()
{
Duckbill duckbill;
std::cout << "Bird: " << sizeof(Bird) << std::endl;
std::cout << "Mammal: " << sizeof(Mammal) << std::endl;
std::cout << "Duckbill: " << sizeof(Duckbill) << std::endl;
}
int main()
{
Test();
system("pause");
return 0;
}
vs2022运行结果:
鸟类的构造函数
哺乳动物类的构造函数
鸭嘴兽类的构造函数
Bird: 12
Mammal: 3
Duckbill: 16
鸭嘴兽类的析构函数
哺乳动物类的析构函数
鸟类的析构函数
Duckbill的内存布局是
char a;
int b;
char c;
char a;
char c;
char b;
按照结构体的内存对齐方式计算得出结果为16,而不是12+3。
派生类对象也可以转换为其基类类型的对象。对于多继承的情况,在转换时编译器可以根据要转换的类型进行适当的转换。例如,对于上面的多继承类,如果要将Derived类对象转换成Base2类的对象,编译器会从Derived对象中按照内存排列的顺序,从中截取出从Base2类继承来的部分构成新对象。
Derived d;
Base2 b2 = static_cast<Base2>( d );
举例说明:
将上述例子中的duckbill转换成基类Mammal对象
Mammal mammal = static_cast<Mammal>(duckbill);
std::cout << "Mammal: " << sizeof(mammal) << std::endl; // Mammal: 3
访问基类成员
在多继承中,如果多个基类拥有同名成员,那么在访问基类成员时,仅通过成员名并不能区分是哪个基类的成员。解决的方法是在成员名前用域运算符::指明成员所属的基类。通过这种方法访问数据成员和函数成员的语法如下:
基类名 :: 数据成员名; // 在派生类成员函数中访问基类成员数据
基类名 :: 函数成员名( 参数列表 ); // 在派生类成员函数中访问基类成员函数
派生类对象 . 基类名 :: 数据成员名;
派生类对象 . 基类名 :: 函数成员名( 参数列表 );
派生类指针 -> 基类名 :: 数据成员名;
派生类指针 -> 基类名 :: 函数成员名( 参数列表 );
前提是基类的成员变量是公有变量
举例:
duckbill.Mammal::a = 'a';
duckbill.Bird::a = 'A';
void Duckbill::Foo()
{
Mammal::a = 'a';
Bird::a = 'A';
}
多继承带来的问题
多继承虽然功能强大,可以让派生类同时具有多个基类的属性和行为,但是多继承同时也会带来一些严重的问题。其中比较常见的问题就是多继承会导致数据重复,并由此带来数据不一致的问题。
举例:
#include <iostream>
// 动物类
class Animal
{
public:
int weight;
};
// 鸟类
class Bird
: public Animal
{
public:
char a;
int b;
char c;
};
// 哺乳动物类
class Mammal
: public Animal
{
public:
char a;
char c;
char b;
};
// 鸭嘴兽类
class Duckbill
: public Bird
, public Mammal
{
public:
};
void Test()
{
Duckbill duckbill;
std::cout << "Bird: " << sizeof(Bird) << std::endl;
std::cout << "Mammal: " << sizeof(Mammal) << std::endl;
std::cout << "Duckbill: " << sizeof(Duckbill) << std::endl;
}
int main()
{
Test();
system("pause");
return 0;
}
运行结果:
Bird: 16
Mammal: 8
Duckbill: 24
很明显,Duckbill中的weight有两份。
比较典型的情况是一个派生类D从两个基类B和C中派生,而这两个基类又有一个共同的基类A,这就会导致A的数据在D中被重复两次,如图16-7所示。D多继承B和C,将B和C的数据复制到D中。由于A的数据已经分别被B和C继承,所以A的数据在D中将重复两次。而且在定义D类的成员函数时,或者通过D类对象和指针访问成员数据a时,必须用域运算符::指明a所在的类,即:
B::a = 1; // 在D的成员函数中访问A类的数据成员
C::a = 2;
D dObj; // D类对象
dObj.B::a = 3; // 通过D类对象访问A类的数据成员
dObj.C::a = 4;
D *pObj = new D(); // D类指针
pObj->B::a = 5; // 通过D类指针访问A类的数据成员
pObj->C::a = 6;
从编译器的设计角度来讲,当D从B和C继承时并不知道基类A的存在。D只能全盘接受来自B和C的数据,而无法区分其中的数据a到底是从B继承而来的,还是从C继承而来的。所以要访问数据a,只能由用户来指明。
从逻辑的角度来讲,在D类的对象中A的数据应当只有一份。比如有一个动物基类Animal,它具有重量属性。鸟类(Bird)和哺乳动物类(Mammal)都从Animal派生,然后鸭嘴兽类(DuckBill)又从鸟类和哺乳动物类派生。从继承的语义来讲,一个DuckBill对象也是一个Animal,所以鸭嘴兽应当具有重量属性。但是,由于多继承导致数据冗余,所以基类的一份数据,在其间接派生类中产生了多份副本。所以在上述的鸭嘴兽对象中将具有“两”个重量属性。这显然是不符合逻辑的。而且由于数据冗余,也容易导致数据的不一致。例如上例的D类,其中继承自B的数据a和继承自C的数据a可以分别访问,如果开发者不能始终保证每次修改两个数据使其完全一样,那么就很容易导致数据不一致。
D dObj; // 定义D类对象
⋯⋯
dObj.B::a = 1; // 修改继承自B的数据a
⋯⋯
dObj.C::a = 2; // 修改继承自C的数据a
显然,在上述代码中很容易导致一个数据a有两个不同值,而这种情况是多继承无法克服的一个缺点。另外,如果A类的构造函数带有参数(而且没有默认构造函数),那么在B类和C类构造时就必须调用这个构造函数。假设由于开发者的疏忽,导致B类和C类在调用A类的构造函数时不一致,那么D类中的两个数据a也就会不一致。
为了解决多继承导致的数据冗余和数据不一致的问题,可以采用虚拟继承机制,也可以禁止最初的基类带有数据。一个不带有任何数据(仅有函数成员)的基类也称做接口。
虚继承
虚拟继承是解决多继承带来的问题的一个重要机制。通过虚拟继承,基类的数据在派生类中将只有一份副本,从而避免了多继承导致的数据冗余和数据不一致问题。
虚继承的语法
虚拟继承是在定义派生类时将基类指明为虚基类,或者说派生类以虚拟的方式从基类派生。虚拟继承的方法是在普通继承的基类名前加上virtual关键字,如下所示:
class派生类名 : 继承方式 virtual 基类名
{
派生类的定义
};
例如B类从A类虚拟继承,则B类定义如下:
class B : public virtual A // B类从A类虚拟继承
{
⋯⋯
private:
int b; // B类新增的成员数据
};
虚继承对象的内存布局
虚继承除了常规的数据成员内存,还会有虚表指针。
#include <iostream>
// 动物类
class Animal
{
public:
int weight;
};
// 鸟类
class Bird
: public virtual Animal
{
public:
int b;
};
// 哺乳动物类
class Mammal
: public virtual Animal
{
public:
char a;
int c;
int b;
};
// 鸭嘴兽类
class Duckbill
: public Bird
, public Mammal
{
public:
};
void Test()
{
Duckbill duckbill;
Bird bird;
Mammal mammal;
std::cout << "Bird: " << sizeof(bird) << std::endl;
std::cout << "Mammal: " << sizeof(mammal) << std::endl;
std::cout << "Duckbill: " << sizeof(duckbill) << std::endl;
}
int main()
{
Test();
system("pause");
return 0;
}
vs2022、x64位运行结果
Bird: 24
Mammal: 32
Duckbill: 48
Duckbill对象的内存布局如下:
与普通继承不同,在虚拟继承中,派生类对象并不是在其内存中保留一份虚基类数据的副本,而是通过一种间接的引用方式,即将虚基类子对象的数据单独存放,在派生类对象中设置一个指针指向基类子对象。这样,当一个派生类通过多个继承路径继承同一个虚基类时,并不需要产生多个数据副本,而只要维护这个虚基类指针即可。
虚继承中的构造
由于在虚拟继承中,虚基类的数据只有一份,所以在间接派生类构造时需要特殊处理,即只能初始化虚基类一次。
假设Vehicle类有一个带有参数的构造函数(而且没有默认构造函数)Vehicle ::Vehicle(int number),那么在中间派生类(虚拟继承)Tank和Boat的构造函数中都要显式调用Vehicle(int number)。但是在AmphiTank类多继承自Tank类和Boat类之后,如果仍然通过两个基类来初始化Vehicle,那么Vehicle将被初始化两次,从而可能导致数据不一致。
所以在C++中,对于虚基类的初始化进行了特殊处理。**如果是在一级派生中,比如Tank类虚拟继承Vehicle类,那么其初始化同一般继承一样。如果是在多级派生中,那么虚基类的初始化将由最终一级的派生类负责。**所以,在水陆两栖坦克的类层次结构中,虚基类Vehicle的初始化应当由最终一级派生类AmphiTank负责,即Vehicle的构造函数应当放在AmphiTank的初始化列表中。
举例:
#include <iostream>
// 动物类
class Animal
{
public:
Animal(int _num)
: weight(_num)
{
}
int weight;
};
// 鸟类
class Bird
: public virtual Animal
{
public:
Bird(int _num)
: Animal(_num)
, b(_num)
{}
int b;
};
// 哺乳动物类
class Mammal
: public virtual Animal
{
public:
Mammal(int _num)
: Animal(_num)
, b(_num)
{}
char a = ' ';
int c = 1;
int b = 2;
};
// 鸭嘴兽类
class Duckbill
: public Bird
, public Mammal
{
public:
Duckbill(int animal, int bird, int mammal)
: Animal(animal)
, Bird(bird)
, Mammal(mammal)
{}
};
void Test()
{
Duckbill duckbill(1,2,3);
// Bird的weight
std::cout << duckbill.Bird::weight << std::endl;
// Mammal的weight
std::cout << duckbill.Mammal::weight << std::endl;
// Duckbill的weight
std::cout << duckbill.weight << std::endl;
}
int main()
{
Test();
system("pause");
return 0;
}
vs2022运行结果:
1
1
1
虽然我们在构造Bird和Mammal时用不同的值都构造了Animal,但是只会由最后一级Duckbill负责。
如果一个派生类既有虚基类(不一定是直接基类),又有非虚基类,那么无论初始化列表如何排列,虚基类总是先初始化。如果有多个虚基类,那么排在前面的先初始化。
派生类的析构顺序总是与构造顺序相反,所以如果一个派生类有虚基类,则虚基类总是在最后析构。
虚继承的缺点
虚拟继承虽然可以解决多继承带来的数据冗余和数据不一致的缺点,但虚拟继承本身也存在一些问题,具体问题如下:
◆ 增加内存。为了保证虚基类的数据在派生类中只出现一次,采用虚继承的方式引入了虚基类指针,额外增加了类的占用内存。
◆ 派生类要显式初始化其虚拟基类。通常从开发者的角度来讲,设计一个派生类只要初始化其直接基类即可。但是如果在类的派生层次中存在虚拟基类,那么派生类始终要负责这些虚拟基类的初始化,这在一定程度上导致了设计的复杂化。
多继承容易导致数据冗余和数据不一致,而虚拟继承在解决了这个问题的同时又引入了新的问题。对于类层次结构的设计者来讲,可以采取另外一种方法来解决多继承的问题,即只允许一个基类有数据,其他基类只有方法,这样就消除了数据冗余和数据不一致的问题。只有方法没有数据的类也称做接口。