C++虚函数总结

来源:互联网 发布:java如何实现多线程 编辑:程序博客网 时间:2024/06/14 10:44
C++虚函数分析 
     虚函数是C++多态的重点,也是难点,这也是我个人在学习过程中所体会到的,稍有不慎,就会出错。
    虚函数的主要功能就是实现多态,子类继承父类,重写父类的函数从而实现自己的功能。多态的具体实现就是定义父类对象指针,指向子类对象,然后在运行时进行动态绑定,调用同名的不同功能的函数。    在C++中,有些概念总是容易混淆,例如:重载,覆盖,隐藏,这些术语虽然表面上看起来没有一点儿关系,但是一到运用中,就会出现各种各样的问题,因而这些清楚这些概念就是十分必要的。    重载:(OverLoad)重载是C++中不可获取的重要概念,重载实现了静态绑定,既在编译时就能确定调用哪个函数。    那编译器是如何在编译时就确定调用哪个函数呢?这就需要函数重载自己的实现特性了,函数重载的方式有:1.不同的形参列表;2.const修饰的类成员函数。但是,仅仅返回类型不同,是不能实现函数重载的,编译器不能识别返回类型不同的同名同参函数。    覆盖:(OverRide)也称作重写,覆盖则和虚函数是紧紧相连的,有了虚函数的大前提条件下,才可能会出现覆盖。    那如何出现覆盖呢?子类继承父类,父类函数必须有Virtual关键字修饰,也就是父类的函数必须是虚函数,并且子类函数和父类函数同名同参,所以,覆盖是针对虚函数而言的。    隐藏:隐藏是个很特别的概念,很容易出现混淆。隐藏是指子类函数屏蔽了同名的父类函数。    那如何区别隐藏呢?首先,只要子类有与父类同名的函数,并且该函数与父类的同名函数形参列表不同,那么无论有没有Virtual关键字,子类都将会隐藏同名的父类函数。其次,如果子类拥有与父类同名同参的函数,但是父类函数没有加Virtual关键字,也就是说父类函数不是虚函数,那么该函数就会隐藏父类的同名同参函数。    这些概念确实很容易混淆的,所以要仔细的分析,并且在实际的编码中理解运用,就会有很大的进步。    当然,这篇文章主要还是写虚函数的具体实现的,那到底虚函数是如何实现的呢?这就要理解虚函数的关键------虚函数表。    虚函数表听起来确实是很威猛,不过只要一步步的探究,理清了就不会畏惧,这也是我在学习虚函数中领悟到的。当初学到虚函数时,看到虚表指针(vptr),虚函数表(vftable),我也挺蒙的,但是再难也不能放弃,学习C++过程是痛苦的,这一点无可置疑,不过学过之后,就会恍然大悟,那种感觉就会是幸福的。    虚函数表是什么呢?它的实现就是一个函数指针数组,可能又蒙了,什么是函数指针?什么又是函数指针数组呢?    其实函数指针也是指针啊,例如:int number; int *p = &number;    这时p是个整形指针,然后指向一个整形变量。函数指针也一样,既然一个整形指针能够指向一个整形变量,那么函数指针同样是指针,那它肯定也是指向一个函数的,至于指向那个函数,则就要自己实现函数指针了,    例如:void BubbleSort(int *pArray , int length)这个函数是个很简单的冒泡排序函数,它接受两个参数,一个数组首指针,一个数组长度,返回值类型为空(void)。那么如何定义指向该函数的函数指针呢?其实很简单,既然我们知道了返回值类型和形参列表,那又有何难呢?    void (*p)(int * , int );这个就是一个函数指针,不过长得比普通的内置类型指针奇怪而已。    void(*p)(int *, int)=BubbleSort;这样就可以了,我们就能用该指针实现调用函数了,p(Array , sizeof(Array) / sizeof(Array[0]);    好了,既然理解了函数指针,那么函数指针数组又是什么呢?这个其实更简单了。    函数指针数组,这个数组可和内置类型数组不一样(int array[10])内置类型数组有三大特性:1.数组下标连续 , 2.数组元素类型一致, 3.数组内存连续。    但是函数指针数组呢,它的实现是根据虚函数个数在堆上开辟同样个数的虚函数指针大小的内存,然后存放每个虚函数的函数指针,函数指针则指向对应的虚函数。图解如下:
    
    
    切记,无论是内置类型指针(int *p)还是函数指针(void(*p)(int*iint)),或者对象指针(Base *p),不管是一级指针(int*p),二级指针(int **p),还是多级指针,只要是指针它在不同平台下的大小是固定的。32位系统,一切指针只占4个字节大小。 sizeof(p)大小始终为4。        只要理解了虚函数表是干什么的,那么虚表指针也就没有什么大问题了。既然虚函数表实在堆上开辟的空间,那我们就必须要保存这块内存空间的首地址,不然就找不到这块内存空间,就会出现内存泄露问题。例如:    void Function( )    {       int *p = new int[10];//这里开辟了10*sizeof(int)=40个字节       memset(p , 0, 10*sizeof(int));//全部置为0            for(int i=0; i< 10; ++i)       {          cout << *p << endl;  //打印10个0        }      }    但是这个函数是有很大问题的,因为指针变量p本身是在栈上开辟的,而在函数内部在堆上开辟了40个字节大小的空间,然后让指针变量p保存这块内存空间的首地址。问题就在这个函数结束的时候,由于指针变量p是栈上的变量,函数调用结束,栈上的变量就被自动释放了,然而堆上的内存空间却不能自动释放,这就造成了内存泄露。    虽然说堆上的空间在程序结束的时候操作系统会自动释放,但是这也是运气好的时候,万一其他程序继续调用这个函数了怎么办,那就会持续性的造成内存泄露。     于是这种形式的函数又诞生了:     void Function( int *p )    {       p = new int[10];//这里开辟了10*sizeof(int)=40个字节       memset(p , 0, 10*sizeof(int));//全部置为0            for(int i=0; i< 10; ++i)       {          cout << *p << endl;  //打印10个0        }     }    调用者便愉快的写上这样的测试程序:    int *p = NULL;    Function(p);     for(int i=0; i< 10; ++i)    {       p[i] = i;  //给p指向的内存空间赋值     }     那么真的能实现该功能吗?肯定不行,原因就出现在函数参数传递的时候。void Function( int *p ); 可能会有人认为形参指针名字和实参名字都一样,那肯定是同一个指针了,那我就换个名字!    void Function( int *q )   {       q = new int[10];//这里开辟了10*sizeof(int)=40个字节       memset(q , 0, 10*sizeof(int));//全部置为0            for(int i=0; i< 10; ++i)       {          cout << *q << endl;  //打印10个0        }    }     然后用同样的测试程序,你依然会发现编译器会快速的给你报错。那么问题就来了,为什么会这样?    其实本质问题就在于函数传参,大家也知道C语言只有值传递,而C++中引入了引用传递。值传递就意为着函数参数接受实参传过来的值。而引用传递则是实实在在的引用原有变量。如下图:           所以呢,即使开辟了内存空间,也是给形参开辟了40个字节的空间,当函数调用结束后,形参变量便自动释放,而堆空间没有释放,所以造成了内存泄露,然而,在测试程序里定义的指针变量p依然没有空间,它仍然是NULL,当你对一个空指针写入数据时,编译器肯定会报错。    那怎么才能实现这个功能呢?那就要用到两种方式:1.参数为二级指针,2.参数为引用。    void Function(int **q);或者是void Function(int *&q);这样就没有问题了。    当然开辟空间的时候,还是有点区别的:    二级指针:*q = new int[10];    引用: q = new int[10];    说了这么多,就是为了让大家理解指针和引用,特别是二级指针。因为我们的虚表指针vptr就是一个二级指针。    现在我们就要开始探究虚函数的实现了。   class Base   {   public:Base( ):m_length(0){}virtual ~Base( ){}virtual void Display( ) const{cout << m_length << endl;}virtual void SetNumber(int len){m_length = len;    }   protected:int m_length;};    当定义了一个这样的类对象的时候,编译器到底为我们做了些什么呢?    首先,它会在我们的对象内存空间里增加一个虚表指针,就是上面说的vfptr, 它是一个二级指针,保存的是虚函数表的地址,也就是说这个指针指向一个虚函数表,而虚函数表里面放的就是这个类里面所有的虚函数。如下图:                看完这张图后,相信大家肯定会有一个清晰的了解了。接下来我们就要一步一步的写出如何通过对象找到虚函数。    虚函数表地址:(int*)*(int*)(&b);    为什么要转换为int*?因为取对象b的地址,就能访问对象b的内容,如果你不转换,就不知道到底要访问对象里面多少个字节的内容,大家也知道指针是有步长的,通俗的说就是指针的类型决定了指针的步长.    例如:int *p = array;(int array[10])array是一个10个元素大小的数组,如果要访问数组第一个元素,可以这样写:*p;如果array[0]的值为1,则*p 和 array[0]的值都为1;如果要遍历,p++就可以实现。为什么p++能够遍历呢,就因为p是一个整形指针,所以就能一次向前走int类型大小的字节,然后访问对应的元素。如果定义一个char*p指针,既char *p = array;则一次就只能走一个字节,不过这不是我们想要的。     所以指针的类型决定了步长,当我们取b的地址时,我们得到的是一个实实在在地址,也就是十六进制表示的数字,例如:0x123456;我们将这个地址转换为int*时,就能利用*(int*)(&b)访问对象b里面的第一个4个字节大小的内容,而这个内容则就是我们存放的虚函数表的地址,然后我们依然将这个地址转换为int*类型就可以了,这就是虚函数表地址。    大家可能有疑问,说是上边图上对象b的第一个内容为vptr啊,怎么会是虚函数表地址呢?就因为:(int*)*(int*)(&b);此时此刻我们访问了对象b的第一个4个字节的内容,而第一个字节的内容就是一个二级指针,而我们对一个vfptr进行*操作时,就像:    int num; int *p = &num; int **q = &p;     *q = p = &num; 一级指针p保存num的地址,而二级指针保存的是一级指针的地址,所以对二级指针进行*操作时就能访问到一级指针,而一级指针保存的是num的地址,所以*q = p = &num;    同理:*vfptr = &vftable;所以(int*)*(int*)(&b) == *vfptr = &vftable;    下图是我在VS2013运行出来的,因为是微软的编译器,所以微软在处理虚函数的时候,就会自动给对象添加一个__vfptr,并且放在对象的前四个字节,其他的编译器我不太清楚,不过大家可以参考一下微软的处理方法:                既然理解了虚函数表,那要访问虚函数还有何难?我们的测试类:Base有三个虚函数,分别是析构函数、输出函数、设置函数;这三个虚函数的地址就放在虚函数表里面,有对应的虚函数指针。因为虚函数表数组,所以我们就可以像访问普通数组一样,去访问虚函数表其他的元素。    第一个虚函数~Base()地址:(int*)(*((int*)*(int*)(&b) + 0));    第二个虚函数Display()地址:(int*)(*((int*)*(int*)(&b) + 1))    第三个虚函数SetNumber(int len)地址:(int*)(*((int*)*(int*)(&b) + 2))     看到这样有人肯定又蒙了,怎么这么长,看起来很难的样子,不过别害怕,我们慢慢来解析。    大家知道虚函数表地址:(int*)*(int*)(&b)代表虚函数指针数组的首地址,既然是首地址,*( (int*)*(int*)(&b) )是不是取虚函数指针数组里面的第一个元素?肯定是的啊,取完后我们再将这个地址转换为int*类型,不就是上面的(int*)*( (int*)*(int*)(&b)+0),而虚函数表里面的第一个元素不就是放的第一个虚函数的地址吗?     这里加不加0都无所谓,加0是为了提醒数组下标是从0开始的,就和数组int *p = array;p[0] == *p == *(p+0) 一样的道理。    那同理第二个虚函数,第三个虚函数不都是一样的实现方法么!    好了, 这就是我总结了虚函数的实现原理,写了一个早上,虽然自己理解了虚函数,但是让自己写出文章出来确实有些难度,不过也锻炼了下自己,有空了就总结自己学习过的知识,然后写下来,就会有一个更透彻的思路了。    C++语言确实比较难,不过C++11新标准,C++14新标准,甚至是在策划的C++17新标准,都为C++语言增添了更好的机制,相对来说,难度也增加了,所以我们就要更加的努力去学习。    我相信,站在巨人肩膀上的我们是幸福的。 
        
     
     
     
     
0 0
原创粉丝点击