热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

C++虚函数探索笔记(2)——虚函数与多继承

链接:C++虚函数探索笔记(1)——虚函数的简单示例分析C++虚函数探索笔记(2)——虚函数与多继承C++虚函数探索笔记(3)——延伸思考:虚函数应用的一些其他情形关注

链接:

  • C++虚函数探索笔记(1)——虚函数的简单示例分析
  • C++虚函数探索笔记(2)——虚函数与多继承
  • C++虚函数探索笔记(3)——延伸思考:虚函数应用的一些其他情形

关注问题:

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

前面我们尝试了一个简单的例子,接下来尝试一个多级继承的例子,以及一个多继承的例子。主要涉及到以下问题:多级继承时虚函数表的内容是如何填写的,如何在多级继承的情况下调用某一级父类里的虚函数,以及在多继承(多个父类)的情况下的对象布局。

多级继承

在这里,多级继承指的是有3层或者多层继承关系的情形。让我们看看下面的代码:

多层继承代码示例
  1. //Source filename: Win32Con.cpp
  2.  #include 
  3.  using namespace std;
  4. class parent1
  5.  {
  6. public:
  7.     virtual int fun1(){cout<<"parent1::fun1()"<<endl;return 0;};
  8.     virtual int fun2()=0;
  9. };
  10. class child1:public parent1
  11.  {
  12. public:
  13.     virtual int fun1()
  14.     {
  15.         cout<<"child1::fun1()"<<endl;
  16.         parent1::fun1();
  17.         return 0;
  18.     }
  19.     virtual int fun2()
  20.     {
  21.         cout<<"child1::fun2()"<<endl;
  22.         return 0;
  23.     }
  24. };
  25. class grandson:public child1
  26.  {
  27. public:
  28.     virtual int fun2()
  29.     {
  30.         cout<<"grandson::fun2()"<<endl;
  31.         //parent1::fun2();
  32.         parent1::fun1();
  33.         child1::fun2();
  34.         return 0;
  35.     }
  36. };
  37. void test_func1(parent1 *pp)
  38. {
  39.     pp->fun1();
  40.     pp->fun2();
  41. }
  42. int main(int argc, char* argv[])
  43. {
  44.     grandson sunzi;
  45.     test_func1(&sunzi);
  46.     return 0;
  47. }

这段代码展示了三个class,分别是parent1,child1,grandson。

  • 类parent1定义了两个虚函数,其中fun2是一个纯虚函数,这个类是一个不可实例化的抽象基类。
  • 类child1继承了parent1,并且对两个虚函数fun1和fun2都编写了实现的代码,这个类可以被实例化。
  • 类grandson继承了child1,但是只对虚函数fun2编写了实现的代码。

此外,我们还改写了test_func1函数,它的参数为parent1类型的指针,我们可以将parent1的子孙类作为这个函数的参数传入。在这个函数里,我们将依次调用parent1类的两个虚函数。

可以先通过阅读代码预测一下程序的输出内容。

程序的输出内容将是:


child1::fun1()
parent1::fun1()
grandson::fun2()
parent1::fun1()
child1::fun2()

先看第一行输出child1::fun1(),为什么会输出它呢?我们定义的具体对象sunzi是grandson类型的,test_func1的参数类型是parent1类型。在调用这个虚函数的时候,是完成了一次怎样的调用过程呢?

让我们再次使用cl命令输出这几个类的对象布局:

class parent1   size(4):
        +---
 0      | {vfptr}
        +---

parent1::$vftable@:
        | &parent1_meta
        |  0
 0      | &parent1::fun1
 1      | &parent1::fun2

parent1::fun1 this adjustor: 0
parent1::fun2 this adjustor: 0

class child1    size(4):
        +---
        | +--- (base class parent1)
 0      | | {vfptr}
        | +---
        +---

child1::$vftable@:
        | &child1_meta
        |  0
 0      | &child1::fun1
 1      | &child1::fun2

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

