2.5 虚函数的实现及基本原理
1. 概述
简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表,其中存放着该类所有的虚函数对应的函数指针。例:
其中:
- B的虚函数表中存放着B::foo和B::bar两个函数指针。
- D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写(override)了基类虚函数B::bar的D::bar,还有新增的虚函数D::quz。
提示:为了描述方便,本文在探讨对象内存布局时,将忽略内存对齐对布局的影响。
2. 虚函数表构造过程
从编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式(仅供参考):
提示:该过程是由编译器完成的,因此也可以说:虚函数替换过程发生在编译时。
3. 虚函数调用过程
以下面的程序为例:
编译器只知道pb是B*类型的指针,并不知道它指向的具体对象类型 :pb可能指向的是B的对象,也可能指向的是D的对象。
但对于“pb->bar()”,编译时能够确定的是:此处operator->的另一个参数是B::bar(因为pb是B*类型的,编译器认为bar是B::bar),而B::bar和D::bar在各自虚函数表中的偏移位置是相等的。
无论pb指向哪种类型的对象,只要能够确定被调函数在虚函数中的偏移值,待运行时,能够确定具体类型,并能找到相应vptr了,就能找出真正应该调用的函数。
提示:虚函数指针中的ptr部分为虚函数表中的偏移值(以字节为单位)加1。
B::bar是一个虚函数指针, 它的ptr部分内容为9,它在B的虚函数表中的偏移值为8(8+1=9)。
当程序执行到“pb->bar()”时,已经能够判断pb指向的具体类型了:
- 如果pb指向B的对象,可以获取到B对象的vptr,加上偏移值8((char*)vptr + 8),可以找到B::bar。
- 如果pb指向D的对象,可以获取到D对象的vptr,加上偏移值8((char*)vptr + 8) ,可以找到D::bar。
- 如果pb指向其它类型对象…同理…
4. 多重继承
当一个类继承多个类,且多个基类都有虚函数时,子类对象中将包含多个虚函数表的指针(即多个vptr),例:
其中:D自身的虚函数与B基类共用了同一个虚函数表,因此也称B为D的主基类(primary base class)。
虚函数替换过程与前面描述类似,只是多了一个虚函数表,多了一次拷贝和替换的过程。
虚函数的调用过程,与前面描述基本类似,区别在于基类指针指向的位置可能不是派生类对象的起始位置,以如下面的程序为例:
5. 菱形继承(钻石继承)
菱形继承
假设有类B和类C,它们都继承了相同的类A。另外还有类D,类D通过多重继承机制继承了类B和类C。
如果直接继承会引发访问不明确(二义性),以及数据冗余。如果直接指定访问对象,可解决二义性,而要解决数据冗余,则要引入虚函数。
因为图表的形状类似于菱形(或者钻石),因此这个问题被形象地称为菱形问题(钻石继承问题)。
示例代码:
#include <Windows.h>
#include <iostream>
using namespace std;
class Life
{
public:
Life() :LifeMeaning(5)
{ }
public:
int LifeMeaning;
};
class Bird :public Life
{
public:
Bird() :BirdMeaning(0x50)
{ }
public:
int BirdMeaning;
};
class Human :public Life
{
public:
Human() :HumanMeaning(0x100)
{ }
public:
int HumanMeaning;
};
class Angel :public Bird, public Human
{
public:
Angel() :AngelMeaning(0x30)
{ }
public:
int AngelMeaning;
};
int main()
{
Angel Angel;
return 0;
}
内存窗口观察Angel对象的基地址,可以看到有两个05(Life中的成员变量LifeMeaning的值05),这是因为子类对象会包父类的成员变量。对于Bird和Human来说,都会去包含Life类中LifeMeaning的值05。对于天使Angel来说,会同时包含Bird和Human的所有成员。故而LifeMeaning的这个变量在子类Angel中出现了两次,这是菱形继承问题。
对于二义性,可以通过作用域符指定访问对象来消除(Angel.Bird::LifeMeaning),而数据冗余的问题,则要通过虚继承。
虚继承
实例代码:
#include <Windows.h>
#include <iostream>
using namespace std;
class Life
{
public:
Life() :LifeMeaning(0x5)
{ }
public:
int LifeMeaning;
};
class LifePropagate1 :virtual public Life
{
public:
LifePropagate1() :LifePropagate1Meaning(0x50)
{ }
public:
int LifePropagate1Meaning;
};
class LifePropagate2 :virtual public Life
{
public:
LifePropagate2() :m_B(0x60)
{ }
public:
int m_B;
};
class NewLife :public LifePropagate1, public LifePropagate2
{
public:
NewLife() :NewLifeMeaning(0x100)
{ }
public:
int NewLifeMeaning;
};
int main()
{
NewLife NewLifeObject;
return 0;
}
内存窗口观察NewLifeObject对象的基地址:
LifePropagate1与LifePropagate2 共用一个虚基类。最终虚基类的数据只有一份,数据冗余和二义性问题不再。
那么,虚继承又是怎么解决这些烦人的问题的呢?
可以看到在B和C中不再保存Life中的内容,保存了一份偏移地址,然后将最原始父类的数据保存在一个公共位置处这样保证了数据冗余性的降低同时,也消除了二义性
6.析构函数什么情况下要定义为虚函数?
如果用父类的指针去指向子类的对象,就会通过子类对象中的虚函数表指针找到子类的虚函数表,通过偏移就能在子类的虚函数表中找到子类的虚函数的函数入口地址,从而执行子类中的虚函数。
父类中用virtual来修饰父类的析构函数,即使子类不写虚析构函数,计算机会默认给你定义一个虚析构函数。如果在父类中定义了虚析构函数,那么在父类的虚函数表中就会有一个父类析构函数的函数指针,指向父类的析构函数,而在子类的虚函数表中也会产生一个子类析构函数的函数指针,指向子类的析构函数(注意:虚析构函数没有覆盖)。当父类的指针指向子类的对象,通过delete接父类的指针时,就可以通过子类对象的虚函数表指针找到子类的虚函数表,通过子类的虚函数表找到子类的析构函数从而使得子类的析构函数得以执行,子类的析构函数执行完毕后,系统会自动执行父类的析构函数。
1.基类的析构函数不是虚函数,在main函数中用继承类的指针去操作继承类的成员,释放指针P的过程是:先释放继承类的资源,再释放基类资源
#include<iostream>
using namespace std;
class ClxBase{
public:
ClxBase() {};
~ClxBase() {cout << "Output from the destructor of class ClxBase!" << endl;};
void DoSomething() { cout << "Do something in class ClxBase!" << endl; };
};
class ClxDerived : public ClxBase{
public:
ClxDerived() {};
~ClxDerived() { cout << "Output from the destructor of class ClxDerived!" << endl; };
void DoSomething() { cout << "Do something in class ClxDerived!" << endl; };
};
int main()
{
ClxDerived *p = new ClxDerived;
p->DoSomething();
delete p;
system("pause");
return 0;
}
运行结果:
2.基类的析构函数同样不是虚函数,不同的是在main函数中用基类的指针去操作继承类的成员,释放指针P的过程是:只是释放了基类的资源,而没有调用继承类的析构函数.调用dosomething()函数执行的也是基类定义的函数。
一般情况下,这样的删除只能够删除基类对象,而不能删除子类对象,形成了删除一半形象,造成内存泄漏。在公有继承中,基类对派生类及其对象的操作,只能影响到那些从基类继承下来的成员.如果想要用基类对非继承成员进行操作,则要把基类的这个函数定义为虚函数。析构函数自然也应该如此:如果它想析构子类中的重新定义或新的成员及对象,当然也应该声明为虚的.。
#include<iostream>
using namespace std;
class ClxBase{
public:
ClxBase() {};
~ClxBase() {cout << "Output from the destructor of class ClxBase!" << endl;};
void DoSomething() { cout << "Do something in class ClxBase!" << endl; };
};
class ClxDerived : public ClxBase{
public:
ClxDerived() {};
~ClxDerived() { cout << "Output from the destructor of class ClxDerived!" << endl; };
void DoSomething() { cout << "Do something in class ClxDerived!" << endl; }
};
int main()
{
ClxBase *p = new ClxDerived;
p->DoSomething();
delete p;
system("pause");
return 0;
}
运行结果:
3.基类的析构函数被定义为虚函数,用基类的指针去操作继承类的成员,释放基类指针的过程是:先调用继承类的析构函数释放继承类的资源,再调用基类的析构函数释放基类资源。调用dosomething()函数执行的也是继承类定义的函数.。
如果不需要基类对派生类及对象进行操作,则不能定义虚函数,因为这样会增加内存开销。当类里面有定义虚函数的时候,编译器会给类添加一个虚函数表,里面来存放虚函数指针,这样就会增加类的存储空间。所以只有当一个类被用来作为基类的时候才把析构函数写成虚函数。
#include<iostream>
using namespace std;
class ClxBase{
public:
ClxBase() {};
virtual ~ClxBase() {cout << "Output from the destructor of class ClxBase!" << endl;};
virtual void DoSomething() { cout << "Do something in class ClxBase!" << endl; };
};
class ClxDerived : public ClxBase{
public:
ClxDerived() {};
~ClxDerived() { cout << "Output from the destructor of class ClxDerived!" << endl; };
void DoSomething() { cout << "Do something in class ClxDerived!" << endl; };
};
int main()
{
ClxBase *p = new ClxDerived;
p->DoSomething();
delete p;
system("pause");
return 0;
}
参考链接: