首页 > 代码库 > c++编译器对多态的实现原理总结

c++编译器对多态的实现原理总结

问题:定义一个空的类型,里面没有任何的成员变量或者成员函数,对这个类型进行 sizeof 运算,结果是?

结果是1,因为空类型的实例不包含任何信息,按道理 sizeof 计算之后结果是0,但是在声明任何类型的实例的时候,必须在内存占有一定的空间,否则无法使用这些实例,至于占据多少内存大小,由编译器决定。

继续问:如果在这个类型里添加一个构造函数和析构函数,那么结果又是多少?

还是1,因为我们调用构造函数和析构函数,只需要知道函数的地址即可,而这些函数的地址只和类型相关,和类型的实例无关,编译器不会为这两个函数在实例内添加任何额外的信息。

继续问:如果把析构函数变为虚函数呢?结果是多少?

c++编译器发现了类型里有虚函数,,就会为这个类型生成一个虚函数表,并在该类型的每一个实例中添加一个指向虚函数表的指针,在32位机器,指针类型大小是4字节,结果是4,64位机器中,指针大小是8字节,结果是8。

面向对象的多态的实现效果

多态:同样的调用语句有多种不同的表现形态

看下面的代码例子:

技术分享

class animal
{public:    void sleep()
    {
        cout<<"animal sleep"<<endl;
    }    void breathe()
    {
        cout<<"animal breathe"<<endl;
    }
};class fish:public animal
{public:    void breathe()
    {
        cout<<"fish bubble"<<endl;
    }
};int main(void)
{
    fish fh;
    animal *pAn=&fh;
    pAn->breathe();    return 0;
}

技术分享

父类指针指向了子类对象,调用了 breathe 方法,那么结果是animal breathe,也就是说调用的是父类的breathe方法。 这没有实现多态性。因为C++编译器在编译的时候,要确定每个对象调用的函数的地址,这称为早期绑定(early binding),当fish类的对象fh的地址赋给父类的pAn指针时,C++编译器进行了类型转换,它认为父类的指针变量pAn保存的就是animal对象的地址。当在main()函数中执行pAn->breathe()时,调用的就是animal对象的breathe函数。

进一步说:

技术分享

在我们构造fish类的对象时,首先要调用父类:animal类的构造函数去构造animal类的对象,然后才调用fish类的构造函数完成自身部分的构造,从而拼接出一个完整的fish对象。当将fish类的对象转换为animal类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是图中的“animal的对象所占内存”。

那么当利用类型转换后的对象指针去调用它的方法时,当然也就是调用它所在的内存中的方法。因此,输出animal breathe。这不是多态的表现形式。

多态实现的三个条件

必要的前提是必须有继承关系、然后我们需要父类指针(引用)去调用子类的对象,且关键是:子类有对父类的虚函数的重写。virtual关键字,告诉编译器这个函数要支持多态,我们不要根据指针类型判断如何调用方法,而是要根据指针所指向的实际对象类型来判断如何调用。

多态的理论基础

前面的例子,输出的结果是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用迟绑定(late binding)技术。当编译器使用迟绑定时,就会在运行时再去确定对象的类型以及正确的调用函数。而要让编译器采用迟绑定,就要在基类中声明函数时使用virtual关键字,这样的函数我们称为虚函数。一旦某个函数在基类中声明为virtual,那么在所有的派生类中该函数都是virtual,而不需要再显式地声明为virtual。

所谓的动态联编:根据实际的对象类型来判断重写函数的调用。

C++中多态的实现原理

当类中声明虚函数时,编译器会在类中生成一个虚函数表,虚函数表是一个存储类成员函数指针的数据结构,虚函数表是由编译器自动生成与维护的,virtual成员函数会被编译器放入虚函数表中,存在虚函数时,每个对象中都有一个指向虚函数表的指针(vptr指针)

技术分享

 

如图,编译器为每个类的对象提供一个虚表指针vptr,这个指针指向对象所属类的虚函数表。在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向所属类的虚表,从而在调用虚函数时,就能够找到正确的函数。

技术分享

在上例子中:

    fish fh;
    animal *pAn=&fh;
    pAn->breathe();

由于父类的指针pAn实际指向的对象类型是子类的对象,因此vptr指向的子类fish 类的vtable,当调用pAn->breathe()时,根据虚表中的函数地址找到的就是fish类的breathe()函数。正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。

那么虚表指针在什么时候,或者说在什么地方初始化呢?
c++是在构造函数中进行虚表的创建和虚表指针的初始化。

构造函数的调用顺序:在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化父类对象的虚表指针vptr,该虚表指针指向父类的虚表。当执行子类的构造函数时,子类对象的虚表指针vptr被初始化, 此时 vptr指向自身的虚表。当fish类的fh对象构造完毕后,其内部的虚表指针也就被初始化为指向fish类的虚表。

在类型转换后,调用pAn->breathe(),由于pAn实际指向的是fish类的对象,该对象内部的虚表指针指向的是fish类的虚表,因此最终调用的是fish类的breathe()函数。
 技术分享

