虚函数:从入门到精通

来源:互联网 发布:数据报表格式 编辑:程序博客网 时间:2024/05/17 02:38

什么是虚函数(Virtual Function)

C++2011标准(草稿)第10章第3小节给的描述为

Virtual functions support dynamic binding and object-oriented programming. A class that declares or inherits a virtual function is called a polymorphic class.

If a virtual member function vf is declared in a class Base and in a class Derived, derived directly or indirectly from Base, a member function vf with the same name, parameter-type-list (8.3.5), cv-qualification, and refqualifier (or absence of same) as Base::vf is declared, then Derived::vf is also virtual (whether or not it is so declared) and it overrides Base::vf.

意思就是说我在基类里面使用virtual关键字声明了一个函数为虚函数,派生类里面不管是直接的还是间接的,只要名字相同、参数类型列表相同、cv条件相同、ref条件相同,派生类里面的也都是虚函数(不管是不是在继承类中也用virtual声明了)。名字相同就不用说了,下面对后面三个条件说下注意点。

一般的参数类型列表相同就不说了,比如char就是char,不能是int。注意点是,const以及volatile关键字(这里便是cv-qualification中cv两个字母的意思),对于值传递的参数来讲,有没有这两个关键字的修饰,是没有区别的,也就是说认为是相同的参数类型。这里的值传递的参数不包括,按值传递的指针类型,当然不包括按引用传递。对于const修饰成员函数,可以认为是传入的this指针对应的形参是不是const指针,分析同前面的。

另外,对于多级继承的情况,在派生类中新添加的虚函数,那么只有往后继承的派生类里面的函数才为虚函数,在继承链中前面的类中满足虚函数条件的函数并不为虚函数。当然如果前面的类中满足虚函数条件的函数声明为虚函数的话,本次重新声明虚函数是不必要的,因为已经是虚函数了。这个可是用下面的代码(VS2013中测试通过)来证明:

class BaseClass{public:virtual void display(){cout << "BaseClass::display()" << endl;}void display2(){cout << "BaseClass::display2()" << endl;}};class DeriveClass :public BaseClass{public:void display(){cout << "DeriveClass::display()" << endl;}virtual void display2(){cout << "DeriveClass::display2()" << endl;}};class DeriveClass2 :public DeriveClass{public:void display2(){cout << "DeriveClass2::display2()" << endl;}};int main(){BaseClass bc;DeriveClass dc;DeriveClass2 dc2;BaseClass *bcp = &dc2;bcp->display2(); return 0;}


虚函数与名字隐藏(Name Hiding)

名字隐藏是指局部作用域的名字(变量名、函数名)会隐藏全局作用域中相同的名字。这个是由变量名查找规则决定的:C++中的名字查找顺序为成员函数局部命名域->所属类命名空间->基类命名空间->namespace命名空间->全局命名空间。这个名字隐藏仅仅考虑名称是否相同,如果是函数名称的话,并不会去考虑参数类型类表是否一致。派生类里面与基类成员函数同名的成员函数会隐藏基类的成员函数(不管是不是虚函数)。

虚函数与重载(Overload)

重载是处于同一个作用域内的函数,拥有不同的参数类型列表,但是相同的函数名。而虚函数的定义中明确说明,参数类型列表必须相同才能成为虚函数,也就是说在继承链中的虚函数并不影响在某一个类中有他的重载函数,并且声明重载函数的某个函数为虚函数也并不会使得其他的重载函数也成为虚函数。这里要说明的一点是,在声明一个基类的有重载的成员函数为虚函数的时候,通常情况下要注意一下,一旦你在继承类里面重写了该虚函数,而不管其他的重载的函数的话,你在派生类中是无法正常调用其他的重载函数的,这个原因呢就是因为上面的名字隐藏。如果要调用,那么就必须加上类标示和双冒号作用域符号,表明调用的函数是处于基类中的。因为在当前的类作用域找到了同样的名字,编译器就不会再去更大的空间中找(不管找到的函数是否匹配)。如果找到的函数参数类型列表不匹配,那么编译器就会报错。因此,通常声明一个被重载的函数为虚函数的话,也声明其他的函数为虚函数,并且在重写其中一个的时候也重写其他的。

