C++中的各种“虚“-- 虚函数纯虚函数虚继承虚基类虚析构纯虚析构抽象类讲解

Posted 狱典司

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++中的各种“虚“-- 虚函数纯虚函数虚继承虚基类虚析构纯虚析构抽象类讲解相关的知识,希望对你有一定的参考价值。

C++中的各种"虚"

C++中的一些重要概念都与“”相关,比如:

① 虚基类、虚继承、虚基类指针(vbptr)、虚基类表(vbtable);

② 虚函数、纯虚函数、虚函数指针(vfptr)、虚函数表(vftable)、抽象类、虚析构、纯虚析构。

这里对上面罗列出的概念做一个总结,争取把这些都一次讲清楚:

1. 菱形继承

由于C++支持多继承,即一个类可以继承自多个类,故有时候会存在菱形继承(又叫钻石继承)的情景,即两个子类继承同一个父类而又有子类同时继承这两个子类

菱形继承示意伪代码:

class CA
    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_B << endl;	// 成功执行,输出10
    cout << obj.CC::m_D << 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_B << endl;	// 成功执行,输出20
cout << obj.CC::m_D << 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;
    
    /* 类外实现 */
    类名::类名()
    

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

以上是关于C++中的各种“虚“-- 虚函数纯虚函数虚继承虚基类虚析构纯虚析构抽象类讲解的主要内容,如果未能解决你的问题,请参考以下文章

纯虚函数的 C++ 继承

19.理解虚基类虚继承

C++ 继承和多态

C++ 继承和多态

C++中的继承和纯虚函数

C++笔记--面向对象(OOP)编程基础--虚函数纯虚函数多继承