class grandson  size(4): //grandson的对象布局
        +---
        | +--- (base class child1)
        | | +--- (base class parent1)
 0      | | | {vfptr}
        | | +---
        | +---
        +---

grandson::$vftable@:  //grandson虚函数表的内容
        | &grandson_meta
        |  0
 0      | &child1::fun1
 1      | &grandson::fun2

grandson::fun2 this adjustor: 0

因为我们实例化的是一个grandson对象,让我们看看它的对象布局。正如前面的例子一样,里面只有一个vfptr指针,但是不一样的却是这个指针所指的虚函数表的内容:

第一个虚函数,填写的是child1类的fun1的地址;第二个虚函数填写的才是grandson类的fun2的地址。

很显然我们可以得出这样一个结论:在一个子对象的虚函数表里,每一个虚函数的实际运行的函数地址,将填写为在继承体系里最后实现该虚函数的函数地址。

所以当我们在test_func1里调用了传入的parent1指针的fun1函数的时候,我们实际执行的是填写在虚函数表里的child1::fun1(),而调用fun2函数的时候,是从虚函数表里得到了grandson::fun2函数的地址并调用之。在“程序输出结果”表里的第一行和第三行结果证实了上述结论。

再看一下程序代码部分的child1::fun1()的实现代码,在第18行,我们有parent1::fun1();这样的语句,这行代码输出了运行结果里的第二行,而在grandson::fun2()的实现代码第35行的parent1::fun1();以及第36行的child1::fun2();则输出了运行结果里的第四行和第五行的内容。这三行代码展示了如何调用父类以及更高的祖先类里的虚函数。——事实上,这与调用父类的普通函数没有任何区别。

在程序代码的第34行,有一行被注释了的内容//parent1::fun2();,之所以会注释掉,是因为这样的代码是无法通过编译的,因为在parent1类里,fun2是一个“纯虚函数”也就是说这个函数没有代码实体,在编译的时候,链接器将无法找到fun2的目标代码从而报错。

其实有了对虚函数的正确的认识,上面的多级继承是很自然就能明白的。然而在多继承的情况下,情况就有所不同了。。。

多继承下虚函数的使用

假如一个类,它由多个父类继承而来,而在不同的父类的继承体系里,都存在虚函数的时候,这个类的对象布局又会是怎样的?它又是怎样定位虚函数的呢?

让我们看看下面的代码:

程序输出结果
  1. //Source filename: Win32Con.cpp
  2.  #include 
  3.  using namespace std;
  4. class parent1
  5.  {
  6. public:
  7.     virtual int fun1(){cout<<"parent1::fun1()"<<endl;return 0;};
  8. };
  9. class parent2
  10.  {
  11. public:
  12.     virtual int fun2(){cout<<"parent2::fun2()"<<endl;return 0;};
  13. };
  14. class child1:public parent1,public parent2
  15.  {
  16. public:
  17.     virtual int fun1()
  18.     {
  19.         cout<<"child1::fun1()"<<endl;
  20.         return 0;
  21.     }
  22.     virtual int fun2()
  23.     {
  24.         cout<<"child1::fun2()"<<endl;
  25.         return 0;
  26.     }
  27. };
  28. void test_func1(parent1 *pp)
  29. {
  30.     pp->fun1();
  31. }
  32. void test_func2(parent2 *pp)
  33. {
  34.     pp->fun2();
  35. }
  36. int main(int argc, char* argv[])
  37. {
  38.     child1 chobj;
  39.     test_func1(&chobj);
  40.     test_func2(&chobj);
  41.     return 0;
  42. }

这一次,我们有两个父类,parent1和parent2,在parent1里定义了虚函数fun1,而在parent2里定义了虚函数fun2,然后我们有一个子类child1,在里面重新实现了fun1和 fun2两个虚函数。然后我们编写了test_func1函数来调用parent1类型对象的fun1函数,编写了test_func2函数调用parent2对象的fun2函数。在main函数里我们实例化了一个child1类型的对象chobj,然后分别传给test_func1和test_func2去执行。

这段代码的运行结果非常简单就能看出来:

