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类。

菱形继承会产生一些问题:

  1. 当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;
    
  2. 菱形继承导致同个成员变量的多次继承,造成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++面向对象的三大特性之一。

  • 多态可以分为两类:

    1. 静态多态: 函数重载 和 运算符重载属于静态多态,即复用函数名;
    2. 动态多态: 基于 派生类虚函数 实现运行时多态。
  • 静态多态和动态多态的区别:

    1. 静态多态:函数地址早绑定 —— 编译阶段就已经确定函数地址;
    2. 动态多态:函数地址晚绑定 —— 运行阶段才能确定函数的地址。

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()函数后,输出的结果是 "迪奥纳特调~"。特点就是会根据传入的对象不同,执行相应类的函数,总结如下:

  • 动态多态满足条件

    1. 有继承关系
    2. 子类重写父类中的虚函数
  • 动态多态的使用

    1. 父类的引用指向子类传入对象

      /* 假设Cat类继承自Animal类且重写了Animal类的虚函数 */ 
      // 参数为父类引用
      void doSpeak(Animal &animal){ 
          animal.speak();
      }
      
      int main(){
      	Cat cat;
          // 传入子类对象
          doSpeak(cat);	// 执行Cat类中的speak()函数			
          return 0;
      }
      
    2. 父类的指针指向子类传入对象

      /* 假设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 多态的优点

使用多态有如下优点:

  1. 代码组织结构清晰,可读性强
  2. 利于项目的前期开发和后期的拓展及维护

使用多态符合大型软件工程开发设计原则中的开闭原则,即对修改(源码)关闭,对添加(插件/功能/模块)开放。

举一个例子,比如我们要实现一个二元运算计算器,在没有掌握多态之前,通常会使用流程控制语句如if…else或goto、switch等 来对参数中的操作符做判断再执行相应运算;

这样写虽然简洁快速,但是对于大型的项目来说,如果需要给该计算器添加新的运算方式如求n次幂时,我们需要去源码的流程控制语句中添加一个判断和执行,这样就违背了开闭原则,不利于项目后期的维护与拓展;

如果使用多态,那么可以设计一个基类,该基类中包含两个操作数做成员变量,以及一个虚函数;

这样在需要后续扩展每种运算功能时,只需一个继承自该基类的子类,并重写基类中的虚函数为具体的计算函数即可(不需要修改源码,而是添加子类),即一个子类对应于一种运算。在需要进行运算时只需要将基类的指针或引用指向子类的对象,并调用该指针或引用的相应函数即可实现多态。

2.4 纯虚函数 && 抽象类

在多态中,通常父类中的虚函数的实现是没有意义的,主要都是调用子类重写父类的虚函数,因此,可以将虚函数改为纯虚函数

纯虚函数语法

virtual 返回值类型 函数名 (参数列表) = 0
  • 类中有了纯虚函数,这个类也成为抽象类

  • 抽象类特点:

    1. 无法实例化对象
    2. 子类必须重写抽象类中的纯虚函数,否则也属于抽象类

2.5 虚析构 && 纯虚析构

使用多态时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码

解决方式:将父类中的膝盖函数改为虚析构纯虚析构

C++中构造函数的调用顺序由父类到子类依次构造,析构函数相反。

  • 虚析构和纯虚析构共性:

    1. 可以解决父类指针释放子类对象
    2. 都需要有具体的函数实现
  • 虚析构和纯虚析构区别:

    • 如果是纯虚析构,该类属于抽象类,无法实例化对象
  • 虚析构语法:

    virtual ~类名(){}
    
  • 纯虚析构语法:

    /* 类内声明 */
    virtual ~类名() = 0;
    
    /* 类外实现 */
    类名::类名(){}
    

【注意】纯虚析构和纯虚函数不同,纯虚函数不需要实现,但纯虚析构仍需要实现。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

狱典司

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值