内存中的虚函数

虚函数的位置在哪里呢?同一个类的虚函数由编译器组织起来形成了一个表,叫做虚函数表。就像通常的指针数组一样,这张表里面存储的是这个类的所有虚函数的函数地址(其实是一个放了一个可以跳到相应函数的JMP指令的地址)。既然都叫虚函数表了,里面是不存在非虚函数地址的。里面函数的排列顺序就是基类(不是派生类)里的声明顺序,如果是后来派生类里面添加的,则会添加到表末尾。同一个类的所有对象的虚函数表只有一个实例。不同类的虚函数表是不同的(不会共用存储空间),虽然存储的entry可能一样(比如基类与没有重写任何虚函数的派生类)。

这个虚函数表与类是如何联系在一起的呢?如果一个类有虚函数表,那么他的所有对象都会新增加一个指针成员——虚指针(void **类型),它指向了虚函数表的入口地址。这个指针,无法正常的使用.成员访问作用符得到,但是你可以通过地址自己强制转换得到。这个指针存在的位置在于这个类对象占用空间的起始地址处,大小看系统是32位还是64位(都说了是指针了,当然跟系统有关),分别是4个字节或8个字节。

一旦类(或其基类)中定义的有虚函数,那么这些类定义的所有对象占用的空间都会增加一个指针。如果在类对象中存储的数据比较少,那么这种空间的使用率就比较低。因此有观点认为要慎用虚函数(其原因还有下面的函数执行效率的一方面)。

虚函数的动态绑定

本以为虚函数在执行的时候,会在虚函数表里面查找匹配的函数入口地址。事实上,这些都是编译器已经完成的事情,也就是说虚函数在执行的时候编译器已经算好了是虚函数表中的第几个entry,直接调用和跳转。

虚函数的执行主要有两个步骤:1、装载(就是MOV指令啦)调用对象的地址;2、然后根据调用对象的地址,装载相应的虚函数地址(没有查找过程,直接装载虚函数表中的某个entry);3、然后执行相应的(虚)函数(汇编指令为CALL和JMP)。而对于一般的函数,编译器直接就写上了他的调用地址,直接CALL和JMP就行了,也就是说只有第3步。因此虚函数的执行比普通函数多了几条指令,用来装载调用对象的地址以及虚函数的地址。当然,说虚函数调用效率低下,多几个指令并不是主要因素,主要因素是虚函数的使用会导致CPU的流水线分支预测(CPU要预读指令以加快运行)预测错(或者错的概率比较大)。因为调用的函数的地址是动态的,在真实调用的时候才被CPU知道,因此预测对是一件很难的事情。而普通函数则直接就是写在指令里面了,流水线分支预测就可以很好的对他进行预测,直接装载下面的指令就行了。

虚函数与纯虚函数

纯虚函数仅仅是将虚函数后面加一个=0,包含纯虚函数的类叫做抽象类,它无法实例化,但是可以定义引用和指针。事实上,即使设定了该函数为纯虚函数,它还是可以有具体实现的;虚函数却不能没有具体实现,不然在link的时候是会出错的;普通成员函数“一般”则需要有实现,不过也不是必须的。这里的原因在与,虚函数的地址是要放进虚函数表的,如果没有实现,那么就找不到其地址,因此link会出错;而对纯虚函数而言,它用后面的=0告诉编译器,它是纯虚函数,没有实现(尽管可能有),你在虚函数表中填入0就行了;对于普通成员函数呢,就是有别人调用它的话就必须有实现,不然会出现link错误,如果没有人调用它,那么就不用有实现。

