天天看点

ATL炒冷饭学习之二:绕不开的虚函数ATL炒冷饭学习之二:绕不开的虚函数 

ATL炒冷饭学习之二:绕不开的虚函数

一、前言

      对于C++的程序员,多态机制是再熟悉不过的了;多态就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。对于多态机制是如何实现的原理,想必对于一个c++程序员来说是小菜一碟。对于即将学习COM技术的人,将会明白com接口有将这一机制运用到了极致,所以,多态技术也是学习COM技术的前提和基础,不知道多态机制的人,是永运无法学习明白COM的。所以,在开始学习COM时,是非常有必要专门复习一下C++的多态机制是如何实现的。

二、多态

从概念上说,多态就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。通过下面的代码先体会一下多态:

#include <iostream>

using namespace std;

class AClass

{

public:

    void PrintMessage()

    {

        cout<<"this i s AClass"<<endl;

    }

};

class BClass : public AClass

{

public:

    void PrintMessage()

    {

        cout<<"this i s BClass"<<endl;

    }

};

int main()

{

    AClass *pAClassObj = new BClass();

   pAClassObj ->PrintMessage();

}

有兴趣的读者可以编译运行上面的代码,运行结果是:this i s AClass!,明显这不是多态的行为。

ATL炒冷饭学习之二:绕不开的虚函数ATL炒冷饭学习之二:绕不开的虚函数 

假如我们对AClass类的PrintMessage函数前面加入关键字virtual,具体代码如下:

#include <iostream>

using namespace std;

class AClass

{

public:

    virtual void PrintMessage()

    {

        cout<<"this i s AClass"<<endl;

    }

};

class BClass : public AClass

{

public:

    void PrintMessage()

    {

        cout<<"this i s BClass"<<endl;

    }

};

int main()

{

    AClass *pAClassObj = new BClass();

    pAClassObj ->PrintMessage();

}

再次编译执行此代码。此时,代码的运行结果为:this i s BClass!这个时候就表现出来了多态行为。通过这个简单的例子,你应该体会到多态的概念了。

ATL炒冷饭学习之二:绕不开的虚函数ATL炒冷饭学习之二:绕不开的虚函数 

三、虚函数表

       多态机制的关键就是在于虚函数表,也就是vtbl。当定义一个类并且类中包含虚函数时,其实也就定义了一张虚函数表,没有虚函数的类是不包含虚函数表的,只有该类被实例化时,才会将这个表分配到这个实例的内存中;在这张虚函数表中,存放了每个虚函数的地址;它就像一个地图一样,指明了实际所应该调用的函数。如下面定义一个类:

class CVirtualBase

{

public:

     CVirtualBase(){}

    CVirtualBase(int i, int f) : m_intVar(i), m_floatVar(f){}

     virtual void virtualBaseMethod1() { cout<<"this is virtualBaseMethod1!"<<endl; }

     virtual void virtualBaseMethod2() { cout<<"this is virtualBaseMethod2!"<<endl; }

     virtual void virtualBaseMethod3() { cout<<"this is virtualBaseMethod3!"<<endl; }

     void baseMethod4() { cout<<"this is baseMethod4!"<<endl; }

private:

     int m_intVar;

     float m_floatVar;

};

这样的一个类,当你去定义这个类的实例时,编译器会给这个类分配一个成员变量,该变量指向这个虚函数表,这个虚函数表中的每一项都会记录对应的虚函数的地址;如下图:

这个类的变量还没有被初始化时,就像上图那样,变量的值都是随机值,而指向虚拟函数表的指针__vfptr中对应的虚函数地址也是错误的地址;只有等我们真正的完成了这个变量的声明和初始化时,这些值才能被正确的初始化,如下图:

ATL炒冷饭学习之二:绕不开的虚函数ATL炒冷饭学习之二:绕不开的虚函数 

       从上图中就可以看到,初始化完成以后,指向虚函数表的__vfptr指针中的元素都被赋予了正确的虚函数值,分别指向了在类中定义的三个虚函数。也看到了,__vfptr指针定义的位置也比m_intVar和m_floatVar变量的位置靠前;在C++编译器中,它保证虚函数表的指针存在于对象实例中最前面的位置,这主要是为了在多层继承或是多重继承的情况下,能以高性能取到这张虚函数表,然后进行遍历,查找对应的虚函数指针,进行对应的调用。

      基类定义了虚函数,子类可以重写该函数,当子类重新定义了父类的虚函数后,父类指针根据赋给它的不同的子类指针,动态地调用属于子类的该函数,且这样的函数调用是无法在编译器期间确认的,而是在运行期确认,也叫做迟绑定。

