virtual在C++中有两个重要的用途:一是解决由多继承中父类有相同基类引起的子类中成员的二义性问题,二是实现多态。


一、解决二义性

1、引起二义性的原因

    二义性是在多继承中出现的,如果派生类的父类继承了同一个基类,那么派生类对象访问继承自基类中成员时便会出现二义性。如下代码:

#include <iostream>
#include <cstdlib>

class Base
{
public:
	int _b;
};

class Base1: public Base
{
public:
	int _b1;
};

class Base2: public Base
{
public:
	int _b1;
};

class Deriver : public Base1, public Base2
{
public:
	int _d;
};

int main()
{
	Deriver d;
	//d._b=

	system("pause");
	return 0;
}

wKioL1cjAcTx0-eVAACoTjlgcuI384.png

   派生类Derive的父类Base1和Base2都继承了同一个基类Base,在派生类Derive中,Base的成员_b被继承了两次,在访问成员_b时会出现二义性,因为无法确定访问继承自Base1中的_b还是访问继承自Base2中的_b。


2、引入虚拟继承,解决二义性问题

    让Base1和Base2虚拟继承Base,其他代码不变,就可以解决Deriver中_b的二义性问题。代码如下:

class Base1: virtual public Base
{
public:
	int _b1;
};

class Base2: virtual public Base
{
public:
	int _b1;
};

    Base1和Base2虚拟继承Base后的模型如下:

wKioL1cUPpfRPSA_AABCuVN2CXk434.png

    由上图可以看出,当Base1虚拟继承Base后,Base1前四字节存放了一个地址,通过这个地址可以找到Base中成员相对于存放这个地址空间的偏移,进而找到Base中的成员。

    当Deriver继承了Base1和Base2后,Deriver的模型如下:

wKioL1cUbxPyEydKAABbH8TKgk8130.png

    当Base1和Base2虚拟继承了Base后,Deriver继承Base1和Base2就不会出现二义性的问题。因为Base的成员_d在Deriver中只保留了一份,而Base1和Base2中本来属于_b的空间被放了两个地址,通过这个地址可以找到_b相对于当前位置的偏移,进而找到_b。


    ▲当一个基类由多个派生类时,这些派生类在继承基类时最好使用虚拟继承,以防某个类继承多个这些派生类时,产生二义性。


3、深度探索virtual继承模型

    在学习这部分知识时,一起学学习的同学问了我一个问题,在菱形继承中(也就是上举的的例子),在哪要虚拟继承?在Deriver继承Base1和Base2时要不要虚拟继承?只在Deriver继承Base1和Base2时虚拟继承行不行? 当时学艺不精,把我也难住了,我只知道Base1和Base2虚拟继承Base就可以解决二义性问题,个中细节并不是很清楚,于是就在VS2013vc 6.0环境下研究了一下虚拟继承的模型。

    我们知道在直接继承中,先继承的放在前面,后继承的放在后面,最后才是派生类中新增的成员。在本文开始的部分,存在二义性的那个例子就是这样的。

    那么在虚拟继承中有什么规律呢?下面的规律是我测试了许多例子后得出的,如有错误还望指正。


规律一:如果一个派生类虚拟继承一个基类,那么派生类的模型如下:

wKiom1cjBt-CdvipAABaNYC2zIw759.png

    也就是说,只要有虚拟继承,派生类模型最开始的部分必然是一个地址,这个地址间接指向虚拟继承自基类的部分。然后是派生类定义的成员,最后是虚拟继承自基类的部分。


规律二:如果一个派生类虚拟继承了两个基类,那么派生类的模型如下:

wKiom1cjBi7wtl_jAAB8FdtDAe8806.png


    如上图,无论派生类虚拟继承多少个基类,在开始的部分只有一个地址,指向所有虚拟继承自基类的开始,虚拟继承自基类的部分放在一起,先继承的放在前面,后继承的放在后面。


规律三:在多继承中,只要有虚拟继承不论有无直接继承,派生类模型开始的四个字节必然是一个地址,这个地址间接指向虚拟继承自基类的开始部分。


规律四:如果在多继承中既有直接继承又有虚拟继承,无论先是直接继承还是先是虚拟继承,在满足规律三的前提下,存放的顺序依次是直接继承的部分、派生类固有成员、虚拟继承基类的部分。如下图所示:

wKioL1cjCNLxqjstAAAtVRXzK_o436.png

    在上面的继承中直接继承部分,先继承的放在前面,后继承的放在后面。虚拟继承部分同理。


规律五:虚拟继承部分永远放在直接继承部分和派生类定义的成员部分后面,其中虚拟继承部分中,直接继承类中的虚拟继承部分放在虚拟继承部分的最前面。如下图所示:

wKioL1cjHRGxwS5JAAEh1e5WwkU594.png

二、virtual实现多态

1、什么是多态

    多态即多种状态,我的理解是,具有不同功能的函数可以用同一个函数名,这样就可以用同一个函数名调用不同功能的函数,比如函数重载。

    多态分为静态多态如函数重载,和动态多态。动态多态是通过虚函数来实现的。


2、virtual函数实现多态

    如果在基类定义一个虚函数,这个虚函数允许在派生类中重写[virtual 函数名相同,参数类型相同,返回值类型相同(协变除外)注:函数重载、重写、覆盖的区别见附录]与基类同名的函数,并且可以通过基类的指针或引用访问基类和派生类中的同名函数。这就是多态的实现。


    代码如下:

