本文作者:sodme
本文出处:http://blog.csdn.net/sodme

C++要实现虚函数, 归纳起来, 其实只用干两件事: 
1. 根据派生和继承关系, 生成虚函数表; 
2. 将代码中对虚函数的调用, 转化成对虚函数表中各虚函数指针的间接调用. 
 
虽然在上文中的那个小例子中, 我们通过反汇编出来的asm弄明白了以下事实: 
1. 每个"类"所拥有的虚函数表的个数: 每个类都会对应"唯一的一份"虚函数表; 
2. "对象"中虚函数表指针的保存位置: 在调用对象的构造函数时, 会把虚函数表指针放到this处(即: 对象首地址处), 所以属于同一个类的每个对象的this地址所指向的单元处存放的是同一个值; 
3. 虚函数的调用转化: 编译器编译c++代码时, 根据类的相关信息, 判断此函数是不是虚函数, 如果是, 则从虚函数表中取出真正的虚函数地址, 生成真正的调用语句; 
 
那么, 现在看来, 问题的核心就变成了: 这个统领全局的虚函数表到底是如何产生? 在生成虚函数表的过程中, 遵守了哪些规则呢? 带着这些疑问, 我们将开始今天的探索.  
 
本文所需代码可从以下地址获得( 此地址含有单继承c++和asm代码 ):
http://sodme.dev.googlepages.com/kyj_04_code.txt 
 
为了研究的方便, 此次对代码结构进行了较大规模的调整, 主要是: 
1. 去除了对输入输出流的引用, 只保存逻辑代码, 不再include任何多余代码; 
2. 声明了两个基类, 它们在单继承和多继承方式下会被拿来作不同的整合: 在单继承下, Base2Class派生于Base1Class, 而MyClass类派生于Base2Class类; 而在多继承下, 两个基类相互之间不再有派生关系, 它们会被MyClass一个类多继承. 
 
先看单继承的情况. 三个类的派生关系如下: 
Base1Class -> Base2Class -> MyClass, 其派生规则为: 多层单继承. 
 
其中,  
 
Base1Class的虚函数表为:

    .long    _ZN10Base1Class14virtual_test_1Ev            ;Base1Class::virtual_test_1()
    .long    _ZN10Base1Class14virtual_test_3Ev            ;Base1Class::virtual_test_3() 

Base2Class的虚函数表为:

    .long    _ZN10Base1Class14virtual_test_1Ev            ;Base1Class::virtual_test_1()
    .long    _ZN10Base2Class14virtual_test_3Ev            ;Base2Class::virtual_test_3()
    .long    _ZN10Base2Class14virtual_test_2Ev            ;Base2Class::virtual_test_2() 

MyClass的虚函数表为:

    .long    _ZN7MyClass14virtual_test_1Ev                ;MyClass::virtual_test_1()
    .long    _ZN10Base2Class14virtual_test_3Ev            ;Base2Class::virtual_test_3()
    .long    _ZN10Base2Class14virtual_test_2Ev            ;Base2Class::virtual_test_2()
    .long    _ZN7MyClass15virtual_test_myEv                ;MyClass::virtual_test_my() 

由这三个虚函数表, 我们发现了以下的规律: 
 
1. 派生类会继承基类的所有非同名虚函数, 存放顺序同基类; 
如: 在Base2Class中, virtual_test_1() 是 Base1Class的虚函数, 且在Base2Class中没有定义同名函数, 它的顺序是1, 因为在基类Base1Class中, 它的顺序就是1; 
 
2. 当派生类与基类有同名虚函数时, 派生类的虚函数表中存放派生类的虚函数, 存放顺序与基类同名虚函数的存放位置相同; 
如: 在Base2Class中, virtual_test_3() 在 Base1Class和Base2Class中都有, 那么在Base2Class的虚函数表中, 就只会存放Base2Class自己的虚函数, 但存放的顺序, 与它的基类Base2Class的存放顺序相同! 而"存放顺序必须相同", 这一点, 是C++实现动态重载的重要方法! 这一点, 后面分析构造函数运行流程时会说到. 
 
3. 存放完所有与基类同名的虚函数 以及 基类有但派生类没有的所有虚函数后, 开始存放派生类的其它虚函数. 
如: 在Base2Class的virtual_test_2() 和 MyClass的virtual_test_my(), 这两个函数, 都是派生类中存在而基类中不存在的, 它们存放的顺序都在上述两类虚函数的后面. 
 
以上, 是关于单继承情况下虚函数表的静态结构分析. 那么, 我们来看看实际运行时, 构造函数又是如何利用这些数据将对象进行初始化的. 
 
