C++虚函数对象内存模型分析

编写测试代码如下,定义一个基类Base,并定义一个虚函数 func。定义一个子类Derived,继承基类Base,并覆盖虚函数 func 

main函数中创建临时对象 Derived, 并使用基类指针指向Derived对象,通过指针调用虚函数 func。调用结果如下:

指令层面分析

首先进入分析main函数代码:

上述是main函数的汇编指令,由于是局部申明对象,申明后的对象在栈中保存,保存地址是 rbp - 0x10,将该地址通过rdi寄存器传参,然后调用Derived的构造函数。调用完构造函数后,先取对象首地址处的值拿到虚表指针,然后取虚表指针的第一个值,拿到虚函数地址,并调用虚函数。

接着进入到Derived的构造函数处分析:

这是一个套壳的构造函数,实际上只是对真实构造函数的调用,进入到真实构造函数处:

构造函数的参数就是对象的首地址,也就是this指针,构造函数先调用基类Base的构造函数,调用完成后,将Derived类基表+0x10处的地址保存在了对象首地址处。看下基类的构造函数:

基类的构造函数同样是将基类的虚表地址+0x10 保存在了对象首地址处。那么继续看下这两个类的虚表数据:

虚表的首地址是 0,然后是该类的type_info,虚表+0x10处才是第一个虚函数的地址。最后面保存的是盖类的析构函数地址。

结论:

1、如果类中有定义虚函数,编译器会生成该类的虚函数表,并在创建对象时,在对象的头部保存虚指针。

2、对象初始化时,在对象的构造函数中,会调用基类的构造函数,在构造函数中会初始化虚指针,基类构造函数中先初始化虚指针,然后在子类构造函数中初始化时,覆盖了基类构造函数中初始化的虚指针值。

3、虚函数不能定义成构造函数,因为虚函数通过虚指针来定位虚表中的虚函数,而虚指针在构造函数完成时才会完成初始化。

4、虚表是一个指针数组,gcc实现中虚表第一个指针保留0,第二个指针存储typeinfo信息指针,第三个指针开始存放虚函数。

多继承下的虚指针和虚表结构

#include <iostream>
 
// 基类
class Base {
public:
    // 虚函数
    virtual void func() {
        std::cout << "Base::func called" << std::endl;
    }
 
    // 虚析构函数,确保正确销毁派生类对象
    virtual ~Base() {}
};
 
// 基类
class Base1 {
public:
    // 虚函数
    virtual void func1() {
        std::cout << "Base1::func called" << std::endl;
    }
 
    // 虚析构函数,确保正确销毁派生类对象
    virtual ~Base1() {}
};
 
// 基类
class Base2 {
public:
    // 虚函数
    virtual void func2() {
        std::cout << "Base2::func called" << std::endl;
    }
 
    // 虚析构函数,确保正确销毁派生类对象
    virtual ~Base2() {}
};
 
// 派生类
class Derived : public Base , public Base1 , public Base2 {
public:
    // 重写基类的虚函数
    void func() override {
        std::cout << "Derived::func called" << std::endl;
    }
    // 重写基类的虚函数
    void func1() override {
        std::cout << "Derived::func1 called" << std::endl;
    }
};
 
// 主函数
int main() {
    // 创建Derived对象
    Derived obj;
 
    // 基类指针指向Derived对象
    Base* basePtr = &obj;
 
    // 调用虚函数,实际调用的是Derived::func
    basePtr->func();
     
    Base2* base2Ptr = &obj;
    base2Ptr->func2();
 
    return 0;
}

上述代码在前面的基础上,新增了两个类用于多继承,其中Base2中的虚方法没有覆盖,同样直接分析对象的构造函数:

这个在构造函数中,依次按顺序调用了3个父类的构造函数,传入的地址并不是同一个,而是对象3个虚指针的地址,调用后,分别初始化对象的这3个虚指针值为这3个父类的虚表地址。接着用对象的虚表地址+偏移覆盖了这3个虚指针。

新的对象虚表构造如下:

0x10偏移处正好是子类 func虚函数,也就是第一个虚函数的地址。 0x40偏移处是子类 func1 虚函数的地址。而 0x68处是Base2父类的func2虚函数地址。

源码中对应的Base2→func2虚函数的调用过程:

结论:

1、子类如果继承多个基类,子类对象头部会有多个虚指针,按继承顺序排列。

2、子类的构造函数中,会依次调用基类的构造函数,并分别传入对应的虚指针地址,在内部会初始化这个虚指针

3、子类的构造函数中,会再次初始化多个虚指针,分别指向子类的虚函数表不同偏移处,实际上就是子类的多个虚指针指向的是同一个虚表的不同偏移处,逻辑上认为是多个虚表。

4、子类如果覆盖了基类的虚函数,虚表中指针指向子类的虚函数,否则会指向基类的虚函数。

多继承带成员变量和虚函数时

修改demo ,在类中增加成员变量:

#include <iostream>
 
// 基类
class Base {
public:
    int base;
    void* ptr;
    // 虚函数
    virtual void func() {
        std::cout << "Base::func called" << std::endl;
    }
 
    // 虚析构函数,确保正确销毁派生类对象
    virtual ~Base() {}
};
 
