在上面几节讲到虚拟函数和多态的时候,我们只是提到虚拟函数就是在函数前面加上virtual,然后就能实现指针对子类函数的调用。但是,本着我们开始时的原则,知其然还要知其所以然,所以,我觉得很有必要探讨一下C++编译器对于虚拟函数的实现方式,这样我们就能够知道为什么虚拟函数可以做到动态绑定。
为了达到动态绑定的目的,C++编译器通过某个表格,在执行期间“间接”调用实际上欲绑定的函数。这样的表格称为“虚拟函数表”(常被称为vtable)。每一个“内含虚拟函数的类”,编译器都会为它做出一个虚拟函数表,表中的每一笔元素都指向一个虚拟函数的地址。此外,编译器也会为类加上一项成员变量,是一个指向该虚拟函数表的指针(常被称为vptr)。用下面的例子说明更好理解:
class Class1{
public:
data1;
data2;
memfunc();
virtual vfunc1();
virtual vfunc2();
virtual vfunc3();
};
上面那一段文字的意思如下图所示:
Class1对象实例在内存中占据这样的空间:
每一个由此类派生出来的对象,都有这么一个vptr。当我们通过这个对象调用虚拟函数时,事实上是通过vptr找到虚拟函数表,在找到虚拟函数的真正地址。
虚拟函数调用的奥妙就在于这个虚拟函数表以及这种间接调用方式。虚拟函数表的内容是根据类中的虚拟函数声明次序,一一填入函数指针的。派生类会继承基类的虚拟函数表(以及所有其他可以继承的成员),当我们在派生类中改写虚拟函数时,虚拟函数表也会受到影响:表中元素所指的函数地址将不再是基类的函数地址,而是派生类的函数地址。
接着上面的例子:
class Class2 : public Class1{
public:
data3;
memfunc();
virtual vfunc2();
};
如上面图中所示,Class2继承了Class1的虚拟函数vfunc1,vfunc2和vfunc3,其中只有vfunc2在子函数中改写了,所以vtable中指针指向中,只有vfunc2指针指向了子类Class2的地址,其他两个没有在子类中改变的虚拟函数仍然指向基类Class1。
动态绑定机制,在执行期,根据虚拟函数表,做出了正确的选择。至此,我们就解开了虚拟函数实现方式的谜团。
附加:
虚拟函数还有一个极重要的行为模式。假设有三个类,层次关系如下:
程序表示如下:
#include <iostream.h>
class CObject //基类
{
public:
virtual void Serialize() { cout << "CObject::Serialize() \n\n"; }
};
class CDocument : public CObject
{
public:
int m_data1;
void func() { cout << "CDocument : : func()" << endl;
Serialize();
}
virtual void Serialize() {cout << "CDocument : :Serialize() \n \n"; }
};
class CMyDoc : public CDocument
{
public:
int m_data2;
virtual void Serialize() { cout << "CMyDoc : : Serialize() \n \n" ; }
};
//-----------------------------------------------------------------------------------------
void main()
{
CMyDoc mydoc;
CMyDoc* pmydoc = new CMyDoc;
cout << "#1 testing" <<endl;
mydoc.func();
cout << "#2 testing" << endl;
((CDocument*)(&mydoc)) ->func();
cout<<"#3 testing"<< endl;
pmydoc ->func();
cout<<"#4 testing" <<endl;
((CDocument)mydoc).func();
}
执行结果如下:
由于CMyDoc自己没有func函数,而它继承了CDocument的所有成员,所以main之中的四个调用操作毫无疑问都是调用CDocument::func。但是,CDocument::func中所调用的Serialize是那一类的成员函数呢?如果它是一般的(non-virtual)函数,毫无疑问是CDocument::Serialize。但是因为这是个虚拟函数,情况就有不同了。
前三个测试都符合我们对虚拟函数的期望:既然派生类已经改写了虚拟函数Serialize,那么理所当然调用派生类之Serialize函数。第四项测试结果就有些不同了。派生类对象通常都比基类对象内存空间大,因为派生对象不但继承其基类的成员,又有自己的成员。那么向上强制转型将会造成对象的内容被切割。
当我们调用((CDocument)mydoc).func(); mydoc已经是一个被切割的剩下半条命的对象,而func内部调用虚拟函数Serialize;后者将使用的“mydoc的虚拟函数指针”虽然存在,它的值是什么呢?
由于((CDocument)mydoc).func(); 是传值而非传址操作,编译器以所谓的拷贝函数把CDocument对象的内容复制了一份,使得mydoc的vtable内容与CDocument对象的vtable相同。本例虽没有明显做出一个拷贝构造函数,但编译器会自动为你合成一个。
总结来说,经过所谓的data slicing,本例的mydoc真正变成了一个完完全全的CDocument对象,所以本例的第四项测试结果也就水落石出了。