MyClass构造函数的主要语句:

    movl    8(%ebp), %eax               ;申请的空间首地址, 即this指针
    movl    %eax, (%esp)
    call    _ZN10Base2ClassC2Ev         ;调用Base2Class的构造函数
    movl    $_ZTV7MyClass+8, %edx       ;将MyClass的虚函数表地址放在edx中, 以便放在this处
    movl    8(%ebp), %eax
    movl    %edx, (%eax)                ;将虚表地址放在this处
    movl    8(%ebp), %eax
    movl    $1, 12(%eax)                ;[this+12] = 1 <==> data1 = 1
    movl    8(%ebp), %eax
    movl    $2, 16(%eax)                ;[this+16] = 2 <==> data2 = 2 

可以看到, 在MyClass的构造函数中, 首先是调用了Base2Class的构造函数: __ZN10Base2ClassC2Ev. 
 
Base2Class的构造函数:

    movl    8(%ebp), %eax                ;this指针
    movl    %eax, (%esp)
    call    _ZN10Base1ClassC2Ev          ;调用Base1Class的构造函数
    movl    $_ZTV10Base2Class+8, %edx    ;将Base2Class虚表地址放在this处
    movl    8(%ebp), %eax
    movl    %edx, (%eax)
    movl    8(%ebp), %eax
    movl    $44, 8(%eax)                 ;[this+8] = 44  <==>  base_2_data = 44  

而在Base2Class中, 又调用了Base1Class的构造函数:

    movl    $_ZTV10Base1Class+8, %eax   ;将Base1Class虚表地址放在this处
    movl    8(%ebp), %edx
    movl    %eax, (%edx)
    movl    8(%ebp), %eax
    movl    $33, 4(%eax)                ;[this+4] = 33  <==>  base_1_data = 33 

从这三个构造函数的执行过程来看, 在Base1Class的构造函数中, 将Base1Class的虚函数表地址传到了this地址处, 在Base2Class的构造函数中, 将Base2Class的虚函数表地址也传到了this地址处, 同样的, 在MyClass的构造函数中, 把MyClass的虚函数表地址还是放在this地址处. 换句话说, 针对于同一个this地址处的这个虚函数表地址的赋值, C++竟然重复作了三次! 而最后一次的赋值, 才是我们真正需要的结果! 没错, C++在这里, 确实是作了两次"无用功". 我在想, 有没有办法避免这样的重复赋值呢? 有一种方法: 在调用构造函数时, 传个参数进来, 表示是不是派生类调用它的, 由个参数来决定是不是将虚函数表地址拿到this, 但是, 仔细算一下: "传递参数+判断" 这样的逻辑, 似乎还没有不管三七二十一直接赋值来得更为简单一些, 所以, 这样看来, C++选择这样作, 也是有充分理由的. 
 
前面反复提到过虚函数的存放顺序. 那么, 这个顺序, 与C++的动态重载到底有多大关系呢? 我们先看这条语句: 
pMyClass->virtual_test_2(); 
 
C++在分析这条语句时, 首先会判断pMyClass是哪种类型, 此例中是MyClass, 然后判断它调用的函数是不是虚函数, 如果是虚函数, 则将调用形式转化为针对于虚函数表的间接调用. 
 
而前面提到, 派生类中存在且在基类中也存在的虚函数, 在派生类虚函数表中的位置与基类位置相同. 所以, 不管是MyClass还是Base2Class的对象, 它们调用virtual_test_2()的汇编代码都是相同的: 
 
pBaseClass->virtual_test_2():

    movl    -12(%ebp), %eax     ; -12(%ebp)表示局部变量pBaseClass
    movl    (%eax), %eax        ; 取虚表
    addl    $8, %eax            ; 取virtual_test_2()存放地址
    movl    (%eax), %edx        ; 取virtual_test_2()地址
    movl    -12(%ebp), %eax
    movl    %eax, (%esp)        ; 将this指针放栈顶传递
    call    *%edx 

pMyClass->virtual_test_2():

    movl    -16(%ebp), %eax        ; -16(%ebp)表示局部变量pMyClass
    movl    (%eax), %eax
    addl    $8, %eax
    movl    (%eax), %edx
    movl    -16(%ebp), %eax
    movl    %eax, (%esp)
    call    *%edx 

经过比对, 除了取this指针的局部变量地址不同, 其余代码均相同, 取虚函数地址时, 都是在this指针的基础上加8. 之所以可以作到这样, 就是因为有这个约定: 针对于同名函数, 它们在虚函数表中的位置相同. 当然, 这个约定并不一定要所有的编译器都来遵守, 针对于同一个编译器而言, 有一套约定就行了, 关键是要有约定, 而具体如何约定倒各有各的作法. 
 
除此之外, 我们还发现, 在Base2Class类的virtual_test_2()函数中, 对于类Base2Class 数据成员base_2_data的访问, 已经被修正为this+8. 这是因为, 经过一系列的派生后, pMyClass对象结构变成如下的方式: 
 
            |                             | 
            |-----------------------------| 
