C++中的各种"虚"
C++中的一些重要概念都与“虚”相关,比如:
① 虚基类、虚继承、虚基类指针(vbptr)、虚基类表(vbtable);
② 虚函数、纯虚函数、虚函数指针(vfptr)、虚函数表(vftable)、抽象类、虚析构、纯虚析构。
这里对上面罗列出的概念做一个总结,争取把这些都一次讲清楚:
1. 菱形继承
由于C++支持多继承,即一个类可以继承自多个类,故有时候会存在菱形继承(又叫钻石继承)的情景,即两个子类继承同一个父类而又有子类同时继承这两个子类
:
菱形继承示意伪代码:
class CA{ public: int m_A; }; class CB :public CA{}; class CC :public CA{}; class CD :public CB,public CC{};
即CB和CC继承自CA类,而CD由继承自CB类和CC类。
菱形继承会产生一些问题:
-
当CD对象想要调用成员变量
m_A
的时候,如果不使用作用域加以区分调用哪个父类中的m_A
时,会产生错误,即产生二义性的问题:CD *obj = new CD(); obj->CB::m_A = 10; obj->CC::m_A = 20; //cout << obj->m_A << endl; // 报错 cout << obj->CB::m_A << endl; // 成功执行,输出10 cout << obj->CC::m_A << endl; // 成功执行,输出20 delete obj;
-
菱形继承导致同个成员变量的多次继承,造成CD对象中
m_A
变量的冗余(空间浪费)。
而虚继承技术是用于解决菱形继承上述问题的方法。
1.1 虚继承 && 虚基类
利用虚继承可以解决菱形继承的问题,在继承之前,加上关键字virtual
:
class CA{
int m_A;
};
class CB :virtual public CA{};
class CC :virtual public CA{};
class CD :public CB,public CC{};
CB和CC虚继承自CA类,此时CA类就是虚基类。
此时不论加不加作用域,CD访问m_A都是在访问同一片地址:
CD *obj = new CD();
obj->CB::m_A = 10;
obj->CC::m_A = 20;
cout << obj->m_A << endl; // 成功执行,输出20
cout << obj->CB::m_A << endl; // 成功执行,输出20
cout << obj->CC::m_A << endl; // 成功执行,输出20
delete obj;
这是因为CD类的对象从CB和CC中继承下来的对象不再是他们各自的m_A
,而是一个指针 —— vbptr (virtual base pointer,虚基类指针)
。
1.2 虚基类指针(vbptr)&& 虚基类表(vbtable)
CD实例化对象中的 vbptr
会指向其 vbtable ( virtual base table,虚基类表 )
, 而虚基类表中记录着vbptr
指向实际变量的偏移量(offset),通过 vbptr + offset
的方式可以访问到唯一的成员变量,从而不再产生歧义和空间浪费的问题。
【注意】C++ 创建一个子类对象时会调用父类的构造函数,那么会创建父类对象吗?
答曰:不会创建另外一个父类对象,只是初始化子类中属于父类的成员,父子类上同名的成员变量和函数可以通过作用域来指定。
创建一个对象的时候,发生了两件事情,一是分配对象所需的内存,二是调用构造函数进行初始化。子类对象包含从父类对象继承过来的成员,实现上来说,一般也是子类的内存区域中有一部分就是父类的内存区域。调用父类构造函数的时候,这块父类对象的内存区域就被初始化了。为了避免未初始化的问题,语法强制子类调用父类构造函数。
2. 多态
多态是C++面向对象的三大特性之一。
-
多态可以分为两类:
- 静态多态: 函数重载 和 运算符重载属于静态多态,即复用函数名;
- 动态多态: 基于 派生类 和 虚函数 实现运行时多态。
-
静态多态和动态多态的区别:
- 静态多态:函数地址早绑定 —— 编译阶段就已经确定函数地址;
- 动态多态:函数地址晚绑定 —— 运行阶段才能确定函数的地址。
2.1 函数地址绑定时机(早/晚绑定)
通过下面的C++伪代码来理解什么是函数地址早/晚绑定:
/* 动物类 */
class Animal {
public:
void speak(){ cout << "动物在说话" << endl; }
};
/* 猫类 */
class Cat :public Animal{
void speak(){ cout << "迪奥纳特调~" << endl; }
};
/* 测试API */
void doSpeak(Animal &animal){ // 父类引用指向子类对象,
animal.speak();
}
/* 测试案例 */
void test01(){
Cat cat;
doSpeak(cat); //?问题:该行输出什么?
}
C++中允许父子之间的类型转换(不需要强制转换),在doSpeak()函数中参数是父类的引用,test01()函数中传入的是子类对象,这在语法上是没毛病的。可能会有的同学认为我们传入的参数是Cat类,理应调用Cat类的speak()函数,但实际上 test01()函数中,输出的结果是 "动物在说话"
,即调用的是父类Animal类的speak()函数。
为什么会产生这样的现象?
原因就在于void doSpeak(Animal &animal)
函数是地址早绑定的,即在编译时就已经确定doSpeak()内部speak()函数的调用地址是Animal类中的speak()函数,故此不论传入test01()函数的对象参数是继承自Animal类的猫类狗类还是别的什么类,最终的结果都将是调用父类Animal类的speak()函数。
如果想让猫说话,这个函数的地址就不能是早绑定的,需要在运行阶段进行绑定(晚绑定),通过派生类和虚函数实现,即运行时多态。
2.2 虚函数
在基类Animal
类的void speak()
函数前加上virtual
关键字,使其成为虚函数:
virtual void speak(){ cout << "动物在说话" << endl; }
继承自含有虚函数的基类后,子类重写父类中的虚函数,就可以实现地址晚绑定。
此时再次运行test01()函数后,输出的结果是 "迪奥纳特调~"
。特点就是会根据传入的对象不同,执行相应类的函数,总结如下:
-
动态多态满足条件:
- 有继承关系
- 子类重写父类中的虚函数
-
动态多态的使用:
-
父类的引用指向子类传入对象
/* 假设Cat类继承自Animal类且重写了Animal类的虚函数 */ // 参数为父类引用 void doSpeak(Animal &animal){ animal.speak(); } int main(){ Cat cat; // 传入子类对象 doSpeak(cat); // 执行Cat类中的speak()函数 return 0; }
-
父类的指针指向子类传入对象
/* 假设Cat类继承自Animal类且重写了Animal类的虚函数 */ Animal *obj = new Cat(); obj.speak(); // 执行Cat类中的speak()函数
-
2.3 虚函数指针(vfptr)与虚函数表(vftable)
当我们在给Animaal类的speak()函数加上virtual关键字之前,实例化一个Animal对象obj并用sizeof(obj),可以看到,大小为1字节。
这是因为C++类中只有非静态成员变量是存储在对象中的,其他的静态成员变量、静态成员函数、成员函数都由所有对象共享类中的一份实例,而为了区分空对象和NULL,C++中规定空对象的大小为1个字节。
但在给Animaal类的speak()函数加上virtual关键字之后,再使用sizeof()函数查看该对象大小,可以看到结果是4字节(32位OS)或8字节(64位OS),具体视操作系统位数而定。
这是为什么呢?
因为使用虚函数后,在对象的地址空间中存储了一个指针,即 vfptr(virtual function pointer,虚函数指针);
vfptr 指针会指向一张表,即 vftable(virtual function table,虚函数表),该表内部会记录虚函数的地址。
当子类即Cat类没有重写父类即Animal类中的虚函数时,子类会继承父类中的vfptr和vftable,如下示意图:
当子类即Cat类重写父类即Animal类中的虚函数之后,子类中 vftable 内部会替换成子类虚函数的地址(父类中的vftable没有改变),如下示意图:
在满足继承与虚函数的重写后,当父类的指针或者引用指向子类对象时,就会发生多态,具体执行子类还是父类中的函数由子类中 vfptr 查 vftable 决定。
2.3.1 多态的优点
使用多态有如下优点:
- 代码组织结构清晰,可读性强
- 利于项目的前期开发和后期的拓展及维护
使用多态符合大型软件工程开发设计原则中的开闭原则,即对修改(源码)关闭,对添加(插件/功能/模块)开放。
举一个例子,比如我们要实现一个二元运算计算器,在没有掌握多态之前,通常会使用流程控制语句如if…else或goto、switch等 来对参数中的操作符做判断再执行相应运算;
这样写虽然简洁快速,但是对于大型的项目来说,如果需要给该计算器添加新的运算方式如求n次幂时,我们需要去源码的流程控制语句中添加一个判断和执行,这样就违背了开闭原则,不利于项目后期的维护与拓展;
如果使用多态,那么可以设计一个基类,该基类中包含两个操作数做成员变量,以及一个虚函数;
这样在需要后续扩展每种运算功能时,只需一个继承自该基类的子类,并重写基类中的虚函数为具体的计算函数即可(不需要修改源码,而是添加子类),即一个子类对应于一种运算。在需要进行运算时只需要将基类的指针或引用指向子类的对象,并调用该指针或引用的相应函数即可实现多态。
2.4 纯虚函数 && 抽象类
在多态中,通常父类中的虚函数的实现是没有意义的,主要都是调用子类重写父类的虚函数,因此,可以将虚函数改为纯虚函数。
纯虚函数语法:
virtual 返回值类型 函数名 (参数列表) = 0;
-
当类中有了纯虚函数,这个类也成为抽象类。
-
抽象类特点:
- 无法实例化对象
- 子类必须重写抽象类中的纯虚函数,否则也属于抽象类
2.5 虚析构 && 纯虚析构
使用多态时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码。
解决方式:将父类中的膝盖函数改为虚析构或纯虚析构。
C++中构造函数的调用顺序由父类到子类依次构造,析构函数相反。
-
虚析构和纯虚析构共性:
- 可以解决父类指针释放子类对象
- 都需要有具体的函数实现
-
虚析构和纯虚析构区别:
- 如果是纯虚析构,该类属于抽象类,无法实例化对象
-
虚析构语法:
virtual ~类名(){}
-
纯虚析构语法:
/* 类内声明 */ virtual ~类名() = 0; /* 类外实现 */ 类名::类名(){}
【注意】纯虚析构和纯虚函数不同,纯虚函数不需要实现,但纯虚析构仍需要实现。