// 基类
class Base1 {
public:
    long long base1;
    void* ptr1;
    // 虚函数
    virtual void func1() {
        std::cout << "Base1::func called" << std::endl;
    }
 
    // 虚析构函数,确保正确销毁派生类对象
    virtual ~Base1() {}
};
 
// 基类
class Base2 {
public:
    int base2;
    void* ptr2;
    // 虚函数
    virtual void func2() {
        std::cout << "Base2::func called" << std::endl;
    }
 
    // 虚析构函数,确保正确销毁派生类对象
    virtual ~Base2() {}
};
 
// 派生类
class Derived : public Base , public Base1 , public Base2 {
public:
    int derived;
    void*ptrDeri;
     
    // 重写基类的虚函数
    void func() override {
        std::cout << "Derived::func called" << std::endl;
    }
    // 重写基类的虚函数
    void func1() override {
        std::cout << "Derived::func1 called" << std::endl;
    }
};
 
// 主函数
int main() {
    // 创建Derived对象
    Derived obj;
     
    obj.derived = 0xEE;
 
    // 基类指针指向Derived对象
    Base* basePtr = &obj;
    //给基类属性赋值
    basePtr->base = 0xFF;
     
    // 调用虚函数,实际调用的是Derived::func
    basePtr->func();
     
    Base2* base2Ptr = &obj;
    base2Ptr->func2();
 
    return 0;
}

Base类中增加有 int 和 void* 类型成员,Base1中增加有 long long 和 void* 成员,Base2类中增加有 int 和 void* 成员,子类中也增加 int 和 void*成员。

我们先看子类的构造方法:

 从构造函数中可以看到,这次3个虚指针在对象中并没有连续保存,而是保存在了 偏移 0 0x18 和 0x30处,也就是 0 24 和 48。接着我们查看源码中对 derived 和 base成员变量赋值的汇编代码:

从汇编代码可以看出,derived 成员在对象+0x48处保存,base成员在对象+0x8处保存。

综合以上分析可以得出结论:

1、多继承内存模型中,按继承顺序依次保存 虚指针和成员变量 。例如上述例子中 0~0x17 保存第一个虚指针和 base(4字节int类型base被对齐到8字节)、ptr成员,0x18~0x2F字节保存第二个虚指针和base2、ptr2成员、0x30~0x47保存base3和ptr3成员,0x48~0x57保存 derived 和 ptrDeri成员。

2、基类的构造函数中,传入的是指向虚指针的地址,后面紧接着是该基类对应的成员变量,因此该虚指针地址处可以强制转成该基类类型进行成员变量初始化操作。

clang++编译后测试,发现虚表的实现、虚指针初始化方式、多继承下虚指针和成员变量的布局方式和gcc一样

由于某次面试时面试官问虚函数是否可以是构造函数,当时忘记了,一脸懵逼,故而写个demo分析下c++虚函数和虚函数表的原理。通过上述分析,生成了如下八股文:

C++虚函数与虚表

C++中有虚函数和纯虚函数的概率,纯虚函数主要用于定义接口,如果类中有纯虚函数,那么这个类是抽象类,不能实例化。同时如果有子类继续这个抽象类,那么子类必须实现全部接口,否者子类也是抽象类,不能实例化。

C++中虚函数是为了实现面向对象的多态功能。

当一个类继承于多个基类时,如果基类中包含虚函数,那么这个类的对象实例中,会有虚指针,虚指针的值在对象的构造函数执行完后,会指向类的虚表。

在类的构造函数中,会依此执行基类的构造函数,而传入基类构造函数的指针,指向一个虚指针地址,或者继承类的首个继承成员地址,也就是说多继承中,对象的内存分布依此是第一个基类对应的虚指针,继承自第一个基类的成员,第二个基类对应的虚指针,继承自第二个基类的成员,依此排布,或者没有虚指针。
那么可以理解了,在类的构造函数中调用基类的构造函数时,传入的是对象中该基类对应的虚指针地址或第一个成员地址,这样在基类的构造函数中会初始化这个虚指针和成员值。然后执行完基类的构造函数后,再继续执行子类的构造函数,会覆盖所有虚指针的值,指向子类的虚函数表,虚函数表是一个连续分布的内存空间,在 gcc和 clang的实现中,虚函数表的一个指针赋值为0,作为保留字段,第二个指针指向类的 type_info,从第三个指针开始,指向第一个虚函数地址,如果子类覆盖了基类的虚函数,那么这里的指针将指向子类的虚函数地址,否则指向基类的虚函数地址。

因此在构造函数中,初始化虚指针时,实际上是把虚表地址+0x10的值赋值给了虚指针。

由于虚函数通过虚指针索引虚表进行调用,而虚指针在类的构造函数中完成初始化,因此 虚函数不能用作构造函数。但是这些只是 c++语言的概念,在汇编实现中,任何高级语言的概念最终都只是汇编语言层面的内存和寄存器的操作。在编译器层面,本身就知道虚函数表在内存常量区的地址,因此即使不初始化虚指针,也可以获取到虚函数地址并调用,即虚函数定义为构造函数,理论上是可行。

  • 23
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值