如果派生类中没有重写抽象基类的纯虚函数,那么这个派生类也属于抽象类,依旧不能实例化,其虚函数表中也包含了一个0项,因为其抽象基类中的那个0项没有被覆盖。

另外,如果纯虚函数有具体实现的话,调用的依旧是可以正常进行的。但是有一点,因为抽象类无法实例化,因此调用该纯虚函数具体实现的this指针一定不是抽象类(有一个例外就是在构造函数中调用,这样子的话该纯虚函数必须有实现,否则就会link出错),因此在不指定域作用符的情况下是会产生多态的,即调用的是this对象中的相应的虚函数。要实际调用,比如使用域作用符来指定,使用上跟普通的继承的成员函数没有太大区别,因为搞了半天只是为了调用一个函数,如果这个实现用另外的函数名字的话,其实实现的是相同的功能,只是函数名字不一样。

析构函数一定要有实现,因为编译器自己要调用以保证在退出某个作用域的时候,这个作用域中的局部对象都被析构,因此析构函数是不是纯虚函数没有任何关系,但通常情况下不应该是纯虚函数。

虚函数与构造函数、析构函数

虚函数的调用通常可以分为两种,类外的函数调用,成员函数调用。类外的函数调用,编译器/链接器会去传入this指针对应的类处的虚函数表,找其相应的虚函数来生成汇编指令。引用其实也是传入了一个指针。类成员函数中调用虚函数,需要注意的是构造函数和析构函数对虚函数的调用。

构造函数的执行过程(VS2013中测试)为基类构造函数,为虚指针赋值,类成员初始化列表(顺序为声明顺序),类内对象默认初始化,构造函数自定义代码。基类的构造函数执行只是省了基类构造函数这一步骤(因为他没有继承任何类)。基类构造函数执行的时候派生类里面啥都没有呢,包括虚指针;在基类构造函数执行完毕时,这个类已经被构造成为了一个基类,虚指针已经被赋值指向基类的虚函数表;接下来虚指针又一次被赋值为派生类的虚函数表地址。因此在基类构造函数中调用虚函数是不会产生效果的,它其实调用的就是基类的虚函数(这其实是编译器要这么做的或者标准上就是这么实现的,它也可以不这么做,只不过就很可能出现问题)。构造函数不能声明为虚函数,编译器会报错。

析构函数的执行过程与构造函数相反,先执行派生类的析构函数,派生类的析构函数退出之后才是基类的析构函数。当某类参与继承并且包含其他虚函数的时候,一般情况下最好将其析构函数声明为虚函数,这样可以保证在使用基类指针delete派生类实例时能够执行派生类的析构函数,不然会出现派生类析构函数不执行的情况。如果某类不包含虚函数,那么一般不要将析构函数声明为虚函数,因为会引入虚指针增大类对象的存储空间。如果类中不包含虚函数,那么一般情况下就不会出现使用基类的指针/引用去指向派生类的情况,因为这样做是为了实现多态,但是没有虚函数何来多态。在析构函数中调用虚函数和构造函数一样,如果是在派生类中调用虚函数依旧是调用的派生类的相应的虚函数,但是如果是在基类里面调用虚函数,那么执行的也是基类的虚函数。因为在派生类析构函数执行完毕时,虚指针已经被赋值回基类的虚函数表地址了。

虚函数与设计模式

设计模式中5种创建模式中有4种都使用了虚函数,他们是原型模式、抽象工厂模式、工厂模式、创建者模式,只有单例模式没有使用到。

虚函数与虚继承

虚继承主要处理的是在多重继承中的基类部分可能会出现多个的情况。当然,一级的多重继承并没有任何问题。一个典型的问题就是菱形继承,即两个类继承了一个基类,然后再有一个继承类继承了这两个子类。在菱形继承中,第二次继承的继承类,可以分别通过其两个父类来调用爷爷类的数据,并且会有两份拷贝。而使用了虚继承(即在继承的时候加上virtual关键字)就可以只保留爷爷的一份数据。

0 0
原创粉丝点击