C++虚函数探索笔记(1)——虚函数的简单示例分析

时间:2022-05-25 20:51:03

关注问题:

  • 虚函数的作用
  • 虚函数的实现原理
  • 虚函数表在对象布局里的位置
  • 虚函数的类的sizeof
  • 纯虚函数的作用
  • 多级继承时的虚函数表内容
  • 虚函数如何执行父类代码
  • 多继承时的虚函数表定位,以及对象布局
  • 虚析构函数的作用
  • 虚函数在QT的信号与槽中的应用
  • 虚函数与inline修饰符,static修饰符

啰嗦两句

虚函数在C++里的作用是在是非常非常的大,很多讲述C++的文章都会讲到它,要用好C++,就一定要学好虚函数。网络上可以google到很多很多关于它的文章,这一次的学习,我不准备去只是简单的阅读了解那些文章,而是希望通过编写一些测试代码,来对虚函数的一些实现机制,以及C++对象布局做一下探索。

虚函数的简单示例 !

虚函数常常出现在一些抽象接口类定义里,当然,还有一个更常见的“特例”,那就是虚析构函数,后面会提到这个。

下面是一段关于虚函数的简单代码,演示了使用基类接口操作对象时的效果:

//Source filename: Win32Con.cpp
#include <iostream>
using namespace std;
class parent1
{
public:
virtual int fun1()=0;
};

class child1:public parent1
{
public:
virtual int fun1()
{
cout<<"child1::fun1()"<<endl;
return 0;
}
};

class child2:public parent1
{
public:
virtual int fun1()
{
cout<<"child2::fun1()"<<endl;
return 0;
}
};

void test_func1(parent1 *pp)
{
pp->fun1();
}

int main(int argc, char* argv[])
{
child1 co1;
child2 co2;
test_func1(&co1);
test_func1(&co2);
return 0;
}

在上面的代码里,类parent1是一个只具有纯虚函数的接口类,这个类不能被实例化,它唯一的用途就是抽象一些特定的接口函数,当然,在这里这个接口函数就是纯虚函数 parent1::fun1()。

而类child1和child2则是两个从parent1继承的类,我们要使用它定义具体的类实例,所以它实现了由parent1继承得来的fun1接口,并且各自的实现是不同的。

函数 test_func1 的参数是一个parent1类型的指针,它所要完成的功能就是调用这个parent1对象的fun1()函数。

让我们编译运行一下上面的代码,可以看到下面的输出

child1::fun1()

child2::fun1()

很显然,在两次调用test_func1函数的时候,虽然传入的参数都是一个parent1的指针,但是却都分别执行了child1和child2 各自的fun1函数!这就是C++里类的多态。然而,这一切是怎么发生的呢?test_func1函数怎么会知道应该调用哪个函数的呢?我不准备像其他人一样画若干图来说明,我准备用具体某个编译器产生的对象布局以及相应的汇编代码来说明这个过程(这个编译器是vs2008里的vc9)。

我们先打开一个VS2008命令提示窗口,改变目录到上面的代码Win32Con.cpp所在目录,输入下面的命令:

cl  win32con.cpp  /d1reportSingleClassLayoutchild

上面的命令可以编译win32con.cpp源码,同时生成里面类名包含child 的类的对象布局(layout)

注意:d1reportSingleClassLayout和后面的child是相连的!

输入上面的命令后看到的对象布局如下,红色字为我添加的注释

class child1    size(4): 子类child1的对象布局,只包含一个vfptr,大小为4字节
+---
| +--- (base class parent1) 这是被嵌套的父类parent1的对象布局
0 | | {vfptr}
| +---
+---
这是child1的vfptr所指的虚函数表的布局,只包含一个函数的地址,就是child1的fun1函数
child1::$vftable@:
| &child1_meta
| 0
0 | &child1::fun1

child1::fun1 this adjustor: 0

class child2 size(4): 子类child2的对象布局,只包含一个vfptr,大小为4字节
+---
| +--- (base class parent1) 这是被嵌套的父类parent1的对象布局
0 | | {vfptr}
| +---
+---
这是child2的vfptr所指的虚函数表的布局,只包含一个函数的地址,就是child2的fun1函数
child2::$vftable@:
| &child2_meta
| 0
0 | &child2::fun1

child2::fun1 this adjustor: 0

从上面的对象布局可以知道:

  • 每个子对象都有一个隐藏的成员变量vfptr(你当然不能用这个名字访问到它),它的值是指向该子对象的虚函数表,而虚函数表里填写的函数地址是该子对象的fun1函数地址。
  • 对一个包含有虚函数的类做sizeof操作的时候,除了能直接看到的成员变量,还得增加4字节(在32位机器上),就是vfptr这个指针的大小。

所以当test_func1进行pp->fun1()调用的时候,会首先取出pp所指的内存地址并按照parent1的内存布局,获取到 vfptr指针(由于pp在两次调用中分别指向co1和co2所以这里取得的实际上是co1的vfptr和co2的vfptr),然后从vfptr所指的虚函数表第一项(现在也只有 1 项)取出作为将要调用的函数,由于co1和co2在各自的虚函数表里填写了各自的fun1的地址,于是pp->fun1()最终就调用到了co1和 co2各自的fun1,输出自然也就不同了。

让我们看看test_func1的反汇编代码:

void test_func1(parent1 *pp)
{
001C1530 push ebp
001C1531 mov ebp,esp
001C1533 sub esp,0C0h
001C1539 push ebx
001C153A push esi
001C153B push edi
001C153C lea edi,[ebp-0C0h]
001C1542 mov ecx,30h
001C1547 mov eax,0CCCCCCCCh
001C154C rep stos dword ptr es:[edi]
pp->fun1();
001C154E mov eax,dword ptr [pp] //取得pp的值放到eax,即对象的地址
//取得对象的vfptr地址放到edx(因为vfptr在对象布局里拍在第一)
001C1551 mov edx,dword ptr [eax]
001C1553 mov esi,esp
001C1555 mov ecx,dword ptr [pp]
001C1558 mov eax,dword ptr [edx] //取出vfptr的第一个虚函数的地址到eax
001C155A call eax //调用虚函数,即fun1()

至此,应该比较清楚虚函数机制的基本实现了。然而,也许你还会有这些问题:

  • 虚函数表是每个子对象都有的么?
  • 虚函数是存在一个表里的,表的数据结构是怎样的,如何定位表里哪个才是我们要调用的虚函数?

略作变化

让我们对前面的代码做以下修改:

  • 定义一个普通类
  • 修改parent类,在fun1前增加虚函数fun2
  • 在child1里和child2里编写fun2的具体实现,一个在fun1之前编写,另外一个在之后编写

修改后的编码大致如下:

class parent1
{
public:
virtual int fun2()=0;
virtual int fun1()=0;
};

class child
{
int a;
};

class child1:public parent1
{
public:

virtual int fun1()
{
cout<<"child1::fun1()"<<endl;
return 0;
}
virtual int fun2()
{
cout<<"child1::fun2()"<<endl;
return 0;
}
};

然后我们再使用cl命令以及/d1reportSingleClassLayout选项输出相关的类对象布局情况:

class child     size(4):  //在普通类child里,看不到vfptr的身影!
+---
0 | a
+---

class child1 size(4): //child1的对象布局,和之前没有变化!
+---
| +--- (base class parent1)
0 | | {vfptr}
| +---
+---
//child1的虚函数表多了fun2,并且两个虚函数在表里的顺序相同于在parent类里声明的顺序
child1::$vftable@:
| &child1_meta
| 0
0 | &child1::fun2
1 | &child1::fun1

child1::fun1 this adjustor: 0
child1::fun2 this adjustor: 0

结论很明显:

  • 虚函数表指针vfptr只在类里有虚拟函数的时候才会存在
  • 当有多个虚函数的时候,虚函数在虚函数表里的顺序由父类里虚函数的定义顺序决定

并且我们还可以观察到:

  • 这个vfptr指针会放在类的起始处(这是必须的,vfptr在父类和子类的对象布局上必须一致!)
  • 虚函数表是以一个NULL指针标识结束

让我们对这次简单的示例代码测试来做个小小总结:

  • 有虚函数的类,一定会有一个虚函数表指针vfptr
  • 这个vfptr指针会放在类的起始处
  • 虚函数表里会按基类声明虚函数的顺序在vfptr里存放函数地址
  • 虚函数表里存放的是函数地址是具体子类的实现函数的地址
  • 调用虚函数的时候,是从vfptr所指的函数表里获取到函数地址,然后才调用具体的代码