C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解

文章目录

    • 纯虚函数
    • 抽象类
    • 多重继承
      • 二义性问题
      • 菱形继承
    • 虚基类
      • 从内存布局看虚继承的底层实现原理


纯虚函数

纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。

一般格式如下:

class <类名>
{
	virtual <类型><函数名>(<参数表>)=0;};

例如:

class A
{
	virtual int funcation(int val) = 0; // 定义纯虚函数
}

纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义

引入原因:

  • 为了方便使用多态特性,我们常常需要在基类中定义虚函数。
  • 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

为了解决上述问题,引入了纯虚函数的概念,即 将函数定义为纯虚函数

若要使派生类为非抽象类,则编译器要求在派生类中,必须对纯虚函数予以重写以实现多态性。同时含有纯虚函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。


抽象类

凡是含有纯虚函数的类叫做抽象类。这种类不能定义对象,只是作为基类为派生类服务。但可以定义指针或引用

除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。在派生类实现该纯虚函数后,定义抽象类对象的指针,并指向或引用子类对象。

一般而言纯虚函数的函数体是缺省的,但是也可以给出纯虚函数的函数体(此时纯虚函数变为虚函数),这一点经常被人们忽视。

  • 在定义纯虚函数时,不能定义虚函数的实现部分
  • 在没有重新定义这种纯虚函数之前,是不能调用这种函数的

抽象类的唯一用途是为派生类提供基类,纯虚函数的作用是作为派生类中的成员函数的基础,并实现动态多态性

继承于抽象类的派生类如果不能实现基类中所有的纯虚函数,那么这个派生类也就成了抽象类。因为它继承了基类的抽象函数,只要含有纯虚函数的类就是抽象类。

纯虚函数已经在抽象类中定义了这个方法的声明,其它类中只能按照这个接口去实现。

示例程序如下:

#include 
using namespace std;
 
/*
** 抽象基类:不能被实例化的基类。 它仅仅只有一个用途,用来派生出其他类。
** 1. 要定义抽象基类,可使用纯虚函数,纯虚函数可当做接口使用
** 2. 基类的纯虚函数,在派生类中必须实现。 虚函数可以不用必须实现
*/
 
/*定义抽象基类*/
class Base 
{
public:

	/*
	** 虚函数=0,这个形式为纯虚函数,告诉编译器,必须在派生类中进行实现
	** 可看成派生类的接口,调用此接口时,调用相应派生类的方法
	*/
	virtual void Fun() = 0;  
};
 
/*
** 实例化对象时,将创建两个对象,子对象和基类对象,
** 通过从调用的构造函数可以看出
*/
class Derive: public Base
{
public:

	/* 若不实现此函数,编译将会出错 */
	void Fun() { cout << "Derive::Fun" << endl; } 
	Derive() { cout << "Derive()" << endl; }
	~Derive() { cout << "~Derive()" << endl; }
};
 
class Derive2: public Base
{
public:

	/* 若不实现此函数,编译将会出错 */
	void Fun() { cout << "Derive2::Fun" << endl; } 
	Derive2() { cout << "Derive2()" << endl; }
	~Derive2() { cout << "~Derive2()" << endl; }
};
 
void Show(Base& Base) {
	Base.Fun();
}
 
int main()
{
	//Base base;  // error,抽象基类不可实例化对象
	Derive derive; // 实例化对象
	Derive2 derive2; // 实例化对象
	
	Base* base = &derive; 
	Base* base2 = &derive2;
	base->Fun();
	base2->Fun();
	
	return 0;
}

我们可以看到,但我们给抽象基类实例化对象时,编译会报错如下:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第1张图片

那么由下图我们可以看出,当我们使用基类指针指向不同的派生类对象时,调用Fun函数将调用基类指针所指对象的Fun函数:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第2张图片


多重继承

之前所介绍的单一继承是指:一个派生类只继承一个基类。

多重继承指的是一个类可以同时继承多个不同基类的行为和特征功能

语法格式如下:

Class <类名><继承方式> <基类1><继承方式> <基类2>···
{···}

示例程序如下:

class Base1
{
public:
	Base1() { cout << "Base1()" << endl; }
	~Base1() { cout << "~Base1()" << endl; }
};
class Base2
{
public:
	Base2() { cout << "Base2()" << endl; }
	~Base2() { cout << "~Base2()" << endl; }
};

/*
**  : 之后称为类派生表,表的顺序决定基类构造函数
** 调用的顺序,析构函数的调用顺序正好相反
*/
class Derive : public Base2, public Base1
{};
int main()
{
	Derive derive;
	return 0;
}

上述程序算是一段简单的多重继承了,编译运行是没有错误的。平时绝大部分时候,我们都只使用单继承,所为单继承是针对多重继承而言的,即一个类只有一个基类
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第3张图片

我们看到运行结果是先是Base2构造,然后Base1构造:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第4张图片

那么多重继承会带来各种各样的问题:

  • 二义性问题
  • 菱形继承导致派生类持有间接基类的多份拷贝

二义性问题

使用多重继承, 一个不小心就可能因为二义性问题而导致编译错误。

最简单的例子,在上面的基类Base1和Base2中若存在相同的方法或成员变量,那么在派生类Derive中或使用Derive的对象时,若使用这个方法或成员变量时,那么编译器不知道需要调用Base1中的方法还是Base2中的方法或成员变量。