child1::fun1()

child1::fun2()

但是,让我们看看对象布局吧:

class child1    size(8):
        +---
        | +--- (base class parent1)
 0      | | {vfptr}
        | +---
        | +--- (base class parent2)
 4      | | {vfptr}
        | +---
        +---

child1::$vftable@parent1@:
        | &child1_meta
        |  0
 0      | &child1::fun1

child1::$vftable@parent2@:
        | -4
 0      | &child1::fun2

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

注意到没?在child1的对象布局里,出现了两个vfptr指针!

这两个虚函数表指针分别继承于parent1和parent2类,分别指向了不同的两个虚函数表。

问题来了,当我们使用test_func1调用parent1类的fun1函数的时候,调用个过程还比较好理解,可以从传入的地址参数取得继承自parent1的vfptr,从而执行正确的fun1函数代码,但是当我们调用test_func2函数的时候,为什么程序可以自动取得来自parent2的vfptr呢,从而得出正确的fun2函数的地址呢?

其实,这个工作是编译器自动根据实例的类型完成的,在编译阶段就已经确定了在调用test_func2的时候,传入的this指针需要增加一定的偏移(在这里则是第一个vfptr所占用的大小,也就是4字节)。

我们可以看看main函数里这部分代码的反汇编代码:

反汇编代码
  1.     child1 chobj;
  2. 00F5162E 8D 4D F4         lea         ecx,[chobj] 
  3. 00F51631 E8 F5 FB FF FF   call        child1::child1 (0F5122Bh) 
  4.     test_func1(&chobj);
  5. 00F51636 8D 45 F4         lea         eax,[chobj] 
  6. 00F51639 50               push        eax  
  7. 00F5163A E8 6F FB FF FF   call        test_func1 (0F511AEh) 
  8. 00F5163F 83 C4 04         add         esp,4 
  9.     test_func2(&chobj);
  10. 00F51642 8D 45 F4         lea         eax,[chobj] 
  11. 00F51645 85 C0            test        eax,eax 
  12. 00F51647 74 0E            je          main+47h (0F51657h) 
  13. 00F51649 8D 4D F4         lea         ecx,[chobj] 
  14. 00F5164C 83 C1 04 add ecx,4 
  15. 00F5164F 89 8D 2C FF FF FF mov         dword ptr [ebp-0D4h],ecx 
  16. 00F51655 EB 0A            jmp         main+51h (0F51661h) 
  17. 00F51657 C7 85 2C FF FF FF 00 00 00 00 mov         dword ptr [ebp-0D4h],0 
  18. 00F51661 8B 95 2C FF FF FF mov         edx,dword ptr [ebp-0D4h] 
  19. 00F51667 52               push        edx  
  20. 00F51668 E8 F6 FA FF FF   call        test_func2 (0F51163h) 
  21. 00F5166D 83 C4 04         add         esp,4 
  22.     return 0;

从第4行至第5行,执行的是test_func1函数,this指针指向 chobj (第2行lea ecx,[chobj]),但是调用test_func2函数的时候,this指针被增加了4(第14行)!于是,在test_func2执行的时候,就可以从&chobj+4的地方获得vfptr指针,从而根据parent2的对象布局得到了fun2的地址并执行了。

为了证实这点,我们可以将代码做如下的修改:

1:  int main(int argc, char* argv[])


2:  {


3:      child1 chobj;


4:      test_func1(&chobj);


5:      test_func2((parent2 *)(void *)&chobj);


6:      return 0;


7:  }


8:  

请注意红色部分的变化,在讲chobj传入给test_func2之前,先用(void *)强制转换为无类型指针,再转换为parent2 指针,这样的转换,显然是可行的,因为chobj本身就是parent2的子类,然而,程序的执行效果却是:

child1::fun1()

child1::fun1()

执行test_func2函数,调用的是parent2::fun2,但是居然执行的是child1::fun1()函数!!!

这中间发生了些什么呢?我们再看看反汇编的代码:

