C++多重继承下的函数动态绑定问题分析

来源:互联网 发布:淘宝如何买东西 编辑:程序博客网 时间:2024/06/05 18:18

问题

前段时间看到了一个比较有趣的问题,多重继承中某种情况动态绑定的行为比较违反直觉,实例代码如下:

#include <iostream>using namespace std;class Base1 {public:    virtual void foo1() {};};class Base2 {public:    virtual void foo2() {};};class MI : public Base1, public Base2 {public:    virtual void foo1() { cout << "Foo1!" << endl; }    virtual void foo2() { cout << "Foo2!" << endl; }};int main() {    MI oMI;    Base1* pB1;    Base2* pB2;    pB1 = &oMI;    pB1->foo1();//测试1    pB2 = &oMI;    pB2->foo2();//测试2    pB2 = (Base2*)(pB1); // 指针强行转换,没有偏移    pB2->foo2();//测试3    pB2 = dynamic_cast<Base2*>(pB1); // 指针动态转换,dynamic_cast帮你偏移    pB2->foo2();//测试4    cin.get();    return 0;}

分析Main函数后,从主观的直觉上程序的行为应该是这样的

Foo1!Foo2!Foo2!Foo2!

但实际结果却是

这里写图片描述

也就是说第三项测试中,多态失效了

结论

结论放在最前面,这是一个UB(undefined behavior),目前在GCC、VC++、Clang中,该UB的表现都是一样的,用上面的例子来描述,就是:foo2()是base2类型中的第1个虚函数,所以程序调用了base2所指向的对象的vtbl中第1个虚函数,即base1::foo1()。(先计算偏移量,然后寻址,如果你懂一点汇编,会明白我在说什么)

避免该问题的方法就是使用动态类型转换。

内部机理

要理解这一行为的内部原理,需要对C++对象模型有一定了解,尤其需要对虚函数表(virtual table, vtbl)虚函数表指针(virtual table pointer, vptr)的运行机制有较为完整的了解

单一继承中的情况

我们先来看一下虚函数动态绑定在单继承的类中是如何实现的,假设有如下的一个继承链

这里写图片描述

其中,vptr完全是由编译器替用户实现的,如果不通过特殊的手段或UB,用户根本感知不到vptr的存在,通俗的说,每一个具有多态性的类都有唯一一个vtbl,记录着该类中所有的虚函数入口地址。而该类的每一个对象都有一个vptr指向该类的vtbl头部

在内存中,上述的结构看起来如下图

这里写图片描述

举例子来说,cat和fish各有一个vtbl,它们的繁殖方式是不同的,因此breed()为派生自基类animal的虚方法,cat的vtbl上有一个指针指向cat::breed(),而fish的vtbl上有一个指针指向fish::breed(),这样即使当一个animal*指针指向fish的某个对象,它也能通过该对象中的vptr找到fish类的vtbl,从而正确使用fish::breed(),而不是把它错认为是animal并调用错误的breed(),这就是C++中多态性的基本原理

需要格外注意的是,每个类的vtbl在内存中只有一份,而该类的每个对象都保存了一个vptr。(至于vptr在内存中的位置,C++的标准没有规定,我们只需要知道大多数现代C++编译器都将vptr放在了对象的头部,这也是它们在这个UB上行为一致的必要条件之一)

在这个结构图中我们可以发现,对于继承链中的一个虚函数,例如breed(),在整个继承链中的任何一个类的vtbl上的编号都是一样的,这得益于以下几个条件:

  • 编译器会先构造派生类中的基类部分,再构造派生类部分
  • 在基类中出现过的虚函数,在派生类中一定会出现
  • 在派生类中新出现的虚函数,不会影响到它的基类

当编译器创建一个cat类的对象的vtbl时候,一个可能的顺序是:它会首先创建一个vtbl,然后查看animal类,并在vtbl中添加animal::breed()方法的指针。然后回到cat类,如果cat类有自己的breed(),它会用cat::breed()的指针值替换原来的指针,然后在第一项的后面再继续添加自己特有的虚函数。(作为补充说明,读者应当意识到,这一切都发生在编译期,被保存在编译后的二进制文件中。)

需要额外注意的是上面的图为了方便理解,省略了一条内容:真正的vtbl头部还包含了8字节的type_info,更多可以参考[1] p14。

