代码改变世界

白话C++系列(24) -- 虚函数与虚析构函数实现原理

2016-06-06 21:47  Keiven_LY  阅读(1884)  评论(0编辑  收藏  举报

虚函数与虚析构函数原理

前面我们学习了虚函数和虚析构函数,大家一定觉得很神奇,也很想知道,到底什么原因,采用了什么方法实现了虚函数和虚析构函数。这节课就为大家来揭晓虚函数和虚析构函数的实现原理。

关于实现原理的部分,因为涉及到函数指针,所以先给大家介绍一下函数指针的相关内容。

函数指针

我们在前面的课程已经学习过:如果通过一个指针指向一个对象,我们就叫它对象指针。那么指针除了可以指向对象之外,它也可以指向函数。那么,函数的本质是什么呢?函数的本质,其实就是一段写在内存中的二进制代码。我们可以通过指针来指向这段代码的开头,那么计算机就会从开头一直往下执行,直到函数的结尾,并且通过相关指令返回回来。如果我们有以下5个函数指针,这五个函数指针这里写的就是五个函数地址。

当我们使用的时候,比如如果我们适用Fun3_Ptr,那么就可以通过Fun3_Ptr拿到Fun3()这个函数的函数入口。当我们用指针指向到函数入口,并且命令计算机开始执行的时候,那么计算机就会使得Fun3中的二进制代码不断地得到执行,直到执行完毕为止。其他的函数也同样如此。那么,有的人会觉得函数指针很神奇,其实函数的指针与普通的指针本质上是一样的,也是由四个基本的内存单元组成,存储着一个内存的地址,这个内存地址就是这个函数的首地址

讲完了函数指针,我们就可以一起来学习多态的实现原理了。我们先来看一个例子。

在这个例子中,我们定义了一个Shape类,在这个Shape类中,还定义了一个虚函数和一个数据成员。然后,又定义了一个Circle子类(public继承自Shape)。大家请注意,我们在这里并没有给Circle定义一个计算面积的虚函数,也就是说,Circle这个子类所使用的也应该是Shape的虚函数来计算面积。当我们这样定义完成之后,我们来想一想,此时的虚函数如何来实现呢?

当我们去实例化一个Shape的对象的时候,在这个Shape对象当中,除了数据成员m_iEdge(表示边数)之外,还有另外一个数据成员-----虚函数表指针,它也是一个指针,占有4个基本内存单元。顾名思义,虚函数表指针就指向一个虚函数表。这个虚函数表会与Shape类的定义同时出现。在计算机中,虚函数表也是占有一定的内存空间的,这里假设虚函数表的起始位置是0xCCFF,那么这个虚函数表指针的值vftable_ptr就是0xCCFF。父类的虚函数表只有一个。通过父类实例化出来的所有的对象的虚函数表指针的值都是0xCCFF,以确保它的每一个对象的虚函数表指针都指向自己的虚函数表。在父类Shape的虚函数表当中,肯定定义了一个这样的函数指针,这个函数指针就是计算面积(calcArea())这个函数的入口地址。这里假设计算面积函数的入口地址是0x3355,那么虚函数表中的函数指针(calcArea_ptr)的值就是0x3355。调用的时候就可以先找到虚函数表指针,再通过虚函数指针找到虚函数表,再通过位置的偏移找到相应的虚函数的入口地址,从而最终找到当前定义的这个虚函数----计算面积(calcArea())。整个过程如下所示:

当我们去实例化Circle的时候,又会是怎样的呢?如果我们实例化一个Circle对象,因为Circle当中并没有定义虚函数,但是它却从父类Shape当中继承了虚函数,所以我们在实例化Circle这个对象的时候,也会产生一个虚函数表。请大家注意,这个虚函数表是Circle自己的虚函数表,它的起始地址是0x6688。但是在Circle的虚函数表当中,它的计算面积的函数指针(calcArea_ptr)却是一样的,都是0x3355,这就能够保证:在Circle当中去访问父类的计算面积的函数,也能够通过虚函数表指针找到自己的虚函数表,在自己的虚函数表中找到的计算面积的函数指针也是指向父类的计算面积的函数入口的。整个过程如下所示:

那么,如果我们在Circle中定义了计算面积的函数(如下所示),又会是怎样的呢?

我们来看一看,对于Shape这个类来说,它的情况是不变的,有自己的虚函数表,并且在实例化一个Shape的对象之后,通过虚函数表指针指向自己的虚函数表,然后虚函数表当中有一个指向计算面积的函数,这样就Ok了。对于Circle来说,则有些变化。如下所示:

Circle的虚函数表与之前的虚函数表示一样的,但是,因为Circle此时自己已经定义了自己的计算面积的函数,所以它的虚函数表中关于计算面积的这个函数指针已经覆盖掉了父类当中的原有的指针的值。换句话说,0x6688当中的计算面积的函数指针的值变成了0x4B2C,而Shape当中的0xCCFF这个虚函数表中的所记录的计算面积的函数指针的值则是0x3355,这两者是不一样的。于是,我们如果用Shape的指针去指向Circle对象,那么,它就会通过Circle对象当中的虚函数表指针找到Circle的虚函数表,通过Circle的虚函数表(偏移量也是一样的),和父类一样,就能够找到Circle的虚函数的函数入口地址,从而执行子类当中的虚函数,这个就是多态的原理。

函数的覆盖与隐藏

在我们还没有学习多态的时候,如果定义了父类和子类。当父类和子类出现了同名函数,那么这时就称之为函数的隐藏。

函数的覆盖是今天要讲的知识。怎么就覆盖了呢?大家请注意:

如果我们没有在子类当中定义同名的虚函数,那么在子类虚函数表当中就会写上父类的相应的那个虚函数的函数入口地址。如果,在子类当中也定义了同名的虚函数,那么在子类的虚函数表当中就会把原来的父类的虚函数的函数入口地址覆盖一下,覆盖成子类的虚函数的函数地址,那么这种情况就称之为函数的覆盖。

虚析构函数的实现原理

虚析构函数的特点是:当我们在父类当中,通过virtual修饰析构函数之后,我们通过父类的指针再去指向子类的对象,然后通过delete接父类指针就可以释放掉子类的对象。

理论前提:执行完子类的析构函数就会执行父类的析构函数。

有了这个前提,我们想一想,如果有了父类的指针,通过delete的方式去释放子类的对象,那么只要能够实现通过父类的指针执行到子类的析构函数就可以实现了。我们来看一看例子:

在这个例子当中,我们给Shape类多加了一个函数:虚析构函数。在Circle类当中,我们也定义了它自己的虚析构函数。如果你不写,计算机会默认给你定义一个虚析构函数的,前提是,必须在父类中必须有关键字virtual修饰的析构函数。如果我们在main函数当中,通过父类的指针来指向子类的对象(如下)

然后,通过delete接父类的指针来释放子类的对象,那么这个时候虚函数表如何来工作呢?我们来看一看。

如果我们在父类当中定义了虚析构函数,那么,在父类当中的虚函数表中就会有一个父类的析构函数的函数指针,而在子类的虚函数表中也会产生一个子类的析构函数的函数指针,指向的是子类的析构函数。这个时候,如果我们使用父类的指针指向子类的对象,或者说,使用Shape的指针来指向Circle的对象,那么,通过deleite来接shape这样一个指针的时候,我们就可以同shape来找到子类的虚函数表指针,然后通过虚函数表指针找到虚函数表,再通过虚函数表找到子类的析构函数,从而使得子类的析构函数得以执行。子类的析构函数执行完毕之后,系统就会自动执行父类的析构函数,这个就是虚析构函数的实现原理。接下来向大家证明一下:虚函数表指针的存在,从而证明前面给大家讲的这一套理论的真实性。

虚函数表代码实践

题目描述:

/* *****************************************************************  */

/* 证明虚函数表的存在

   要求:

       1. 定义Shape类, 成员函数:calcArea(),构造函数,析构函数

       2. 定义Circle类,成员函数:构造函数,析构函数

                        数据成员:m_iR

   概念说明:

       1. 对象的大小

       2. 对象的地址

       3. 对象成员的地址

       4. 虚函数表指针

*/

/* *****************************************************************  */

几个概念解释:

1.      什么是对象的大小呢?

指在类实例化的对象当中,它的数据成员所占据的内存大小,而不包括成员函数。比如上面的Shape类来说,它没有定义任何数据成员,那么,理论上它应该是不占据内存的,但是呢?待会看一看程序结果。对于Circle类来说,它有一个数据成员m_iR,其实int类型,理论上它应该占据4个基本内存单元,所以每次实例化一个Circle类的对象,那么这个实例化的对象的大小就应该是4.

2.      什么是对象的地址?

指通过一个类实例化一个对象,那么这个对象在内存当中就会占有一定大小的内存单元,那么这个内存单元的首地址就是这个对象的地址。

3.      什么是对象成员的地址?

指当用一个类去实例化一个对象之后,这个对象当中可能有一个或多个数据成员,那么每一个数据成员所占据的地址就是这个对象的成员地址。由于对象的每一个数据成员,因为它的数据类型不同,所以它占据的内存大小也有所不同,从而其地址也是不同的。

4.      什么是虚函数表指针?

指的是在具有虚函数的情况下,实例化一个对象的时候,这个对象的第一块内存当中所存储的是一个指针,这个指针就是函数表的指针。因为其也是一个指针,所以其占据的内存大小也应该是4。那么就通过这个特点,我们就可以通过计算对象的大小来证明虚函数表指针的存在。

程序框架:

头文件(Shape.h

#ifndef SHAPE_H
#define SHAPE_H

#include <iostream>
using namespace std;

class Shape
{
public:
    Shape();
    ~Shape();
    double calcArea();
};

#endif

源程序(Shape.cpp

#include "Shape.h"

Shape::Shape()
{
    //cout << "Shape()" << endl;
}

Shape::~Shape()
{
    //cout << "~Shape()" << endl;
}

double Shape::calcArea()
{
    cout << "Shape-->calcArea()" << endl;
    return 0; 
}

头文件(Circle.h

#ifndef CIRCLE_H
#define CIRCLE_H

#include "Shape.h"

class Circle:public Shape
{
public:
    Circle(int r);
    ~Circle();
protected:
    int m_iR;
};

#endif

源程序(Circle.cpp

#include "Circle.h"
Circle::Circle(int r)
{
    m_iR = r;
}
Circle::~Circle()
{

}

主调程序(demo.cpp

#include <iostream>
#include <stdlib.h>
#include "Circle.h"
using namespace std;

int main()
{

    Shape shape;
    cout << sizeof(shape) << endl;

    Circle circle(100);
    cout << sizeof(circle) << endl;

    system("pause");
    return 0;
}

在main函数中,首先我们先实例化了一个Shape类的对象shape,而我们注意到:在Shape.h中,Shape对象中没有任何的数据成员,那么理论上打印它的大小应该打印出的是0。那么真相是什么呢?一会揭晓!!!接着我们又实例化了一个Circle类对象circle,并且传入了参数100,这个100会传值给m_iR,然后我们打印circle这个对象的大小,理论上circle这个对象中含有一个int类型的数据成员,那么理论上circle对象的大小应该就是4。那么,我们现在来揭晓答案。

程序运行结果如下:

我们可以看到,在运行的结果当中,第一行打印出的是1,第二行打印出的是4。对于打印出的4来说,是符合我们预期的。那么,对于打印出的1如何来理解呢????我们来想一想:当Shape类没有任何数据成员的时候,而这个类也是可以实例化的,它实例化一个对象后,那么,作为一个对象来说,它必须要标明自己的存在。C++如何来完成这样的工作呢?C++对于一个数据成员都没有的情况,用1个内存单元去标定它,也就是说,这个内存单元只标定了这个对象的存在。如果这个对象里面有数据成员,那么这个1也就不存在了(比如Circle类的情况,它不会变成5,也就是说,它已经有了数据成员,能够标定它的存在了,那么就不需要额外的内存来标定它的存在了)。

       然后我们再来看其他的。首先,在main函数中定义一个指针,并且通过这个指针来指向这个对象。请大家注意,这个指针比较奇特。这个指针是指向int类型的指针,而指向的这个对象shape是一个Shape类型,那么这种情况下可以指向吗?其实,直接指向是不可以的。所以,我们必须要使用强制类型转换。即将Shape类型的一个地址转换成一个int类型的地址,即:int *p = (int *)&shape;这个是不得已,否则我们没有办法进行后续的操作。我们指向之后,就可以通过cout语句将这个地址打印出来,则打印出的地址就是shape这个对象的地址。那么main函数如下:

int main()
{

    Shape shape;
    //cout << sizeof(shape) << endl;

    int *p = (int *)&shape;
    cout << p << endl;

    Circle circle(100);
    //cout << sizeof(circle) << endl;

    system("pause");
    return 0;
}

运行结果如下:

那么这个shape对象的地址与下面的circle对象的地址是不是同一个地址呢?我们来看一看。

int main()
{
    Shape shape;
    //cout << sizeof(shape) << endl;
    int *p = (int *)&shape;
    cout << p << endl;

    Circle circle(100);
    //cout << sizeof(circle) << endl;
    int *q = (int *)&circle;
    cout << q << endl;

    system("pause");
    return 0;
}

运行结果:

我们可以看到,shape这个对象与circle这个对象在内存中所占的地址是不同的。这个是顺理成章的事,毕竟是两个不同的对象。

接下来,我们讲一讲,作为指针p此时指向的shape这个对象,那么这个对象此时只有一个标识符来表明这个对象的存在。对于circle来说,指针q指向这个对象之后,那么这个对象的第一个位置就应该放的是circle的数据成员m_iR,我们来打印一下,验证一下是不是这样的。

int main()
{
    Shape shape;
    //cout << sizeof(shape) << endl;
    int *p = (int *)&shape;
    cout << p << endl;

    Circle circle(100);
    //cout << sizeof(circle) << endl;
    int *q = (int *)&circle;
    cout << q << endl;
    cout << (unsigned int)(*q) << endl;

    system("pause");
    return 0;
}

运行结果:

 

我们看到,第三行打印出的是100。那么这个100是怎么来的呢?这个100就是在我们实例化circle的时候,传入的参数100,而且这个传入的100赋值给了circle这个对象的数据成员m_iR。这个m_iR就处在circle这个对象地址的第一个位置。我们的指针q所指向的就是m_iR,当然,它也就是circle这个对象的地址。我们把这个地址的值打印出来,那就正好打印出来了m_iR。

讲到这为止,我们都是在讲虚函数前面的内容,只是对对象的理解。下面要讲的就是与虚函数相关的了。

首先,我们修改一下Shape.h头文件,在Shape这个类中的calcArea()函数前面加上关键字virtual。大家请注意,此时Shape的析构函数还是普通的析构函数,只不过它计算面积calcArea()这个成员函数变成了虚函数。那么在这种情况下,如果去实例化一个Shape的对象,就应该具有一个虚函数表指针。也就是说,如果原来它占的是一个内存的大小,那么,当我们已经拥有一个虚函数表指针的时候,那么这个Shape就应该占有4个内存的大小(因为一个指针所占有的内存单元的4)。我们来验证一下是不是这样的??

头文件(Shape.h

#ifndef SHAPE_H
#define SHAPE_H

#include <iostream>
using namespace std;

class Shape
{
public:
    Shape();
    ~Shape();
    virtual double calcArea(); 
};

#endif

主调程序(demo.cpp

#include <iostream>
#include <stdlib.h>
#include "Circle.h"
using namespace std;

int main()
{
    Shape shape;
    cout << sizeof(shape) << endl;

    system("pause");
    return 0;
}

运行结果:

我们看到,打印出来的结果已经不是1了,而是我们期望看到的4。这就充分证明了,我们加了virtual关键字之后,在实例化Shape对象的时候,那么它实例化出来的对象当中就含有一个虚函数表指针。

接下来再来打开Shape.h文件,将计算面积的函数变为普通函数,同时在析构函数前面加上关键字virtual,使其变成虚析构函数。如下:

#ifndef SHAPE_H
#define SHAPE_H

#include <iostream>
using namespace std;

class Shape
{
public:
    Shape();
    virtual ~Shape();
    double calcArea();
};

#endif

这种情况下,我们可以看到,只有析构函数前面加了virtual关键字。那么在这种情况下,如果我们实例化一个Shape的对象,那么这个Shape的对象究竟占多少内存单元呢?是不是只有虚析构函数的情况下,作为Shape对象来说,也有一个虚函数表指针呢?我们就可以通过打印的方式来看一看shape这个对象的大小,如果结果是4,那么就说明:当我们去定义一个虚析构函数的时候,它同样会在实例化对象的时候,会产生一个虚函数表,并且在对象当中,产生一个虚函数表指针。

主调程序(demo.cpp

#include <iostream>
#include <stdlib.h>
#include "Circle.h"
using namespace std;

int main()
{
    Shape shape;
    cout << sizeof(shape) << endl;

    system("pause");
    return 0;
}

运行结果:

在运行结果当中,我们看到,打印出来的结果也是4。那么,从这一点,我们就可以充分证明:虚析构函数同样能够使类在实例化对象的时候产生一个虚函数表,并且在实例化对象当中产生一个虚函数表指针。

那么,既然虚函数表指针会存在于父类对象当中,那么它也一样会存在于子类对象当中。

头文件保持之前的不变,主调程序如下:

#include <iostream>
#include <stdlib.h>
#include "Circle.h"
using namespace std;

int main()
{
    Shape shape;
    cout << sizeof(shape) << endl;

    Circle circle(100);
    cout << sizeof(circle) << endl;


    system("pause");
    return 0;
}

运行结果:

 

通过打印,我们可以看到,第一行是4,也就是说shape的大小为4。第二行是8,这个8是怎么来的呢?其中的4个内存单元是由circle对象的数据成员m_iR所占据的(因为m_iR是int类型),另外4个正如大家所想,它就应该是虚函数表指针所占据的。因为父类当中定义了虚析构函数,这个虚析构函数能够传给子类,子类也就因为有虚析构函数的原因,在实例化的时候产生了虚函数表,并且在对象当中产生了虚函数表指针。那么,这个虚函数表指针在哪个位置呢?我们说其在对象的前4个内存单元。如何而来证明这一点呢,我们可以这样:

首先,我们使用指针p去指向shape对象,用指针q去指向circle对象。这种指向结束之后,我们就可以通过*p来打印shape对象当中的前4个基本单元的值。那么这个被打印出来的值是什么值呢?这个值就是Shape这个类的虚函数表的地址。同时,我们也可以在打印*q的值,那么*q就是指当前的指针q所指向的circle这个对象的地址。那么,作为circle这个对象来说,circle这个对象的前4个基本内存单元就是它的虚函数表指针,那么这个虚函数表指针究竟是什么值呢?我们就可以通过*q来打印出来。

主调程序(demo.cpp

#include <iostream>
#include <stdlib.h>
#include "Circle.h"
using namespace std;

int main()
{
    Shape shape;
    //cout << sizeof(shape) << endl;

    int *p = (int *)&shape;
    //cout << p << endl;
    cout << (unsigned int)(*p) << endl;

    Circle circle(100);
    //cout << sizeof(circle) << endl;

    int *q = (int *)&circle;
    //cout << q << endl;
    cout << (unsigned int)(*q) << endl;

    system("pause");
    return 0;
}

运行结果:

我们可以看到,作为shape来说,它的虚函数表的地址是13229484,对于circle来说,它的虚函数表的地址是13229204。对于circle来说,它的前4个内存单元是虚函数表的地址,那么接下来的4个内存单元就应该是m_iR的地址了。为了能够表明这一点,我们让指针q实行++操作,于是这个q指针就往后指向了一个位置。那么指向的这个位置一共跳过了4个基本内存单元,正好指向了m_iR,此时再来打印*q,应该就能打印出100这个值来了。我们来看一看:

#include <iostream>
#include <stdlib.h>
#include "Circle.h"
using namespace std;

int main()
{
    Shape shape;
    //cout << sizeof(shape) << endl;

    int *p = (int *)&shape;
    //cout << p << endl;
    cout << (unsigned int)(*p) << endl;

    Circle circle(100);
    //cout << sizeof(circle) << endl;

    int *q = (int *)&circle;
    //cout << q << endl;
    cout << (unsigned int)(*q) << endl;
    q++;
    cout << (unsigned int)(*q) << endl;
    system("pause");
    return 0;
}

    int *p = (int *)&shape;
    cout << p << endl;

    Circle circle(100);
    //cout << sizeof(circle) << endl;
    int *q = (int *)&circle;
    cout << q << endl;
    cout << (unsigned int)(*q) << endl;

运行结果:

我们可以看到,第三行打印出来的值就是100,这充分说明了,在多态的情况下,虚函数表指针在对象当中所占据的内存位置是每个对象的前4个基本内存单元,后面依次排列的才是这个对象的其他的数据成员。