C++虚函数的实现机制
说起虚函数想必大家不会陌生,这可是多态的实现基础,没有它就白瞎了。多态也就是运行时绑定,个人对静态和动态语言之间的差别研究不深,但在鄙人看来,其实动态语言也没有那么神奇的,静态语言只不过是代码区的调用在编译期就已经完全绑定了,而动态函数可以实现在运行期动态绑定。能在运行期动态绑定必然是好的,可以提供更大的灵活性,但是天下没有免费的午餐,想要动态绑定必然需要耗费额外的空间存储一些额外的用于动态跟踪的信息和额外的绑定时间。从后面的对虚函数实现机制中我们可以窥其一二。
一、函数调用机制
首先我们需要明白的一点事,函数是如何调用的,调用函数时发生了什么?这很重要,这里我的理解是,通常情况下C/C++中函数被放入内存中的代码区,是只读的(不允许修改,能修改那就会很强大,但势必带来性能的牺牲),而函数中存储的的只是一套'计算规则'(也可以称之为一套协议,比如一套菜谱,他告诉你有了原料之后如何进行有效的烹饪),里面没有任何实实在在的变量,下面举例说明:
static int id = 0; void getName(char *name) { //获取ID int myId = id++; //初始化后缀Buffer char namePostfix[20]; memset(namePostfix,0,20*sizeof(char)); //计算名字长度 int len = strlen(name); //设置后缀连接符 name[++len] = '_'; //获取后缀的字符串格式 itoa(myId,namePostfix,10); //计算后缀的长度 int pLen = strlen(namePostfix); //后缀拼接到名字末尾 memcpy(name+len+1,namePostfix,pLen); name[len+pLen+1] = '\0'; }
当有用户A调用函数getName时,首先它应当事先知道函数的入口地址,也就是函数代码放在哪儿了,然后才能读到这一行代码,读到每一行时其行为如下:
(1) 读到第5行时,它了解到我应当声明一个局部变量myId,这是自己的东西,与其他人调用是毫无关系,互不影响,好比你读到菜谱时,加少许盐一样,盐是你自己的,菜谱可不负责提供盐,他只负责指导你下一步该干嘛。好了声明之后,将id左自增运算,等等,这是你会想,这个没叫我自己买啊,那什么意思,很明显这个作料买不到,买到了也不是那个味道。天下仅此一份,你得耐心的去外面找,如果别人在用的话你最好等等(这就是为什么全局变量影响并发度的原因),找到了之后用完进行赋值。
(2)读到第七句告诉你,你需要你个buffer用来存储名字后缀,好吧你该去厨房拿个大点的碗来,待会有用,等等,记得洗干净。你丫的菜谱,什么都要用我自己的东西,这不是被你牵着鼻子走吗?菜谱回答道:我不是保姆,我只负责指导。
(3)读到第十行,好了你该计算一下这个名字有多长,好比你该看看你几天做菜的分量大概多少,待会好按情况放盐。
(4)......
诸如以上步骤:你完全可以把函数当做一个操作指南,它与具体的数据无关,他只是告诉你你想要完成任务,你得需要哪些原材料,这些材料仅仅是个名字,并不存在,当你按照指南行事的时候,你必须按名字找来一些可用的材料。至于类的成员函数也差不多,没有多少区别,由于一般读是不需要互斥的,所以只要没有全局变量和静态变量,基本上统一指导书多人同时参考是没有关系的。至于操作系统底层是否有一个处理机制就不深究,毕竟一个数据总线也不大可能在0.00000000000...01秒内同时读取同一位置吧,其实全局变量枷锁的原因也不在于说不能同时访问,只是防止数据的不一致。
二、函数调用的绑定
(1)对于普通函数,编译器就已经可以将函数名直接替换成地址,属于静态绑定,具体会调用那个函数其实早已盖棺定论了,对于普通成员函数来讲其实也是如此。
(2)对象并不知道自己拥有哪些成员函数,成员函数的绑定是根据类型来的,例如Base obj; Derived *d = &obj;当用d调用函数函数时,并不会因为d指向的是Base类型的函数就会调用Base类里面的成员函数,NO,C++没有那么智能,根本原因在于obj内没有存储任何有关函数的信息(除虚函数外),在内存中obj表现为一个结构体,只存储非静态的成员变量(这也是为什么sizeof不计算静态成员所占空间一样),到了二进制代码之后对象完全感知到不到函数成员的存在和静态数据成员的存在,之所以我们认为能感知,只不过是编译器的一些绑定行为欺骗了我们,比如说上例当d调用函数时,在编译期编译器根据类型(类命名空间找到对应的函数名——名字查找规则——我只认名字——其他的不管),查找对应的函数。然后将调用函数的地方替换成查找到的函数地址。就是这样,他们之间所谓的联系完全就是编译器的绑定行为造成了假象,如果我们知道编译器的底层实现,知道如何获取地址,我们可以在运行态制造一些完全不相干的绑定调用。
(3)相反,虚函数才是被对象实实在在感知的,因为对象对应的内存块中存储了虚函数表的指针,这样对于虚函数的解析,编译器不得不小心谨慎,他首先从内存中读出该指针,然后根据函数的索引值找到需要调用的函数,不过需要声明的时,这个索引值仍然是编译期决定的,实现绑定在代码中的(内部机制,表面上看不出来)。这就是为什么能实现多态,C++中的多态我觉得就是在集成体系中根据对象的实际类型来调用对应的函数,而不是根据指针的类型来调用函数。实现的方法就是对象和函数的绑定关系记录到内存中。在运行态解析,而不是编译期解析。
(4)其实如果看过前面的函数打桩的文章,我们了解到,有些C++编译器没有将代码区绝对只读化,可以通过一定的接口修改只读属性,来使得我们在运行态可以修改函数的代码,实现一些逆天的功能,尽管大多数时候用不到。而且所谓的私有公有数据也只不过是编译器的一箱情愿,如果我们在知道类的基本结构和类的布局之后,所有数据都可以通过:初始地址+偏移随心所欲的访问。
(5)需要声明的时尽管虚函数的地址在运行态可以改变,但是对于某个具体的类来讲,它的虚函数表只有一份,且里面的函数地址就是类对于的虚函数的地址,这是编译期决定的。按此推断:&类名::函数名格式应该是可以获取真实地址的,之所以如此可能是出于槽位的考虑以及既然支持多态,就不允许使用静态的地址,而造成某些地方多态失效的缘故吧。例如下面的例子:
Derived obj; void (Base::*pmf)= &Base::x; Base *ptr = &obj; (ptr->*pmf)(); |
如果虚函数x的形如&Base::x取到的是真实地址,那么是否以为真该调用会调用Base中的x函数呢, 如果是,那么这就破坏了多态性质,按照多态来讲,它应当调用Derived的x函数。 |
三、虚函数的实现机制
下面给出类的层次结构,主要表明一下几点,假设我们把对象看做内存级别的,那么有以下结论:
(1) 内存级对象表示对象在内存中的二进制编码;
(2) 对象其实不认识成员变量,内存只认识普通的成员变量和虚函数;
(3) 普通的成员函数(除去虚函数)和静态成员变量属于类,不属于任何对象,且对象中没有任何他们的信息,他们之间的任何关系和联系都是由编译器实现的,也就是说他们之间的相互绑定是在编译期决定的;
(4) 所有函数成员,不管是否继承,都只有一份拷贝,没有多余的副本。
class Base{ public: static int shareData; void funA() { printf("call Base::funA.\n"); } void funB() { printf("call Base::funB.\n"); } virtual void funC(){} virtual void funD(){} void setStaData() { shareData = 100; } private: int bData; }; int Base::shareData = 0; class Derived:public Base{ public: void funA() { printf("call Derived::funA.\n"); printf("&Base::funA = 0x%x\n",&Base::funA); } void funC(){} void setStaData() { shareData = 1000; } private: int dData; }; int main() { Derived d; //funA地址相同说明Base中的funA只有一份拷贝而 //Derived::funA的地址与其不同,是重写的缘故 //所谓函数继承只不过是让其在该命名空间可访问无 //需制造副本,重写的话只是自己写了一个同名的函 //数罢了,这会覆盖掉子空间的同名成员,这就涉及 //名字查找规则,就像Derived::funB能够获取继 //承而来的funB函数的原因在于,首先在Derived //空间查找自然是没有,然后又会去父类的空间中查 //找,所以才能找到,并不是说Derived空间中存在 //funB的声明,没有。 printf("&Base::funA = 0x%x\n",&Base::funA); printf("&Base::funA = 0x%x\n",&Derived::funA); d.funA(); d.Base::funA(); d.funB(); d.Base::funB(); //funB是同一地址,说明继承体系中函数只有一份拷贝 //继承只不过是将函数funB声明从属于类命名空间Derived //的子空间Base printf("&Base::funB = 0x%x\n",&Base::funB); printf("&Derived::funB = 0x%x\n",&Derived::funB); //对静态成员变量的操作说明静态成员变量也只有一份拷贝 //继承时只不过是声明该静态成员变量从属于类命名空间 //Derived的子空间Base printf("&Base::shareData = 0x%x\n",&Base::shareData); printf("&Derived::shareData = 0x%x\n",&Derived::shareData); d.setStaData(); printf("Base::shareData = %d\n",Base::shareData); printf("Derived::shareData = %d\n",Derived::shareData); //大小分别为8,12说明只有一个虚函数指针,这跟虚拟继承多继承是有区别的 //具体可以进一步阅读下面的参考博文 printf("sizeof(Base) = %d\n",sizeof(Base)); printf("sizeof(Derived) = %d\n",sizeof(Derived)); getchar(); return 0; }
总结:成员函数+静态成员变量属于类不属于对象,而虚函数+普通成员变量属于对象。属于类指的是对象对其没有感知,属于对象,说明对象对其有感知,但是任何函数都只有一份拷贝。
文献: