引言

最近把《深度探索c++对象模型》读了几遍,收获甚大。明白了很多以前知其然却不知其所以然的姿势。比如构造函数与拷贝构造函数什么时候被编译器合成,虚函数、实例函数、类函数的区别等等。在此,我根据书本上的描述,结合VS2012的C++编译器,来验证其内容的正确性。让我们一起以指针寻址、虚函数表等理论作为依据,以汇编代码来实证,探索C++多态的实现。

  1. #include <iostream>

  1. using namespace std;

  1.  

  1.  

  1. class Base

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Base::Test()" << endl;

  1. }

  1. };

  1.  

  1. class Derived : public Base

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Derived::Test()" << endl;

  1. }

  1. };

  1.  

  1. int main()

  1. {

  1. Derived d;

  1. Base *b = &d;

  1.  

  1. // 在这里,大家都知道输出的是Derived::Test(),因为多态。

  1. // 但是它到底是如何实现的呢?

  1. b->Test();

  1. }

谈到多态,就不得不聊聊虚函数表了,有关虚函数表的内存布局请点击这。但是虚函数表是怎么创建的?又是怎么使用的?虚函数表里都是函数的指针,怎么找到欲对应的函数指针?下面,我们从最基本的指针寻址开始,一步步解答这些问题。

指针类型的作用

当我们写定义一个指针时,其对应的类型大小,便是其指向的内存范围的大小,例如int *pi, pi指向的是一块sizoef(int)大小的内存,char *pc;则指向的是一块sizeof(char)大小的内存。为什么要知道其指向的内存大小?举个例子,如下图:

如果ptr是一个char*指针,那么此时*ptr的值是0x78,因为它指向的类型是一个char,所以其内存范围是sizeof(char),即1。如果是ptr是short*类型的则*ptr的值为0x5678。我们来写代码验证一下:

  1. #include <iostream>

  1. #include <limits>

  1. using namespace std;

  1.  

  1.  

  1. class Base

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Base::Test()" << endl;

  1. }

  1. };

  1.  

  1. class Derived : public Base

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Derived::Test()" << endl;

  1. }

  1. };

  1.  

  1.  

  1. int main()

  1. {

  1. // Derived d;

  1. // Base *b = &d;

  1. //

  1. // // 在这里,大家都知道输出的是Derived::Test(),因为多态。

  1. // // 但是它到底是如何实现的呢?

  1. // b->Test();

  1. int i = 0x12345678;

  1.  

  1. char *pc = (char *)&i;

  1. cout << hex << "pc = 0x" << (int)*pc << endl;

  1.  

  1. short *ps = (short *)&i;

  1. cout << hex << "ps = 0x" << (int)*ps << endl;

  1. }

运行时,可以观察两个指针指向的地址,及其指向地址处的内存数据:

可以看到,两个指针都指向i的地址0x0038fed8处,该地址处依次存放着78 56 34 12。我们来看看输出:

果然如此。现在,我们来看看生成的汇编代码是怎么样的:

汇编代码忠实的反应出了指针类型的信息。现在,我们得到一个结论:要通过一个内存地址去取值,必须知道1、内存地址是多少;2、欲取得的值占用多少字节内存。现在我们来测试一下自定义的class类型,看看其指针是如何工作的,代码如下:

  1. #include <iostream>

  1. #include <limits>

  1. using namespace std;

  1.  

  1.  

  1. class Base

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Base::Test()" << endl;

  1. }

  1. };

  1.  

  1. class Derived : public Base

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Derived::Test()" << endl;

  1. }

  1. };

  1.  

  1. class Test

  1. {

  1. public:

  1. int a;

  1. int b;

  1. int c;

  1. int d;

  1. };

  1.  

  1. int main()

  1. {

  1. // Derived d;

  1. // Base *b = &d;

  1. //

  1. // // 在这里,大家都知道输出的是Derived::Test(),因为多态。

  1. // // 但是它到底是如何实现的呢?

  1. // b->Test();

  1. // int i = 0x12345678;

  1. //

  1. // char *pc = (char *)&i;

  1. // cout << hex << "pc = 0x" << (int)*pc << endl;

  1. //

  1. // short *ps = (short *)&i;

  1. // cout << hex << "ps = 0x" << (int)*ps << endl;

  1.  

  1. // 首先,初始化一个Test指针, 然后将其赋值给一个local Test object t。

  1.  

  1. Test *pt = new Test;

  1.  

  1. Test t = *pt;

  1.  

  1.  

  1. }

Test类中,仅包含了四个int,此时sizeof(Test) == sizoef(int) * 4。注意,在此处,我们没有为其定义构造函数以及copy构造函数。而且因为类中没有virtual函数也没有有构造函数的数据成员,编译器也不会为这个类合成构造函数与copy构造函数。在上述最后一行代码中,通过*pt取值,应该会有 4个类似mov dword ptr的操作。我们来看看生成的汇编代码:

我们可以看到,其在赋值时,确实产生了4个 mov dword ptr的操作,说明其寻址的大小是sizeof(Test)(即4 * sizeof(int))。而且在其初始化的时候,除了new操作有一个call,没有其他任何call,说明确实没有所谓的编译器合成的缺省构造函数被调用。这种仅包含内置数据类型的类比较简单,下面让我们一起探索一下比较复杂的。

有虚函数成员的类——虚函数表的构造

我们为Test类加上一个虚函数:

然后查看生成的汇编代码,比较其与没有虚函数时,有什么不同:

通过生成的汇编代码,我们可以清楚的看到,类的大小增大了四个字节,并且编译器为我们合成了一个构造函数。赋值的时候,不再是挨个mov dword ptr了, 而是调用copy构造函数。为什么会这样?很简单,因为编译器要为我们构造虚函数表,虚函数表的初始化,就是在编译器为我们生成的构造函数里进行的。进而可以理解,为什么会合成copy构造函数了,如果不合成,那么赋值时,虚函数表岂不是无法赋值!我们跟进构造函数,看看编译器为我们生成的构造函数里有哪些代码:

同理,跟进copy构造函数:

啊哈,该有的一个都没少。

虚函数表的使用——多态的实现

回到本篇开头的代码,我们一起看一下d的地址,以及存放在指针b中的地址:

我们先不看生成的汇编代码,先来猜想一下调用的过程:将d的地址赋给b后,他们的寻址大小改变了,&d的寻址大小是sizeof(Derived),而b的寻址大小是sizeof(Base),(在此处,由于两者都是仅含一个虚函数,所以两者一样大。)。但是两个对象的内存布局有一部分是一样的,两个类的虚函数表的地址都存放在对象的首位,即对象的地址。所以理论上来说,d是可以寻址到该函数的。下面,我们看看汇编代码是如何写的:

虚函数地址,是个2级地址。对象地址首四位存放的是虚函数表的地址,虚函数表的第一个slot(即首个地址)存放的是第一个虚函数的地址,依次类推。详细参考代码请点击这。由于这里是单继承,所以类结构比较简单,类指针转换时,可以直接看做:基类对象的首地址等于派生类对象的首地址。下面我们来看一下稍微复杂一点的多继承情况,并且将派生类的对象地址赋值给第二个基类指针。代码如下:

  1. #include <iostream>

  1. #include <limits>

  1. using namespace std;

  1.  

  1.  

  1. class Base

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Base::Test()" << endl;

  1. }

  1. };

  1.  

  1. class AnotherBase

  1. {

  1. public:

  1. virtual void AnotherTest()

  1. {

  1. cout << "AnotherBase::AnotherTest()" << endl;

  1. }

  1. };

  1.  

  1. class Derived : public Base, public AnotherBase

  1. {

  1. public:

  1. virtual void Test()

  1. {

  1. cout << "Derived::Test()" << endl;

  1. }

  1.  

  1. virtual void AnotherTest()

  1. {

  1. cout << "Derived::AnotherTest()" << endl;

  1. }

  1. };

  1.  

  1. class Test

  1. {

  1. public:

  1. virtual void Print()

  1. {

  1. cout << "Test::Print" << endl;

  1. }

  1. int a;

  1. int b;

  1. int c;

  1. int d;

  1. };

  1.  

  1. int main()

  1. {

  1. // Derived d;

  1. // Base *b = &d;

  1. //

  1. // // 在这里,大家都知道输出的是Derived::Test(),因为多态。

  1. // // 但是它到底是如何实现的呢?

  1. // b->Test();

  1.  

  1. Derived d;

  1. AnotherBase *pAB = &d;

  1.  

  1.  

  1. // int i = 0x12345678;

  1. //

  1. // char *pc = (char *)&i;

  1. // cout << hex << "pc = 0x" << (int)*pc << endl;

  1. //

  1. // short *ps = (short *)&i;

  1. // cout << hex << "ps = 0x" << (int)*ps << endl;

  1.  

  1. // 首先,初始化一个Test指针, 然后将其赋值给一个local Test object t。

  1.  

  1. // Test *pt = new Test;

  1. //

  1. // Test t = *pt;

  1.  

  1.  

  1. }

我们再来看看他们的地址:

这是神马情况?我们明明把d的地址赋给了pAB呀!!!这不科学。我们一起去汇编代码里找原因去:

基本得到的信息与上面地址值对应:pAB中存的地址比d对象的地址大4个字节,然后以pAB中存的地址作为指针,辗转两次调用虚函数。很显然,这个+4的操作是编译器帮我们生成的,那么为什么要+4呢?我们来看一下派生类的对象内存布局:

这个对象有两个虚函数表,画出来应该是这样:

作为对比,我们来看下Derived对象自己来调用虚函数时,是个什么情况:

以Derived对象的指针来调用虚函数时,是个什么情况:

在这里,我们看到派生类对象无论是通过指针还是直接调用虚函数,其this参数,都会+4,即派生类包含该基类的内存布局的偏移。看来,调用虚函数时,总是以基类的“身份”去调用的。

现在,我们知道是如何调用到虚函数的了。但是还有个疑问:如果在派生类Derived中定义一个数据成员,并在虚函数中使用。那么基类的指针,如何寻址到该地址呢?毕竟寻址范围是根据指针类型来确定的。让我们一起去探索这最后的疑问:

分析图如下:

搞到最后,才发现调用虚函数的,其实一直是基类的指针。只是在对应的派生类虚函数实现里,修正了基类指针相对于派生类对象首地址的偏移。

总结

要实现多态,需要这么多的工具配合:虚表机制;派生类对象赋值给基类对象时,编译器添加指针相对偏移代码;虚函数内,编译器生成相应的偏移指针调用派生类数据成员的代码。多态,用起来轻快方便,实现起来难呀!

发现了个VS的bug,如果在调用虚函数时,this指针的值会在保存寄存器值后,无缘无故改变。变成对象的地址。其实是个假的值,查看内存即可知道。有可能是为了在调试时对用户透明故意为之,以免普通用户奇怪:居然this指针的值与对象值不同,囧…Orz