反汇编的代码
  1.     child1 chobj;
  2. 013D162E 8D 4D F4         lea         ecx,[chobj] 
  3. 013D1631 E8 F5 FB FF FF   call        child1::child1 (13D122Bh) 
  4.     test_func1(&chobj);
  5. 013D1636 8D 45 F4         lea         eax,[chobj] 
  6. 013D1639 50               push        eax  
  7. 013D163A E8 6F FB FF FF   call        test_func1 (13D11AEh) 
  8. 013D163F 83 C4 04         add         esp,4 
  9.     test_func2((parent2*)(void *)&chobj);
  10. 013D1642 8D 45 F4         lea         eax,[chobj] 
  11. 013D1645 50               push        eax  
  12. 013D1646 E8 18 FB FF FF   call        test_func2 (13D1163h) 
  13. 013D164B 83 C4 04         add         esp,4 
  14.     return 0;

从调用test_func2的反汇编代码可以看到,这一次ecx寄存器的值没有做改变!所以在执行test_func2的时候,将取得parent1对象布局里的vfptr,而这个vfptr所指的虚函数表里的第一项就是fun1,并且被填写为child1::fun1的地址了。所以才出现了child::fun1的输出内容!显然这里有一个隐藏的致命问题,加入parent1和parent2的第一个虚函数的参数列表不一致,这样的调用显然就会导致堆栈被破坏掉,程序99%会立即崩溃。之前的程序没有崩溃并且成功输出内容,不过是因为parent1::fun1()和parent2::fun2()的参数列表一致的关系而已。
所以,千万不要在使用一个多继承对象的时候,将其类型信息丢弃,编译器还需要依靠正确的类型信息,在使用虚函数的时候来得到正确的汇编代码!

多继承与虚函数重复

既然说到了多继承,那么还有一个问题可能会需要解决,那就是如果两个父类里都有相同的虚函数定义,在子对象的布局里会是怎么样个情况?是否依然可以将这个虚函数指向到正确的实现代码上呢?

修改前面一个源代码,在parent2的接口里增加下面的虚函数定义:

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

上面的fun1的定义与parent1类里的完全重复相同(类型,参数列表),增加上面的代码后立即开始编译,程序正常编译通过。运行之,得到下面的结果:

child1::fun1()

child1::fun2()

这个程序居然正确的完成了执行,编译器在其中做了些怎样的工作,是怎么样避免掉队fun1函数的冲突问题呢?

让我们来看看这个时候的child1的对象布局:

class child1    size(8):
        +---
        | +--- (base class parent1)
 0      | | {vfptr}
        | +---
        | +--- (base class parent2)
 4      | | {vfptr}
        | +---
        +---

child1::$vftable@parent1@:
        | &child1_meta
        |  0
 0      | &child1::fun1

child1::$vftable@parent2@:
        | -4
 0      | &child1::fun2
 1      | &thunk: this-=4; goto child1::fun1

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

恩~~~还是两个vfptr在child1的对象布局里(不一样就怪啦,呵呵),但是第二个vfptr所指的虚函数表的内容有所变化哦!

注意看红色字体部分,虚函数表里并没有直接填写child::fun1的代码,而是多了一个 &thunk: this-=4;然后才goto child1::fun1!注意到一个关键名词thunk了吧?没错,vc在这里使用了名为thunk的技术,避免了虚函数fun1在两个基类里重复出现导致的冲突问题!(除了thunk,还有其他方法可以解决此类问题的)。

现在,我们知道为什么相同的虚函数不会在子类里出现冲突的情况了。

但是,倘若我们在基类里就是由两个冲突的普通函数,而不是虚函数,是个怎样的情况呢?

多继承产生的冲突与虚继承,虚基类

我们在parent1和parent2里添加一个相同的函数void fun3(),然后再进行编译,通过了!查看类对象布局,跟上面的完全一致。但是在main函数里调用chobj.fun3()的时候,编译器却不再能正确编译了,并且会提示“error C2385: 对“fun3”的访问不明确”的错误信息,没错,编译器不知道你要访问哪个fun3了。

