美文网首页C语言C语言&嵌入式IT狗工作室
第9篇:C++ 继承链的构造过程和内存布局

第9篇:C++ 继承链的构造过程和内存布局

作者: 铁甲万能狗 | 来源:发表于2019-11-13 18:07 被阅读0次

    在本文中,我们将继续深入研究C ++运行时动态调度的相关话题。 到目前为止,我们已经验证gdb不会Trivial类型的类和默认构造函数创建虚拟表,我们在本篇将阐述非虚拟派生类它们的构造和内存布局,这是正确区分虚拟类和非虚拟类在内存分配方面的差异的前提。

    示例导入

    让我引入两个新的类。一个基类Person和一个从Person继承的派生类Student。请注意,两者都使用其方法who()的相同签名。

    • 有了动态调度后,将查询vtable并调用适当的方法。
    • 如果没有动态分配,则将调用与对象的指针类型匹配的方法。 Student *m将调用Student:: who(),而Person *p将调用Person::who()。让我们用汇编代码验证一下。
    #include <stdio.h>
    
    class Person {
    public:
        Person() {}
        void who() {
            printf("I am a human!!\n");
        }
    
    };
    
    class Student: public Person{
    public:
        Student() {}
        void career() {
            printf("I am a student!!\n");
        }
    };
    
    int main() {
        Student *m = new Student();
        m->who();
    
        Person *p = m;
        p->who();
    }
    

    我们看到who()方法已经在编译时已经植入Student类的上下文,从callq 0400658 <_ZN7Student3whoEv>,这条指令表明运行时决策是不可能的。 指针的类型在编译时是已知的,编译器会选择正确的who()方法调用,同理callq 0x400626 <_ZN6Person3whoEv>,也是一条编译时的静态指令。下图说明类一切:


    但是我们也可以通过定义基类的名称空间来调用基类的方法,如下所示
    m->Person::who()
    

    内存布局

    为了降低问题的复杂性,我将使用此代码的一些细微变化,去掉一些who方法:

    class Person {
    public:
        int age=6;
        Person() {}
    };
    
    class Student: public Person{
    public:
        int idNo=1000;
        Student() {}
    };
    
    

    为了了解虚拟表的隐藏位置,让我们首先检查一下简单继承层次结构的内存布局。 让我们向Student和Person类添加一些整数变量。 使用此特定编译器的特定计算机上的sizeof(int)为4个字节。 但总是要记住,这个数字在不同硬件上尺寸可能不一样。我们在gdb中使用print命令输出代码中相关变量的地址,如下所示。

    (gdb) p &m
    $2 = (Student **) 0x7fffffffe2e8
    (gdb) p &p
    $3 = (Person **) 0x7fffffffe2e0
    (gdb) p *m
    $4 = {<Person> = {age = 6}, idNo = 1000}
    (gdb) p *p
    $5 = {age = 6}
    (gdb) p m
    $6 = (Student *) 0x602010
    (gdb) p p
    $7 = (Person *) 0x602030
    (gdb) 
    

    我们用绘制成如下图


    以低数字开头的内存位置(在本例中为0x602010)是在堆上分配给Student对象的。由main栈帧上的指针变量m(地址0x7fffffffe2e8)指向它。如此类推Person对象也在堆上的区域是0x602030的位置。 众所周知,堆向上增长,而栈向下增长。 对象在堆和栈帧的的组织方式在很大程度上取决于所使用的编译器和操作系统的内存管理方式。

    某些值可以完全优化并不需要入栈,直接并用寄存器代替。但本示例中没有使用任何编译的优化选项,因此仍然使用x86的约定组织程序栈。

    从上面的内存布局中,我们可以得出几点启示:

    • 基类指针p和派生指针都分别类的第一个字节。基类之后是派生类,位于更高的地址处。这个简单示例有帮于我们找到虚拟指针。
    • 派生类的内存分配一般来说会比父类的内存分配要大,因为派生类会从基类中继承(拷贝)了public和protected修饰的数据成员的副本。在本例中Student对象得到了Person对象的age副本,当你尝试使用sizeof(*m)得到的结果是8,而Person的内存分配尺寸则是4.

    继承链中的构造顺序

    这其实是前面文章谈论到多继承的RAII约定,我们从反汇编的角度,加深对此过程的了解


    派生类的初始化过程

    1. 在当前派生类构造函数的上下文的按照继承列表中的顺序执行依次初始化继承链中各个父类的构造函数,本示例如下步骤。
    • (1) 从main函数执行call 0x400640指令后,进入Student::Student()所在代码段中的上下文执行的一些入栈的操作(构造函数栈内存分配以及状态保存)。
    • (2) 执行call 0x44062c 即在Person类指令集所在的代码段地址初始化,在基类构造返回之前,汇编指令movl $0x6,(%rax),这个干了这些事:基类将数据成员-变量age=6作为返回值保存到rax寄存器中缓存的内存地址指向的位置(该位置在前一步的Student::Student构造函数的栈内存分配了,即-0x8(%rbp)的位置),以便派生类Student对象的构造函数读取作为它的数据成员。
    1. 返回派生类本身的构造函数执行剩余的指令集。
      继承的初始化过程

    垃圾回收的过程 ,和继承列表中定义的父类顺序相反。

    • 首先,调用函数在结束之时隐式执行子类的解构函数。
    • 然后,依次逆序执行子类继承列表中父类的解构函数。

    从汇编代码可知,在每个构造函数的的汇编上下文,在执行retq指令返回之前,当前的构造函数已经将初始化的一些局部变量缓存到可用的寄存器中缓存的内存地址所指向的位置了,当然通常是rax寄存器。

    小结

    不对派生类的成员进行任何更改而优先初始化基类的构造函数。 这对引入虚拟表时是一个非常重要的概念,因为此顺序定义了什么函数在什么阶段可见。

    相关文章

      网友评论

        本文标题:第9篇:C++ 继承链的构造过程和内存布局

        本文链接:https://www.haomeiwen.com/subject/usqabctx.html