下面先看看虚函数实现的模型:

ATL炒冷饭学习之二:绕不开的虚函数ATL炒冷饭学习之二:绕不开的虚函数 

    结合上面中模型的特点,并对内存存取和空间进行了优化。在此模型中,non static 数据成员被放置到对象内部,static数据成员, static and nonstatic 函数成员均被放到对象之外。对于虚函数的支持则分两步完成:

1.每一个class产生一堆指向虚函数的指针,放在表格之中。这个表格称之为虚函数表(virtual table,vtbl)。

2.每一个对象被添加了一个指针,指向相关的虚函数表vtbl。通常这个指针被称为vptr。vptr的设定(setting)和重置(resetting)都由每一个class的构造函数,析构函数和拷贝赋值运算符自动完成。

       另外,虚函数表地址的前面设置了一个指向type_info的指针,RTTI(Run Time Type Identification)运行时类型识别是有编译器在编译器生成的特殊类型信息,包括对象继承关系,对象本身的描述,RTTI是为多态而生成的信息,所以只有具有虚函数的对象在会生成。

     编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,称为虚表指针(vptr),这种数组成为虚函数表(virtual function table, vtbl),即,每个类使用一个虚函数表,每个类对象用一个虚表指针。

        举个例子:基类对象包含一个虚表指针,指向基类中所有虚函数的地址表。派生类对象也将包含一个虚表指针,指向派生类虚函数表。看下面两种情况:

  •         如果派生类重写了基类的虚方法,该派生类虚函数表将保存重写的虚函数的地址,而不是基类的虚函数地址。
  •         如果基类中的虚方法没有在派生类中重写,那么派生类将继承基类中的虚方法,而且派生类中虚函数表将保存基类中未被重写的虚函数的地址。注意,如果派生类中定义了新的虚方法,则该虚函数的地址也将被添加到派生类虚函数表中。

下面的图片体现了上述的底层实现机制:

ATL炒冷饭学习之二:绕不开的虚函数ATL炒冷饭学习之二:绕不开的虚函数 

在C++primer第六版第十三章的虚函数的工作原理编译器处理虚函数的方法是:

      给每个对象添加一个指针,存放了指向虚函数表的地址,虚函数表存储了为类对象进行声明的虚函数地址。比如基类对象包含一个指针,该指针指向基类所有虚函数的地址表,派生类对象将包含一个指向独立地址表的指针,如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址,如果派生类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址将被添加到虚函数表中,注意虚函数无论多少个都只需要在对象中添加一个虚函数表的地址。

ATL炒冷饭学习之二:绕不开的虚函数ATL炒冷饭学习之二:绕不开的虚函数 

调用虚函数时,程序将查看存储在对象中的虚函数表地址,转向相应的虚函数表,使用类声明中定义的第几个虚函数,程序就使用数组的第几个函数地址,并执行该函数。

使用虚函数后的变化:

(1) 对象将增加一个存储地址的空间(32位系统为4字节,64位为8字节)。

(2) 每个类编译器都创建一个虚函数地址表

(3) 对每个函数调用都需要增加在表中查找地址的操作。

虚函数的注意事项

总结前面的内容

(1) 基类方法中声明了方法为虚后,该方法在基类派生类中是虚的。

(2) 若使用指向对象的引用或指针调用虚方法,程序将根据对象类型来调用方法,而不是指针的类型。

(3)如果定义的类被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚。

构造函数不能为虚函数。

基类的析构函数应该为虚函数。

友元函数不能为虚,因为友元函数不是类成员,只有类成员才能是虚函数。

如果派生类没有重定义函数,则会使用基类版本。

重新定义继承的方法若和基类的方法不同(协变除外),会将基类方法隐藏;如果基类声明方法被重载,则派生类也需要对重载的方法重新定义,否则调用的还是基类的方法。

总结

      总结了这么多关于虚函数表的内容,看似和COM的和口没有多大的关系;但是,毋庸置疑的是这一切都是COM的基础,COM实质就是接口,而接口的背后C++的虚函数,只有很好的理解了虚函数,才可以顺利的理解后面COM中的技术术语和深层次的概念。

继续阅读