如何解决这样的多继承带来的问题呢,其实有一个简单的做法。就是在方法前限定引用的具体是哪个类的函数,比如:chobj.parent1::fun3();  ,这样的写法就写明了是要调用chobj的父类parent1里的fun3()函数!

我们再看看另外一种情况,从parent1和parent2里抹去刚才添加的fun3函数,将之放到一个共同的基类里:

class commonbase


 {


public:


    void fun3(){cout<<"commonbase::fun3()"<<endl;}


};

而parent1和parent2都修改为从此类继承。可以看到,在这个情况下,依然需要使用chobj.parent1::fun3();  的方式才可以正确调用到fun3,难道,在这种情况下,就不能自然的使用chobj.fun3()这样的方式了吗?

虚继承可以解决这个问题——我们在parent1和parent2继承common类的地方添加上一个关键词virtual,如下:

class parent1:virtual public commonbase


 {


public:


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


};

给parent2也同样的处理,然后再次编译,这次chobj.fun3()可以编译通过了!!!

编译器这次又在私下里做了哪些工作了呢????

class child1    size(16):
        +---
        | +--- (base class parent1)
 0      | | {vfptr}
 4      | | {vbptr}
        | +---
        | +--- (base class parent2)
 8      | | {vfptr}
12      | | {vbptr}
        | +---
        +---
        +--- (virtual base commonbase)
        +---

child1::$vftable@parent1@:
        | &child1_meta
        |  0
 0      | &child1::fun1

child1::$vftable@parent2@:
        | -8
 0      | &child1::fun2
 1      | &thunk: this-=8; goto child1::fun1

child1::$vbtable@parent1@:
 0      | -4
 1      | 12 (child1d(parent1+4)commonbase)

child1::$vbtable@parent2@:
 0      | -4
 1      | 4 (child1d(parent2+4)commonbase)

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

vbi:       class  offset o.vbptr  o.vbte fVtorDisp
      commonbase      16       4       4 0

这次变化可大了去了!!!

首先,可以看到两个类parent1和parent2的对象布局里,都多了一个vbptr的指针。而在child1的对象布局里,还有一个virtual base commonbase的虚拟基类。再看看两个vbptr的内容:

12 (child1d(parent1+4)commonbase) 这个很好理解,从parent1的vbptr开始,偏移12个字节,指向的是virtual base commonbase!

再看看4 (child1d(parent2+4)commonbase) ,从parent2的vbptr开始,便宜4个字节,也指向了virtual base commonbase!

这下明白了。虚基类在child1里只有一个共同的对象布局了,所以就可以直接用chobj.fun3()啦,当然,在commonbase里的其他成员变量此时也可以同样的方式访问了!

虽然解决方案有了,但是在一个系统的设计里,如果有一个基类出现多继承冲突的情况,大部分情况下都说明这样的设计是有问题的,应该尽量避免这样的设计,并且尽量用纯虚函数,来提取一些抽象的接口类,把共同的方法接口都抽取出来,通常就能避免多继承的问题。