this ->     |    类MyClass虚函数表地址    |        0 
            |-----------------------------| 
            | Base1Class::base_1_data     |        +4 
            |-----------------------------| 
            | Base2Class::base_2_data     |        +8 
            |-----------------------------| 
            |     MyClass::data1          |        +12 
            |-----------------------------| 
            |     MyClass::data2          |        +16 
            |-----------------------------| 
            |                             |
 
其规则是: this地址处存放最上层类的虚函数表地址, 然后向下依次存放各基类的数据成员, 其顺序按类中的声明顺序排列. 需要指出的是, 这里存放的数据, 不仅包括public的数据, 也同时包括private的数据. 道理很简单: 在调用某层类的函数时, 该函数中仍有可能要引用到私有数据, 尽管此私有数据不能被外部函数访问. 所以, 从这个层面上来说, 私有数据仅仅是C++自己的"君子"约定, 我们完全使用比较流氓的方法来访问它. 比如我们在类Base1Class增加一private数据, 修改为如下形式:

class Base1Class
{
public:
    Base1Class(){ base_1_data = 33; };
    ~Base1Class(){};
    int base_1_data;
    virtual void virtual_test_1(){ base_1_data = 10; };
    virtual void virtual_test_3(){ base_1_data = 11; };
private:
    int pri_base_1_data;
}; 

pri_base_1_data会被放在base_1_data和base_2_data之间. 如果要访问pri_base_1_data, 在取得this指针后, 我们就可以通过this+8来访问, 具体的c++代码可以是这样: 
*( (char*)pMyClass + 8 ) = (int)3333; 
 
那么, C++如何区别这两条语句呢?  
 
pBase2Class->virtual_test_2():

    movl    -12(%ebp), %eax
    movl    (%eax), %eax
    addl    $8, %eax
    movl    (%eax), %edx
    movl    -12(%ebp), %eax
    movl    %eax, (%esp)
    call    *%edx 

((MyClass*)pBase2Class)->virtual_test_2():

    movl    -12(%ebp), %eax
    movl    (%eax), %eax
    addl    $8, %eax
    movl    (%eax), %edx
    movl    -12(%ebp), %eax
    movl    %eax, (%esp)
    call    *%edx 

pMyClass->virtual_test_2():

    movl    -16(%ebp), %eax
    movl    (%eax), %eax
    addl    $8, %eax
    movl    (%eax), %edx
    movl    -16(%ebp), %eax
    movl    %eax, (%esp)
    call    *%edx 

((Base2Class*)pMyClass)->virtual_test_2():

    movl    -16(%ebp), %eax
    movl    (%eax), %eax
    addl    $8, %eax
    movl    (%eax), %edx
    movl    -16(%ebp), %eax
    movl    %eax, (%esp)
    call    *%edx 

比较完这四条语句的代码之后, 我们发现: 不管作不作类型转换, 其编译出的汇编代码全是一样的!  其实, 前面的类型转换只是后面是否可以调用某函数的类型约定, 至于此函数的具体执行行为, 则完全看对象本身的虚函数表是哪份以及this指针指向的数据是什么而定了, this指针指向的虚函数表决定了函数的最终行为归属, 而此表地址在对象初始化时被放在了对象首地址处, 这样想来, 好像前后的知识可以贯通了: 
对于一个pBase2Class而言, 我如何知道形如这样的调用: 
pBase2Class->virtual_test_2();  
它调用的到底是类Base2Class的, 还是MyClass的? 一切答案皆在虚函数表中. 对象与"某个类的虚函数表"绑定, 而virtual_test_2()则与"虚函数表地址+8"绑定, 要想知道调用的是哪个, 就看此时的this到底是哪个类的this.   
 
也就是说, 在对virtual_test_2()此函数的调用上, 不管是Base2Class类的对象还是MyClass类的对象, 其调用的汇编代码完全一样, 唯一可以让它们有不同执行行为的, 就是"addl $8, &eax"后取出的虚函数地址. 比如: 
pBase2Class = new Base2Class; 则: 
((MyClass*)pBase2Class)->virtual_test_my() 必定是非法的, 它会被转化成以下语句:

    movl    -12(%ebp), %eax
    movl    (%eax), %eax
    addl    $12, %eax      ;根据类型为MyClass及函数为virtual_test_my()转化为"虚函数表地址+12"
    movl    (%eax), %edx
    movl    -12(%ebp), %eax
    movl    %eax, (%esp)
    call    *%edx 

由此, 可以看出, 编译器会根据当前对象的类型, 决定如何编译virtual_test_my(), 这里的"类型", 指的是"调用时类型", 而不是简单的指"定义时类型", 因为调用时, 可能会进行强制类型转换. 这里, 尽管virtual_test_my()函数可以编译通过, 但通过"this->虚函数表+12"找出来的这个地址, 根据不是正常的函数地址, 此时this指向实际对象所拥有的虚函数表中最多只有2个虚函数(+8).

