多重继承派生类对象的内存结构分析以及相应派生类vptr调用多态的方法

目录

前言

多重继承的实现以及派生类对象的内存结构分析

总结


前言

继上一篇关于单一继承方式多态的vptr调用实现,Rock老师又讲解了关于多重继承的vptr调用实现,以及多重继承情况下对象的内存分配情况。

多重继承的实现以及派生类对象的内存结构分析:

  • 多重继承:

    • 当一个派生类继承自两个基类,这种情况我们称为多重继承。

    • 多重继承派生类对象的内存结构:

      • 我们首先用一个派生类Son继承自两个基类Father和Mother,在这里就只考虑虚函数吧,因为我们知道非虚函数是存储在代码段中的。

      • Father类中有三个虚函数分别为func1, func2, func3,为了方便查看全部为void func(void) 类型,并输出当前所在函数的位置,此设定适用于下面涉及的所有类内定义的虚函数。

      • Mother类中也有三个虚函数名字分别为cook1, cook2, cook3。

      • Son类中继承了以上两个类,继承顺序为Father然后时Mother,然后重写了一个func2,以及多写了一个func4。

      • 三个类中的构造函数均是传入int变量然后初始化内部变量,Son类需要初始化其所有基类的构造函数。

      • Father类中有两个成员变量 int x, y, Mother中有一个成员变量int z,然后是派生类中的一个成员变量int w,为了方便查看以上的所有成员全部为public权限。

      • 代码如下:

       #include 
       #include 
       /*
        * this is a test for case of multi-inheritance
        * */
       ​
       class Father{
       public:
           virtual void func1(){
               std::cout << "in father::func1" << std::endl;
           }
           virtual void func2(){
               std::cout << "in father::func2" << std::endl;
           }
           virtual void func3(){
               std::cout << "in father::func3" << std::endl;
           }
           Father(int x, int y):x_(x), y_(y){}
           //for simplicity, giving all member public authority
       public:
           int x_;
           int y_;
       };
       ​
       class Mother{
       public:
           virtual void cook1(){
               std::cout << "in mother::cook1" << std::endl;
           }
           virtual void cook2(){
               std::cout << "in mother::cook2" << std::endl;
           }
           virtual void cook3(){
               std::cout << "in mother::cook3" << std::endl;
           }
           explicit Mother(int z):z_(z){}
       public:
           int z_;
       };
       ​
       class Son:public Father, public Mother{
       public:
           virtual void func2(){
               std::cout << "in son::func2" << std::endl;
           }
           virtual void func4(){
               std::cout << "in son::func4" << std::endl;
           }
           Son(int x, int y, int z, int w): Father(x, y), Mother(z),w_(w){}
       public:
           int w_;
       };
      • 好了类定义结束了,首先说明一下这个派生类对象的内存构建方式,这个构建方式会随着不同的编译器有所变化,我现在说的方式是用于g++编译器。vscode应该也可以。派生类对象会按照顺序存储基类(从左到右)的虚函数指针,成员变量。如果派生类中有对于基类虚函数的重写则按照多态的机制根据传入对象的类型改变函数的调用情况,如果没有就直接把对应基类的vtable复制过去就好了。值得注意的是,如果在派生类中重新声明了一个所有基类都没有定义过的虚函数,那么这个虚函数会直接在基类vptr中按照顺序被添加,具体来说根据我们上述定义如果我们声明一个派生类Son的对象son,那么他的内存分配应当如下图所示:

      • 多重继承派生类对象的内存结构分析以及相应派生类vptr调用多态的方法_第1张图片

  • 所以我们知道了派生类的内存分布以后我们就可以通过vptr指针的偏移量访问所有的更新后的成员虚函数,以及成员变量。

    • 实现多态:

      • 为了方便,我们首先定义一个函数类型func,其类型与我们所有的虚函数定义一样。

      • 依旧地按照之前的方法我们来实现多态,和之前略微不同的是,由于我们有Mother类,访问Mother的成员函数需要将指针从Father的vptr偏移到Mother类的vptr,于是我们需要定义一个偏移量,配合sizeof()函数对vptr进行偏移,这里我们定义一个size_t类型的偏移量而不是int,这样更好的适配不同的操作系统,能用size_t还是尽量用size_t,这样代码可读性也更强一点。

      • 在vtbl内部的切换还是由我们的int indx来实现的,但是当超出vtbl就要进入到类内,这时需要了解内部成员变量的大小,在进行偏移,也就是第三个形参的作用。因为这二者需要分别传参,因此如果想要调用Mother的vtbl中的函数就需要更新相应的偏移量,重新调用一次。

      • 下面是多态函数的实现:

       //define a function type
       typedef void (*func)();
       ​
       func polymorphism_(Son& son, int indx, size_t offset = 0){
           uintptr_t** vptr = reinterpret_cast (&son);
           uintptr_t* vtbl = *(vptr + offset/sizeof(uintptr_t*));
           uintptr_t function = vtbl[indx];
           return reinterpret_cast(function);
       }
      • 然后我们开始对多态调用的实现进行一个测试,声明一个对象,把xyzw分别传入为1,2,3,4方便后续的成员变量访问的测试,现在开始调用多态:

       
      void test_4_polymorphism(){
           Son son(1, 2, 3, 4);
           for(int i=0; i<4; ++i){
               std::cout << "vptr -> no." << i << " function ->" << std::endl;
               polymorphism_(son, i)();
           }
           //get the offset derived from the vptr father: one vptr(uintptr_t**), two member function(int)
           //offset can be integer type
           size_t mother_offset = sizeof(uintptr_t**) + 2 * sizeof (int);
           for(int i=0; i<2; ++i){
               std::cout << "vptr -> no." << i << " function ->" << std::endl;
               polymorphism_(son, i, mother_offset)();
           }
           std::cout << "=============================polymorphism done=============================" << std::endl;
       }
      • 输出结果如下,正常退出且结果和我们想得一模一样(具体我们怎么想的看前面的图):

       /media/herryao/81ca6f19-78c8-470d-b5a1-5f35b4678058/work_dir/Document/computer_science/QINIU/projects/week02/day05/project03/cmake-build-debug/project03
       vptr -> no.0 function ->
       in father::func1
       vptr -> no.1 function ->
       in son::func2
       vptr -> no.2 function ->
       in father::func3
       vptr -> no.3 function ->
       in son::func4
       vptr -> no.0 function ->
       in mother::cook1
       vptr -> no.1 function ->
       in mother::cook2
       =============================polymorphism done=============================
       ​
       Process finished with exit code 0
       ​
    • 实现成员变量访问:

      • 首先我们要明确一个派生类对象的内存分布中,两个基类的vptr分别是两个隐形的成员变量,所以如果我们想访问我们的成员变量我们就要把这两个指针绕过去。

      • 现在我们来思考一下成员变量和成员虚函数有什么区别呢?

        • 虚函数首先我们要找到我们的对象的地址,然后这个地址就是我们的vptr,然后我们的vptr指向了我们的vtbl,我们的vtbl + 偏移量指向了我们的函数。所以我们的对象的指针对于函数对象来说是一个二级指针。

        • 但是我们的指针加偏移量对于我们的成员变量来说只是一个一级指针(这里有点乱,为什么一个相同的变量取出来可以是不同的类型呢,大家再好好思考一下这里面的逻辑),因为没有了中间指针vtbl了,因此我们只需要获取对象地址,然后加上偏移量最后解引用就获取到我们的成员变量了。

       //&obj = vptr -> vtbl -> func
       //^     ^       ^       ^
       //func**    func**  func*   func
      • 请注意静态变量以及非虚函数是不存储在对象的内存中的,当考虑偏移量时这一点要格外注意。

      • 下面是获取成员变量的函数,注意这两步转换我是为了和之前的操作保持一致,直接一步return,一次转换是完全可以的。

       int access_member(Son& son, size_t offset){
           uintptr_t* base = reinterpret_cast(&son);
           return * reinterpret_cast(base+offset);
       }
      • 现在我们来定义一个测试函数测试一下,依旧是相同的对象声明,最后打印出来的数据也应该是1, 2, 3, 4这么个顺序出来的。下面是我们的测试用代码:

       void test_4_member_access(){
           //===========================================================
           //access the member in father, one pointer size offset from vptr = vptr + 8
           Son son(1, 2, 3, 4);
       ​
           std::cout << "in father: " << std::endl;
           size_t father_base_offset = sizeof(uintptr_t*);  // size of vtable
           for(int i = 0; i < 2; ++i){
               size_t current_offset = father_base_offset + sizeof(int) * i;
               std::cout << "the " << (i == 0 ? "first member: " : "second member: ") << access_member(son, current_offset) << std::endl;
           }
       ​
       ​
           std::cout << "in mother: " << std::endl;
           size_t mother_base_offset = sizeof(uintptr_t*) * 2 + 2 * sizeof (int);
           //access the member in mother, one pointer size offset from vptr + 8 + 4 + 4 = vptr + 8 + 4 + 4 + 8
           std::cout << "the member in mother: " << access_member(son, mother_base_offset) << std::endl;
       ​
           std::cout << "in son: " << std::endl;
           size_t son_base_offset = sizeof(uintptr_t*) * 2 + 3 * sizeof (int);
           //access the member in mother, one pointer size offset from vptr + 8 + 4 + 4 = vptr + 8 + 4 + 4 + 8
           std::cout << "the member in mother: " << access_member(son, son_base_offset);
       }
      • 下面是我们的运行结果:

       /media/herryao/81ca6f19-78c8-470d-b5a1-5f35b4678058/work_dir/Document/computer_science/QINIU/projects/week02/day05/project03/cmake-build-debug/project03
       in father: 
       the first member: 1
       the second member: 2
       in mother: 
       the member in mother: 3
       in son: 
       the member in mother: 4
       Process finished with exit code 0