推荐阅读
  • http:my.oschina.netleejun2005blog136820刚看到群里又有同学在说HTTP协议下的Get请求参数长度是有大小限制的,最大不能超过XX ... [详细]
  • 本文介绍了为什么要使用多进程处理TCP服务端,多进程的好处包括可靠性高和处理大量数据时速度快。然而,多进程不能共享进程空间,因此有一些变量不能共享。文章还提供了使用多进程实现TCP服务端的代码,并对代码进行了详细注释。 ... [详细]
  • 本文介绍了C函数ispunct()的用法及示例代码。ispunct()函数用于检查传递的字符是否是标点符号,如果是标点符号则返回非零值,否则返回零。示例代码演示了如何使用ispunct()函数来判断字符是否为标点符号。 ... [详细]
  • C++字符字符串处理及字符集编码方案
    本文介绍了C++中字符字符串处理的问题,并详细解释了字符集编码方案,包括UNICODE、Windows apps采用的UTF-16编码、ASCII、SBCS和DBCS编码方案。同时说明了ANSI C标准和Windows中的字符/字符串数据类型实现。文章还提到了在编译时需要定义UNICODE宏以支持unicode编码,否则将使用windows code page编译。最后,给出了相关的头文件和数据类型定义。 ... [详细]
  • 本文介绍了深入浅出Linux设备驱动编程的重要性,以及两种加载和删除Linux内核模块的方法。通过一个内核模块的例子,展示了模块的编译和加载过程,并讨论了模块对内核大小的控制。深入理解Linux设备驱动编程对于开发者来说非常重要。 ... [详细]
  • 李逍遥寻找仙药的迷阵之旅
    本文讲述了少年李逍遥为了救治婶婶的病情,前往仙灵岛寻找仙药的故事。他需要穿越一个由M×N个方格组成的迷阵,有些方格内有怪物,有些方格是安全的。李逍遥需要避开有怪物的方格,并经过最少的方格,找到仙药。在寻找的过程中,他还会遇到神秘人物。本文提供了一个迷阵样例及李逍遥找到仙药的路线。 ... [详细]
  • STL迭代器的种类及其功能介绍
    本文介绍了标准模板库(STL)定义的五种迭代器的种类和功能。通过图表展示了这几种迭代器之间的关系,并详细描述了各个迭代器的功能和使用方法。其中,输入迭代器用于从容器中读取元素,输出迭代器用于向容器中写入元素,正向迭代器是输入迭代器和输出迭代器的组合。本文的目的是帮助读者更好地理解STL迭代器的使用方法和特点。 ... [详细]
  • linux进阶50——无锁CAS
    1.概念比较并交换(compareandswap,CAS),是原⼦操作的⼀种,可⽤于在多线程编程中实现不被打断的数据交换操作࿰ ... [详细]
  • 本文主要介绍了gym102222KVertex Covers(高维前缀和,meet in the middle)相关的知识,包括题意、思路和解题代码。题目给定一张n点m边的图,点带点权,定义点覆盖的权值为点权之积,要求所有点覆盖的权值之和膜qn小于等于36。文章详细介绍了解题思路,通过将图分成两个点数接近的点集L和R,并分别枚举子集S和T,判断S和T能否覆盖所有内部的边。文章还提到了使用位运算加速判断覆盖和推导T'的方法。最后给出了解题的代码。 ... [详细]
  • loader资源模块加载器webpack资源模块加载webpack内部(内部loader)默认只会处理javascript文件,也就是说它会把打包过程中所有遇到的 ... [详细]
  • 如何使用Xcode7软件添加NTL库并运行C++程序。一、首先安装NTL库1、进入“ATourofNTL:ObtainingandInstallingNTLfor ... [详细]
  • 数据结构-图详解(图基本概念、图的存储结构及C++实现)
    本文主要介绍关于数据结构,c++,图论的知识点,对【数据结构-图详解(图基本概念、图的存储结构及C++实现)】和【数据结构图的存储结构代码】有兴趣的朋友可以看下由【NUC_Dodamce】投稿的技术文 ... [详细]
  • C++简单单向链表实现
    #include?pch.h#include?创建链表typedef?struct?ListTable?{int?nElement;????链表元素int?nSequence;???节点序号ListTable?* ... [详细]
  • 字符串的题目用库函数往往能大大简化代码量介绍几个常用的C的字符串处理库函数strtok()原型char*strtok(chars[],constchar*delim); ... [详细]
  • 本文讨论了使用差分约束系统求解House Man跳跃问题的思路与方法。给定一组不同高度,要求从最低点跳跃到最高点,每次跳跃的距离不超过D,并且不能改变给定的顺序。通过建立差分约束系统,将问题转化为图的建立和查询距离的问题。文章详细介绍了建立约束条件的方法,并使用SPFA算法判环并输出结果。同时还讨论了建边方向和跳跃顺序的关系。 ... [详细]
author-avatar
xin新的
这个家伙很懒,什么也没留下!
Tags | 热门标签
RankList | 热门文章
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有