多重继承中的情况

利用vtbl和vptr实现的多态在单一继承中的表现非常完美,但在多重继承中就不那么尽如人意了当一个类多重继承自两个基类的时候,它在内存上的布局看起来如下图

这里写图片描述

此时问题就凸显出来了,可以看到一个MI类的对象中存在两个vptr。而如果我们做了如下的事情

    pB2 = (Base2*)(pB1); // 指针强行转换,没有偏移    pB2->foo2();//测试3

其实就相当于将对象中的base1 part部分的头部直接赋给了一个Base2*类型的指针。而通过上一节的内容我们又知道,对象中存储的vptr实际上并不能表达对象本身的类型,只能帮助程序找到这个对象所属类型所具有的方法而已。因此,编译器在这里计算出了对于Base2类型,foo2()方法在vtbl的第1个位置:slot1(slot,槽是形容vtbl中一条记录的术语),然后访问了指针指向的数据的第一项(base1 part的vptr),找到了vtbl of base1,并运行了其中的第1项。

反汇编窥视

利用gcc编译最上面的示例程序并进行反汇编,得到的结果如下

这里写图片描述

其中博主设置了四个断点的地方,就是四次foo方法调用,我们可以看到每个call前面都有结构相似的一段代码

mov rax, [rbp-0x20]add rax, 8mov [rbp-0x28], raxmov rax, [rbp-0x28]mov rax, [rax]mov rax, [rax]...call rax

这就是下面两行的汇编代码

    pB2 = (Base2*)(pB1); // 指针强行转换,没有偏移    pB2->foo2();//测试3

参考下图可以看出,rbp(0000 7fff c34a e570)是一个较大的值,指向的是当前运行域的栈底,而rbp-0x20中存储的值(0000 0000 0040 0d20)则较小,与代码段的地址相近,可以判断这其实就是vptr的指针。所以可以分析出如下执行序列:

  1. 第1行的mov指令在运行栈上计算出目标对象的vptr的偏移量(32个字节),并将其记录的值赋给rax。
  2. 第2行的add指令让rax跳过了8字节的type_info,指向了slot1
  3. 第3行将该值赋值给我们设置的指针变量pB2,第4行又将其取出,这两行没有分析价值
  4. 第5行将slot1中存储的值:MI::foo1()的方法头(我不知道是否可以称其为函数指针)的地址,取出放到rax中
  5. 第6行将函数头中存放的方法的第一条指令所在的地址的值放置在rax中
  6. 用call rax指令开始执行虚方法

这里写图片描述

更多测试例子

Base1有两个虚函数,Base2只有一个虚函数的情况

#include <iostream>using namespace std;class Base1 {public:    virtual void mmm() {};    virtual void foo1() {};};class Base2 {public:    virtual void foo2() {};};class MI : public Base1, public Base2 {public:    virtual void mmm() { cout << "mmm" << endl; }    virtual void foo1() { cout << "Foo1!" << endl; }    virtual void foo2() { cout << "Foo2!" << endl; }};int main() {    MI oMI;    Base1* pB1;    Base2* pB2;    pB1 = &oMI;    pB1->foo1();//测试1    pB2 = &oMI;    pB2->foo2();//测试2    pB2 = (Base2*)(pB1); // 指针强行转换,没有偏移    pB2->foo2();//测试3    pB2 = dynamic_cast<Base2*>(pB1); // 指针动态转换,dynamic_cast帮你偏移    pB2->foo2();//测试4    cin.get();    return 0;}

运行结果:均调用了MI::mmm()

这里写图片描述

将上面程序中的void mmm()改为void mmm(int a)后的运行结果:VC++抛出异常,GCC中a为随机值

这里写图片描述

总结

最后再回顾一遍结论:这是一个UB(undefined behavior),目前在GCC、VC++、Clang中,该UB的表现都是一样的,用上面的例子来描述,就是:foo2()是base2类型中的第1个虚函数,所以程序调用了base2所指向的对象的vtbl中第1个虚函数,即base1::foo1()。

避免该问题的方法就是使用动态类型转换。

参考文献

[1] Stanley B. Lippman(著),侯杰(译),深度探索C++对象模型,电子工业出版社,2012

0 0
原创粉丝点击