总结

  • 本次基于上一篇文章的实现进一步深入,分析了派生类对象的内存分布,并依据此分析实现了派生类对象通过vptr+偏移量 访问类内以及基类中的所有虚函数和相应的成员变量,进一步验证了这种内存分布的真实性。

  • 此访问方法针对不同的编译器可能有不同的逻辑,也就是说,这种内存分布并不是cpp内部的定义,所以这个学习仅为了更好的理解多态的运行机制,大家在平时工作时尽量避免这种操作。

  • 请大家尽量使用size_t而不是int(博主一开始就是用int 写的,最后花了很长时间才全部修改完毕),这样更有利于别的读者的理解,而且这个类型也是动态变化的,更有利于可移植性。

  • 在此顺便提一下返回size_t的一些操作符号吧

    • sizeof 操作符:

      • 这是最常见的返回 size_t 类型的操作符。

      • 它用于确定任何数据类型(包括基本类型、数组、指针、结构体等)在内存中所占的字节数。

    • 字符串操作函数:

      • 函数如 strlen(用于计算 C 风格字符串的长度)返回 size_t 类型的值。

    • 容器的大小方法:

      • 在 C++ 标准库中,许多容器类(如 std::vector, std::string, std::list, 等)有一个 .size() 方法,这个方法返回容器中元素的数量,类型为 size_t

    • 内存分配函数:

      • std::allocator 类的 max_size() 方法返回分配器可以最大分配的元素数量,返回类型是 size_t

    • 算法:

      • 标准库中的某些算法函数(例如 std::countstd::count_if)也会返回 size_t 类型,表示计数或其他类似的数量。

  • 上一篇文章中的一个错误我要澄清一下,long long类型是完全可以的,当时博主的测试代码写的不准让我以为有问题,其实逻辑很简单,你定义的类型能够覆盖相应系统的寻址范围即可,但是我还是十分建议使用uintptr_t 这种操作,首先是专业的事用专业的方法,其次,这种方法会随着操作系统的变化而自动动态调整,这样提升了代码的可移植性。

  • 刚刚更新了一下上一篇文章,我解释了为什么long long 是可以的,为什么int是不可以的,为什么同样是指针类型转换会对于一个在相同os上的指针有寻址大小的要求。如果这个问题一样困扰了你,请看我的上一片多态实现的文章。

致谢

  • 感谢奇牛学院 牛老师 的回答以及qq群里面的讨论,让我发现了前一篇文章的问题。
  • 继续感谢 Rock老师 的课程。
  • 感谢大家的支持,让我们一起越来越强,继续坚持再接再厉。

你可能感兴趣的:(c++学习,c++,开发语言,学习)