当然我们可以给方法添加作用域来解决这个问题,我们也可以通过在派生类Derive中重新定义这个方法来覆盖基类中的同名方法,从而使编译器能够正常工作。

示例如下:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第5张图片

编译后,我们发现错误,即show函数的调用存在二义性问题:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第6张图片

修改如下,我们为show函数加上作用域,即可正常运行:(另外一种解决方法是我们在Derive派生类中重写Show方法,将隐藏基类方法,程序也可正常运行)
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第7张图片


菱形继承

我们来看一个示例图:

  • Derive1继承了Base类,它的成员有ma、mb、mc
  • Derive2继承了Base类,它的成员有ma、mb、md
  • Derive3继承了Derive1类和Derive2类,它的成员有ma、mb、mc、ma、mb、md
    C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第8张图片

我们发现了问题:Derive3由于多重继承,拿到了它的间接基类Base的两份数据拷贝,这并不是我们所期望的。

这将会引起问题,例如,通常可以将派生类对象的地址赋给基类指针,但现在将出现二义性:

Derive3 derive3;
Base *base = &derive3; // error

如下图所示:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第9张图片

通常,这种赋值将把基类指针设置为派生类对象中的基类指针的地址。但是现在Derive3中包含两个地址可选择,所以应该使用类型转换来指定对象

这将使得使用基类指针来引用不同的对象(多态性)复杂化。

为了解决上述问题,C++引入了一种新技术——虚基类(virtual base class)。


虚基类

虚继承和虚函数是完全无相关的两个概念。

虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在派生类中存在多份拷贝。这将存在两个问题:

  • 浪费存储空间
  • 存在二义性问题。

通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性

虚基类使得从多个类(它们的基类相同)派生出的对象只继承一个基类对象。例如,通过在类声明中使用关键字 virtual ,可以使这些派生类只保留虚基类的一个副本。

例如上例,如下声明后,Derive1 和 Derive2 虚继承了 Base后, Base 成为了Derive1 和 Derive2 的虚基类,那 Derive3 就可以安全的多继承 Derive1 和 Derive2了。如下图:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第10张图片

从内存布局看虚继承的底层实现原理

了解了虚基类的用法和作用后,我们来看一下虚基类的底层到底是怎样实现的
之前我们介绍虚函数时,介绍了其实现原理即虚函数指针 vfptr 和虚函数表vftable。

那么虚基类的实现是产生虚基类表指针 vbptr 与虚基类表 vbtable。

虚继承底层实现原理与编译器相关,一般通过虚基类指针和虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)。

需要强调的是,虚基类依旧会在派生类里面存在拷贝,只是仅仅只存在一份而已,并不是不在派生类里面了;当虚继承的派生类被当做基类继承时,虚基类指针也会被继承。

实际上,vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚基类表中记录了虚基类与本类的偏移地址;

通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

在这里我们可以对比虚函数的实现原理:他们有相似之处,都利用了虚指针(均占用对象的存储空间)和虚表(均不占用对象的存储空间)。

  • 虚基类依旧存在继承类中,只占用存储空间;虚函数不占用存储空间。
  • 虚基类表存储的是虚基类相对直接继承类的偏移;而虚函数表存储的是虚函数地址。

接下来我们从内存布局看一下具体过程:
首先看普通继承的内存布局:

/* 普通继承(没有使用虚基类)*/
 
// 基类A
class Base
{
public:
	int ma;
};
 
class Derive1 : public Base
{
public:
	int mb;
};
 
class Derive2 : public Base
{
public:
	int mc;
};
 
class Derive3 : public Derive1, public Derive2
{
public:
	int md;
};

打开VS开发者命令行工具
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第11张图片

进入工程目录下,输入下述命令,注意/EHsc不一定要写,根据项目是否开启C++异常检查而定,最后加上类名即可

我们得到Derive3的内存布局如下,我们发现 Derive3 拿到了其间接基类的两份拷贝:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第12张图片

其基类Derive1和Derive2的内存布局如下:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第13张图片
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第14张图片

那么接下来我们定义Derive1和Derive2为虚继承,使得Base成为它们的虚基类。
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第15张图片

查看Derive1内存布局如下:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第16张图片

我们看到了在Derive1内存的前4个字节中存储了vbptr虚基类指针,而虚基类的数据由之前的首部移至了尾部。

并且我们看到在vbtable虚基类表中,offset偏移量字段显示为8,表示虚基类数据相对Derive1类首部的偏移量(向下偏移量)

同样的,我们查看Derive2内存布局如下:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第17张图片
其发生的变化和Derive1的是相同的。

最后,我们查看一下 Derive3 的内存布局:
C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解_第18张图片
同样的,我们发现,虚基类Base的数据移至了Derive3内存布局的末尾,并且只存在一份数据,并不是之前的两份,因此,我们验证了虚基类解决了多重继承中的菱形继承问题。

实际上,vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚表(virtual table),虚表中记录了vbptr与本类的偏移地址;第二项是vbptr到共有基类元素之间的偏移量。

在这个例子中,虚表表明Derive3的间接基类Base的成员变量ma距离类Derive3开始处的位移为20,这样就找到了成员变量ma,而虚继承也不用像普通多继承那样维持着公共基类的两份同样的拷贝,节省了存储空间。


查看内存布局:

cl -d1reportSingleClassLayoutDerive 源.cpp
Derive为类名 后边为源.cpp

你可能感兴趣的:(C++继承详解(三):抽象类和纯虚函数、多重继承与虚基类的底层实现原理详解)