C++深坑之多继承下的类型转换

4,216 阅读6分钟

引子

问:C++的指针在任何情况下都可以保到在具有void *类型的中间变量里,而在需要的时候再转回到合适的类型吗?

答:大部分时候是可以的,除了一种场景:目标类型有多个基类。

问题

先上代码:

#include <iostream>
using namespace std;

//基类1
class Base1 {
public:
    virtual void f1() {
        cout << "Base1::f" << endl;
    }
private:
    int a[10];
};

//基类2
class  Base2 {
public:
    virtual void f2() {
        cout << "Base2::f" << endl;
    }
private:
    int a;
};

//派生类,多继承自上面两个基类并重写两个虚函数
class Derived : public Base1, public Base2 {
public:
    virtual void f1() {
        cout << "Derived::f1" << endl;
    }
    virtual void f2() {
        cout << "Derived::f2" << endl;
    }
};

int main() {
    //生成一个派生类对象
    Derived *p0 = new Derived();
    //赋值给Base2类型的指针,并调用其虚函数
    Base2 *p1 = (Base2 *)p0;
    p1->f2();

    //指针先赋值给void *类型的指针
    void *pv = p0;
    //再赋值给Base2类型的指针,然后调用其虚函数
    Base2 *p2 = (Base2 *)pv;
    p2->f2();

    //输出上述各指针的值
    cout << "p0: " << p0 << ", pv: " << pv << ", p1: " << p1 << ", p2: " << p2 << endl;
    return 0;
}

运行结果:

Derived::f2
Derived::f1
p0: 0x7fc007402a40, pv: 0x7fc007402a40, p1: 0x7fc007402a70, p2: 0x7fc007402a40

可以看到,同样类型的两个指向派生类的指针,调用同样的虚函数,却得到了不同的结果(第一次结果是正确的,第二次是错误的),其唯一的区别是,第二次用于调用的对象指针,是派生类指针先经void *类型指针保存了一次,然后才转回到Base2 *类型,而第一次调用是直接由派生类指针转换为Base2 *类型指针的。

分析

为什么会出现上述结果呢?这跟C++多继承和虚函数的实现方式有关,下面是相关的知识点:

  • 对于单继承的对象内存布局,C++的实现方式是基类在前,子类跟在基类后面
  • 在多继承情况下,也是遵从先基类后子类的原则,多个基类在前面以声明顺序顺次排布,最后面是子类对象
  • 对于虚函数,C++的实现方式是,在对象的内存开始位置保存一个虚函数表指针(虚函数表指针指向对应类型的虚表,虚表里面放置了对应类型的虚函数实现版本,运行时通过对象指针查询虚表,得到正确的虚函数实现版本)
  • 多继承情况下,由于多个基类可能都声明了虚函数,故多个基类的对象可能都带有虚函数指针,此时多个虚表指针散落在对象的不同位置

综上:

  • 单继承情况下,对象地址和基类的虚表指针重合,调用虚函数时直接使用对象地址即可
  • 多继承情况下,子类对象在向上转型时,由于转型后的指针必须指向合适的基类,才可以查询到正确的虚函数版本(以及访问到正确的数据成员),所以转型后其指针所指向的内存地址是需要经过调整的。除了第一个基类之外的其它所有基类,其转型后实际得到的地址会比原始对象地址大一些(具体大多少取决于基类在继承列表里的位置和前面基类对象的大小)。

上述类型转换过程中的地址调整操作,必须在编译器知悉其继承体系的前提下进行,对于如void *转换为Base2 *这种转换,编译器不知道这是个Derived对象的指针(目前是个void *),故无法计算正确的偏移量。上述程序的结尾处,特意输出了各个指针的值,可以看到,能调用到正确函数版本的对象指针,是经过正确调整的的(转换后的指针值跟原始指针比对象指针大了一个Base1的大小,即虚表指针大小加对象本身(即其数据成员)的大小);而调用到错误函数版本的对象指针,由于是经由void *转换的,故原样保留了原始指针值(按位赋值),导致转换后的对象指针指向了错误的虚表(即派生类的第一个基类的虚表,此处应是Base1,运行结果也确实是调用到了Base1的函数实现)

解决

至此,我们搞清楚了问题产生的原因,但肯定有读者会发出疑问,我们有什么必要要经由void *转换一下呢,这个看似是问题根源的操作,完全是上面程序自找的嘛,如果老老实实直接转换,就不会有问题了。这个例子确实如此,但在实际应用中,确实存在一些这样的场景,而且并不像上面的例子那样直接,识别起来比较隐蔽。举个例子,在创建线程的时候,如果要传递一个Derived类的对象作为线程的参数(pthread_create的第四个参数),则这个指针必须先转换为void *,后面在线程函数内部转换回Base2 *(这个是pthread_create函数的实现决定的,它的第四个参数就是void *类型的,这一点我们无法改变),而且在实际编码中,并不容易察觉这个细节,因为在大多数情况下,把指针转换到void *再转换回来是可以工作的,甚至是保存到一个long类型的变量里,都是可行的。

这个问题解决起来并不困难,最重要的是要识别出这种场景来,如果在线程函数内部没有识别到这一点,直接做由void *到Base2 *的转换,就会出现上述问题。正确的做法是,对象指针先原样转换回Derived *类型,然后再转换为Base2 *类型,这样在第二次转换时,编译器就可以根据两个类型的关系,作出正确的偏移量计算了。

此时再调用虚函数,就会调用到正确的版本了:

    Base2 *p3 = (Base2 *)(Derived *)pv;
    p3->f2();

小结

由于C++在处理多继承和虚函数的实现方式,多继承情况下类型的向上转型需要对指针做偏移操作,才可以让转换后的指针指向正确的基类对象(虚表及数据成员),同时由于这个转换无法在编译器无法获知其继承体系的情况下进行,故在处理此类转换时,要当心子类实际类型的丢失造成的计算错误。如果无法避免转向void * ,其解决方法,就是把丢失的类型原样找回来后,再进行向上转型。