虚函数的机制
在C++中为了实现运行中的多态,需要满足三个条件:类之间是派生关系;声明基类、派生类的成员函数为虚函数;在满足赋值兼容性规则的前提下,通过指针或引用访问虚函数。虚函数的为了实现动态联编(运行多态)而引入的概念。
动态联编(dynamic binding)和静态联编(static binding)。静态联编意味着编译器能够直接将标识符和存储的物理地址联系在一起。每一个函数都有一个唯一的物理地址,当编译器遇到一个函数调用时,它将用一个机器语言说明来替代函数调用,用来告诉CPU跳至这个函数的地址,然后对此函数进行操作。这个过程是在编译过程中完成的(注:调用的函数在编译时必须能够确定),所以静态联编也叫前期联编(early binding)。
但是,如果使用哪个函数不能在编译时确定,则需要采用动态联编的方式,在程序运行时在调用函数,所以动态联编也叫后期联编(late binding)。虚函数简单实例如下:
/* *作者:侯凯 *说明:虚函数简单实例 *日期:2014-1-4 */ #include <iostream> using namespace std; class A { public: virtual void f() {cout << "A" << endl;} //声明虚函数 }; class B : public A { public: void f() { cout << "B" << endl;} }; void main() { A a, *pa; B b; b.f();//输出B a = b; a.f();//静态联编,输出A pa = &b; pa->f();//动态联编,输出B A &aa = b; aa.f();//动态联编,输出B cin.get(); }
它具体的实现原理是什么呢?C++采用虚函数表来实现动态束定。虚函数表是一张函数查找表,用以解决以动态联编方式调用函数。它为每个可以被类对象调用的虚函数提供一个入口,这样当我们用基类的指针或者引用来操作子类的对象时,这张虚函数表就提供了编译器实际调用的函数。虚函数表其实是存储了为类对象进行声明的虚函数地址。当我们创建一个类对象时,编译器会自动的生成一个指针*__vptr(一个隐藏指针),该指针指向这个类中所有虚函数的地址表。
虚函数的动态绑定机制和3个东西相关:
虚函数表 vtbl:它是多态类自身的信息,和具体的对象无关。在多继承情况下,虚表中罗列继承自每个父类的虚函数地址。如果子类直接使用父类中的虚函数定义,不进行覆盖,则子类、父类的虚函数地址就会相同;如果子类进行覆盖虚函数,vtbl 中对应的虚函数地址就会替换成子类自己的函数地址。
虚表指针 vptr:它保存 vtbl 的地址,每个多态类的对象存储中都会有 vptr。并且在多继承情况下,会存在多个 vptr。多继承子类对象,是按续构造父类对象后的拼接对象,再加上子类特有的存储。与单继承相同的是所有的虚函数都包含在虚函数表中,所不同的多重继承有多个虚函数表,当子类对父类的虚函数有重写时,子类的函数覆盖父类的函数在对应的虚函数位置,当子类有新的虚函数时,这些虚函数被加在第一个虚函数表的后面。
虚函数序号:它是配合 vtbl 实现动态绑定的重要元素,它在编译时就确定了。在运行时,会根据 RTTI(运行时类型信息) 来选择合适的 vptr,配合虚函数序号,计算 vtbl 中的存储虚函数地址的位置,最后找到虚函数的实际调用地址,进行 this call 调用。
简单的示例程序如下:
class C1 { public: void ttt(){cout<<"A"<<endl;} }; class C2 { public: int s; void ttt(){cout<<"A"<<endl;} }; class C_Virtual { public: int s; virtual void C_test(){ cout<<"C_Virtual"<<endl;} }; class D_Virtual:public C_Virtual { int s; virtual void D_test(){cout<<"D_Virtual"<<endl;} }; void main() { C1 c1;C2 c2;C_Virtual c_virtual;D_Virtual d_virtual; cout<< sizeof(c1)<<" " << sizeof(c2)<<" " << sizeof(c_virtual)<<" " << sizeof(d_virtual)<<" " << endl; cin.get(); }
输出结果为:,解释如下:
一般来说,成员函数存在类中,而变量存放在对象中,所以C1是空类。空类(不含数据成员)的大小不为0,而是1,试想一个“不占空间”的变量如何被存储、又如何被取地址呢?显然不行,编译器也就只得为其分配一个字节的空间用于占位,而一旦类有了非静态数据成员,那么就不存在上面的问题,编译器也就没有必要多分配这1个字节了。
而C2就不是空类了吗,它还有一个非静态的成员,整型变量s存储在对象中,所以占4个字节。
C_Virtual 类中含有虚函数C_test,所以类中要存放虚函数表,相对应的对象中就要存储虚表指针vptr,32位操作系统中,指针占4个字节,再加上变量s共8个字节。
D_Virtual 继承自C_Virtual 类,并含有自己声明的虚函数,继承机制会把D_test添加到继承的虚函数表中,形成新的虚函数表存储在类空间中;对应对象中有虚表指针指向它的虚函数表。
在VS下命令提示中,查看对象的内存布局:
cl [filename].cpp /d1reportSingleClassLayout[className]
可见,对象大小为12,vfptr占4个字节,基类的变量s和自己的变量s各占4个字节;虚函数表中包含C_test和D_test两个虚函数。
最后简单谈谈:使用基类指针操作子类数组
数组的寻址方式:元素在内存中的位置 = 数组开始位置 + 索引 x 单个元素的大小。
子类数组的结构:基类数据放在前面,子类数据放在后面,如下
使用父类指针时的操作分析:
把一个子类数组变量赋值给一个数据元素父类的指针时,指针首先指向数组头的位置(如图)。
操作1:访问元素
访问第一个元素(元素0)时,没有任何问题。访问下一个元素时,指针后移。因为该指针是父类指针,所以偏移量为父类的大小sizeof(父类)。这时指针将指向图中“子类数据”的位置,而不是元素1的位置。这将导致出错。
操作2:使用delete[]删除数据
delete[]同样是按访问元素的方法逐个析构这些元素。因为它无法找到对象真正的起始位置。比较有意思的一段程序如下:
#include <iostream> using namespace std; class Base { public: int a; int b; Base() { a = 1; b = 2; } void Print(){ printf("%d,%d\n", a,b); } }; class Child:public Base { public: int c; Child() { c = 3; } }; int main(int argc, char* argv[]) { Base* pBase = new Child[3]; for (int i=0; i<3; i++) pBase[i].Print(); delete []pBase; return 0; }
输出:,大家自己分析下吧。切记,不要用父类指针去操作子类数组。