探索VS中C++多态实现原理的更多相关文章

  1. java中实现多态的机制是什么?

    多态性是面向对象程序设计代码重用的一个重要机制,我们曾不只一次的提到Java多态性.在Java运行时多态性:继承和接口的实现一文中,我们曾详细介绍了Java实现运行时多态性的动态方法调度:今天我们再次 ...

  2. c++中虚多态的实现机制

    c++中虚多态的实现机制 參考博客:http://blog.csdn.net/neiloid/article/details/6934135 序言 证明vptr指针存在 无继承 单继承无覆盖 单继承有 ...

  3. C 语言实现多态的原理:函数指针

    C语言实现多态的原理:函数指针 何为函数指针?答案:C Programming Language. 能够查阅下,从原理上来讲,就是一个内存地址.跳过去运行相应的代码段. 既然如此,在运行时决定跳到哪个 ...

  4. C++ 类的多态三(多态的原理--虚函数指针--子类虚函数指针初始化)

    //多态的原理--虚函数指针--子类虚函数指针初始化 #include<iostream> using namespace std; /* 多态的实现原理(有自己猜想部分) 基础知识: 类 ...

  5. C++基础 (7) 第七天 多态的原理 纯虚函数和抽象类 依赖倒置原则

    1 昨日回顾 2 多态的原理 1 要有继承 2 要有子类重写父类的虚函数 3 父类指针(或者引用)指向子类对象 (动态联编 虚函数表 3 证明vptr指针的存在 4 vptr指针在构造父类的时候是分步 ...

  6. c++中的多态机制

    目录 1  背景介绍 2  多态介绍 2-1  什么是多态 2-2  多态的分类 2-3  动态多态成立的条件 2-4  静态联编和动态联编 2-5  动态多态的实现原理    2-6   虚析构函数 ...

  7. Java 中泛型的实现原理

    泛型是 Java 开发中常用的技术,了解泛型的几种形式和实现泛型的基本原理,有助于写出更优质的代码.本文总结了 Java 泛型的三种形式以及泛型实现原理. 泛型 泛型的本质是对类型进行参数化,在代码逻 ...

  8. 聊聊 C# 中的多态底层 (虚方法调用) 是怎么玩的

    最近在看 C++ 的虚方法调用实现原理,大概就是说在 class 的首位置存放着一个指向 vtable array 指针数组 的指针,而 vtable array 中的每一个指针元素指向的就是各自的 ...

  9. jquery中on/delegate的原理

    jquery中on/delegate的原理 早期版本中叫delegate, 后来有过live函数,再后来统一用on.下面的方法等效: // jQuery 1.3 $(selector).(events ...

随机推荐

  1. c# 计算两日期的工作时间间隔(排除非工作日)及计算下一个工作时间点.

    一个日期段如工作时间为 8:00 至 17:00 public class TimeHelper { /// <summary> /// 计算时间间隔 /// </summary&g ...

  2. javascript typeof 和 constructor比较

    转自:http://www.cnblogs.com/hacker84/archive/2009/04/22/1441500.html http://www.cnblogs.com/siceblue/a ...

  3. A Multipart Series on Grids in ASP.NET MVC

    A Multipart Series on Grids in ASP.NET MVC Displaying a grid of data is one of the most common tasks ...

  4. Android中“再按一次返回键退出程序”实现

    private long exitTime = 0; @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if(keyC ...

  5. windws 安装jdk

    (1) 到官网下载好jdk:http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html (2 ...

  6. 【转载】经典漫画讲解HDFS原理

    分布式文件系统比较出名的有HDFS  和 GFS,其中HDFS比较简单一点.本文是一篇描述非常简洁易懂的漫画形式讲解HDFS的原理.比一般PPT要通俗易懂很多.不难得的学习资料. 1.三个部分: 客户 ...

  7. 一:Html基本结构

    1:什么是Html(HTML 概念)? Html是 HyperText mark-up Language 的缩写,意思是:超文本标记语言 2.HTML的发展史? 1991年:出现Html1.0(不存在 ...

  8. POJ 3660 Cow Contest (闭包传递)

    Cow Contest Time Limit: 1000MS   Memory Limit: 65536K Total Submissions: 7690   Accepted: 4288 Descr ...

  9. DWZ框架学习

    转自(http://blog.sina.com.cn/s/blog_667ac0360102ec0q.html) 初始化配置文件 $(function(){ DWZ.init("dwz.fr ...

  10. Linux apache日志分析常用命令汇总

    1.查看当天有多少个IP访问: awk '{print $1}' log_file|sort|uniq|wc –l 2.查看某一个页面被访问的次数: grep "/index.php&quo ...