#include <iostream>
#include <cstdlib>

using namespace std;

class Base
{
public:
virtual void Display()
{
cout << "Base::Display()" << endl;
}
};

class Deriver :public Base
{
public:
virtual void Display()
{
cout << "Deriver::Display()" << endl;
}

};

void FunTest()
{
Base b;
Base* pb = &b;
pb->Display();

        Deriver d;
pb = &d;
pb->Display();
}

int main()
{

FunTest();

system("pause");
return 0;
}

    上面的代码输出结果如下:

wKiom1cUtGKQp6ePAABplY8wITo024.png


    多态能够实现除了虚函数外,还有一个重要的原因是基类对象的指针可以指向或者引用派生类的对象。

    派生类继承了基类,用基类指针指向或者引用派生类时,就会找到派生类继承基类的部分。反则,派生类的指针不能指向或者引用基类的对象,这是因为如果这样可以指向的话,就会发生越界访问的情况。


3、深度探索多态的实现

    上面说只要在基类中定义虚函数,在派生类重写虚函数,然后就可以用基类的指针或者引用调用基类或者派生类的虚函数,那么为什么这样能够实现呢?

    在基类定义虚函数后,基类就会产生一个地址_vfptr,这个地址指向的地方存放着所有虚函数的入口地址,以00 00 00 00即NULL结束。 派生类直接继承这个基类时,会把这个地址也继承过去。如果派生类重写继承来的虚函数,那么这个虚函数在虚表中的入口地址就会更新为重写的虚函数的地址,那么用基类的引用或者指针指向派生类时,调用这个虚函数时就会调用派生类重写的虚函数。

#include <iostream>
#include <cstdlib>

using namespace std;

class Base
{
public:
	void f()
	{
		cout << "Base:: f()" << endl;
	}

	virtual void g()
	{
		cout << "Base:: g()" << endl;
	}

	virtual void h()
	{
		cout << "Base:: h()" << endl;
	}

	int _b;
};

class Deriver :public Base
{
public:
	virtual void g()
	{
		cout << "Deriver:: g()" << endl;
	}

	int _d;
};


int main()
{
	Base b;
	Deriver d;
	Base* pb = &b;
	pb->f();
	pb->g();
	pb->h();

	pb = &d;
	pb->f();
	pb->g();
	pb->h();

	system("pause");
	return 0;
}

wKioL1ctPEnA1GUDAAA7ZGO86d0620.png    

    上面的代码中,在基类定义了一个普通函数 f() 和两个虚函数 g() 、h() ,在派生类只对g() 进行了重写。然后用基类的指针指向基类和派生类来调用这些函数,结果如上图所示。

    Base和Derive的模型如下:

wKioL1ctQoqh0Sr0AABh1BbiePE707.png

    派生类在继承基类时也把基类的虚表也继承了,当用基类的指针指向派生类后,调用虚函数时,会找到虚表进而找到虚函数,所以在派生类对g() 重写后,调用的就是重写的函数,因为重写更新了虚表。至于f()不是虚函数,但是派生类继承了它当然可以调用它,只不过调用的是基类的函数。


4、深度探索虚表的模型

    在VS2013 和vc6.0中 :  

 4.1 虚表地址(_vfptr)的位置

    (1)如果一个基类定义了虚函数,那么_vfptr 的位置在这个基类的最前面4个字节。如果基类虚拟继承了其他的基类,那么_vfptr的位置在指向偏移量的那个地址之后,即第二个四字节的位置

    (2)在直接继承中,有多少个含有虚函数的基类就有多少个_vfptr, _vfptr的位置满足(1)中所述

    (3)在只有虚拟继承中,有多少个含有虚函数的基类就有多少个_vfptr,如果派生类定义新的虚函数,那么就会再产生一个派生类的_vfptr。_vfptr的位置在  指向虚基类的偏移量的地址 之后。

    (4)在既有直接继承又有虚拟继承,如果直接继承至少有一个有虚函数,那么,有多少个含有虚函数的基类就有多少个_vfptr, _vfptr的位置满足(1)中所述。,如果直接继承的基类没有虚函数,那么满足(3)中所述。


4.2 虚表模型

    派生类在继承基类的同时,也继承了基类的虚表。

    注意:继承的时候并不是把基类的虚表的地址直接拿来,而是拷贝了一份虚表,虚表的地址不同,但是内容一样的。

    通过_vfptr就可以找到虚表,虚表在填写的时候遵循一定的规律,如下:

    (1)在基类中定义虚函数,先定义的虚函数在前,后定义的虚函数在后,以NULL结束

    (2)在派生类中如果重写了虚函数,那么就会更新对应的函数的入口地址。

    (3)派生类新定义的虚函数数会存到第一个直接继承的含有虚函数的基类的虚表中,存放规则如(1)所述,如果没有直接继承的基类,派生类会重新生成一个虚表,填写规则如(1)所述。

    注:如果继承的多个基类含有相同的虚函数,必须在派生类重写,否则在用派生类 类型的指针调用的函数时会出现二义性问题。同样在多个基类定义普通的函数也会出现二义性。解决方法很简单,就是换个函数名……


附录:函数重载、重写、隐藏的区别

wKiom1ctuvKgSDlKAAK7vuXYP-M015.png