C++,继承、虚函数解惑!相关推荐

  1. 【足迹C++primer】52、,转换和继承虚函数

    转换和继承,虚函数 Understanding conversions between base and derived classes is essential to understanding h ...

  2. 继承虚函数单层需继承的内存图(VC6.0)

    上班之余抽点时间出来写写博文,希望对新接触的朋友有帮助.今天在这里和大家一起学习一下继承虚函数 继承关系图 class A {virtual aa(){}; };class B : public vi ...

  3. 虚函数 虚继承 抽象类

    虚函数.纯虚函数.虚基类.抽象类.虚函数继承.虚继承 虚函数:虚函数是C++中用于实现多态(polymorphism)的机制.核心理念就是通过基类访问派生类定义的函数.是C++中多态性的一个重要体现, ...

  4. 虚函数继承与虚函数表-汇编码分析

    (Owed by: 春夜喜雨 http://blog.csdn.net/chunyexiyu) 参考:https://www.equestionanswers.com/cpp/vptr-and-vta ...

  5. 菱形继承,多继承,虚继承、虚表的内存结构全面剖析(逆向分析基础)

    // 声明:以下代码均在Win32_Sp3   VC6.0_DEBUG版中调试通过.. 在逆向还原代码的时候,必须得掌握了菱形继承,多继承,虚继承虚函数的内存虚表结构.所以,这篇文章献给正在学习C++ ...

  6. C++学习笔记——虚函数

    2019独角兽企业重金招聘Python工程师标准>>> 基本概念 虚函数是在某基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数,用法格式为: virtual ...

  7. 【C++基础之十一】虚函数的用法

    虚函数的作用和意义,就不进行说明了,这里主要讨论下虚函数的用法. 1.典型的虚函数用法 可以看到,只有标识为virtual的函数才会产生多态的效果,而且是编译多态.它只能借助指针或者引用来达到多态的效 ...

  8. 【虚基类、虚函数及应用】

    虚基类 1.虚基类存在的意义 当在多条继承路径上有一个公共的基类,在这些路径中的某几条汇合处,这个公共的基类就会产生多个实例(或多个副本),若只想保存这个基类的一个实例,可以将这个公共基类说明为虚基类 ...

  9. 空类,虚函数类,虚继承类的空间大小

    //此代码在32位win下运行成功 #include<iostream> using namespace std; class A//A是空类,编译器会用一个char类型标记这个类,大小为 ...

最新文章

  1. asp.net gridview 模板列 弹出窗口编辑_连云港各种新型铝模板设计软件,哪家强_威尔达建材...
  2. sqlliet 创建多表查询的视图_第4关 复杂查询
  3. python3.1.1_python 3.1.1 with--enable shared:将不会构建任何扩展
  4. C# CKEditor、CKFinder集成使用
  5. mysql 优化器提示_Mysql查询优化器
  6. Android手机录制音频
  7. 计算机科学与技术专接本的历年真题,10年计算机专业专接本真题
  8. 操作系统课程设计(linux操作系统)
  9. Tp5.0完全开发手册学习(第六章 请求)之一 (request 和input)
  10. 【微分方程数值解】常\偏微分方程及其常用数值解法概述
  11. 华为 IPD 集成产品开发流程的缺点和适用局限性
  12. 基于WKT标准的空间参考系字符串及prj文件生成样例
  13. 手机浏览器打开微信小程序,支持外部浏览器跳转到小程序
  14. 3500字专家访谈,探访汽车零部件企业争相迈步数字化背后的故事
  15. 计算机学硕研究计划,博士研究生学习计划和研究计划
  16. SAT数学公式之几何图形
  17. 批量修改WORD文档密码
  18. [JZOJ5442]【NOIP2017提高A组冲刺11.1】荒诞([BZOJ3060]【POI2012】Tour de Byteotia)
  19. C语言switch语句用法详解
  20. Linux下安装软件的几种方法

热门文章

  1. 【云原生Docker系列项目实战第一篇】dockerfile+lnmp+workpress(星星温柔泛滥,人间至善)
  2. anasys hpc集群_ANSYS高性能计算(HPC)
  3. 滚珠螺杆的四种安装方式
  4. 身份证识别(java)
  5. 二分法python上机实验报告_数值分析上机实验报告..doc
  6. 项目工期所需人数的的计算
  7. 七年级下学期计算机考试题,七年级信息技术试卷及答案
  8. 转 : Squareup刷卡器,音频读卡识别android/iOS源码API
  9. java 可见_java 可见性简单总结
  10. Spring和Ehcache整合详解