说明:

通过虚函数表指针VPTR调用重写函数是在程序运行时进行的,因此需要通过寻址操作才能确定真正应该调用的函数。而普通成员函数是在编译时就确定了调用的函数。在效率上,虚函数的效率要低很多。出于效率考虑,没有必要将所有成员函数都声明为虚函数

对象在创建的时,由编译器对VPTR指针进行初始化,只有当对象的构造完全结束后VPTR的指向才最终确定,到底是父类对象的VPTR指向父类虚函数表还是子类对象的VPTR指向子类虚函数表。

回到开始的问题:

技术分享

class A
{    void g(){.....}
};
则sizeof(A)=1;如果改为如下:class A
{public:    virtual void f()
    {
       ......
    }    void g(){.....}
}

技术分享

则 sizeof(A)=4,这是因为在类A中存在virtual function,为了实现多态,每个含有virtual function的类中都隐式包含着一个静态虚指针vptr指向该类的静态虚表vtable, vtable中的表项指向类中的每个virtual function的入口地址。

多态是在程序进行动态绑定得以实现的,而不是编译时就确定对象的调用方法的静态绑定。

程序运行到动态绑定时,通过基类的指针所指向的对象类型,通过vptr找到其所指向的vtable,然后调用其相应的方法,即可实现多态。这就是动态绑定(dynamic binding)或者叫做迟后联编(lazy compile)。

技术分享

class base;base *pbase;class base{public:    base()
    {
        pbase=this;
    }    virtual void fn()
    {
        cout<<"base"<<endl;
    }
};class derived:public base{    void fn()
    {
        cout<<"derived"<<endl;
    }
};

derived aa;int main(void)
{
    pbase->fn();    return 0;
}

技术分享

在base类的构造函数中将this指针保存到pbase全局变量中。在定义全局对象aa,即调用derived aa;时,要调用基类的构造函数,先构造基类的部分,然后是子类的部分,由这两部分拼接出完整的对象aa。

这个this指针指向的当然也就是aa对象,那么我们在main()函数中利用pbase调用fn(),因为pbase实际指向的是aa对象,而aa对象内部的虚表指针指向的是自身的虚表,最终调用的当然是derived类中的fn()函数。

在derived类中声明fn()函数时,忘了加public关键字,导致声明为了private(默认为private),但通过前面我们所讲述的虚函数调用机制,也就明白了这个地方并不影响它输出正确的结果。不知道这算不算C++的一个Bug,因为虚函数的调用是在运行时确定调用哪一个函数,所以编译器在编译时,并不知道pbase指向的是aa对象,所以导致这个奇怪现象的发生。如果直接用aa对象去调用,由于对象类型是确定的(注意aa是对象变量,不是指针变量),编译器往往会采用早期绑定,在编译时确定调用的函数,于是就会发现fn()是私有的,不能直接调用。

如果直接在基类的构造函数中调用虚函数,会怎样?

在调用基类的构造函数时,编译器只“看到了”父类,并不知道后面是否后还有继承者,它只是初始化父类对象的虚表指针,让该虚表指针指向父类的虚表,所以看到结果当然不正确。只有在子类的构造函数调用完毕后,整个虚表才构建完毕,此时才能真正应用C++的多态性。换句话说,不要在构造函数中去调用虚函数实现多态,当然如果只是想调用本类的函数,也无所谓。

得到一个结论:

构造函数中调用多态函数,不能实现多态。

虚函数和纯虚函数比较

虚函数

引入原因:为了方便使用多态特性,我们常常需要在基类中定义虚函数。

纯虚函数

引入原因:为了实现多态性,纯虚函数有点像java中的接口,自己不去实现过程,让继承他的子类去实现。在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。 这时我们就将动物类定义成抽象类,也就是包含纯虚函数的类,纯虚函数就是基类只定义了函数体,没有实现过程:

 virtual void Eat() = 0; 直接=0 不要 在cpp中定义就可以了

虚函数和纯虚函数的区别
虚函数中的函数是实现的哪怕是空实现,它的作用是这个函数在子类里面可以被重载,运行时动态绑定实现动态,而纯虚函数是个接口,是个函数声明,在基类中不实现,要等到子类中去实现
虚函数在子类里可以不重载,但是虚函数必须在子类里去实现。

总结:

对于虚函数调用来,每一个对象内部都有一个虚表指针,该虚表指针被初始化为本类的虚表。所以在程序中,不管你的对象类型如何转换,但该对象内部的虚表指针是固定的,所以才能实现动态的对象函数调用,这就是C++多态性实现的原理。

如果基类有虚函数:

1、每一个类都有虚表。

2、虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现。如果基类3个虚函数,那么基类的虚表中就有三项(虚函数地址),派生类也会有虚表,至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。

3、派生类的虚表中虚函数地址的排列顺序和基类的虚表中虚函数地址排列顺序相同。


c++编译器对多态的实现原理总结