从C++取地址操作看对象内存布局

从C++取地址操作看对象内存布局

对于一个C++对象,取地址存入一个指针,不同类型的指针拿到的值是一样的吗?

答案是不一定!

我们直接考察带虚函数的单继承和多继承两种场景。

测试样例

示例代码如下:

#include <stdio.h>
#include <stdint.h>

class A {
public:
    virtual void funA() {}
    int64_t a;
};
class B {
public:
    virtual void funB() {}
    int64_t b;
};
class C : public A {
public:
    virtual void funcC() {}
    int64_t c;
};
class D : public A, public B {
public:
    virtual void funD() {}
    int64_t d;
};

int main() {
    {
        C c;
        void *p = &c;
        A *a = &c;
        printf("%p %p %p %p %p\n", p, a, &(c.a), &c, &(c.c));
    }
    {
        D d;
        void *p = &d;
        A *a = &d;
        B *b = &d;
        printf("%p %p %p %p %p %p %p\n", p, a, &(d.a), b, &(d.b), &d, &(d.d));
    }
}

本机运行结果如下:

0x7fffe3d44ec0 0x7fffe3d44ec0 0x7fffe3d44ec8 0x7fffe3d44ec0 0x7fffe3d44ed0
0x7fffe3d44ec0 0x7fffe3d44ec0 0x7fffe3d44ec8 0x7fffe3d44ed0 0x7fffe3d44ed8 0x7fffe3d44ec0 0x7fffe3d44ee0

可以看到:

  • 对于单继承,直接取地址、用父类型指针存地址,结果都是一样的;
  • 对于多继承,直接取地址、用第一个父类型指针存地址,结果一样,用第二个父类型存地址结果不一样;

我们结合 gcc 和 clang 查看对象内存布局,进一步分析。

内存布局分析

使用 gcc -std=c++17 -fdump-class-hierarchy test-class-layout.cpp 命令,文件输出如下:

Vtable for A
A::_ZTV1A: 3 entries
0     (int (*)(...))0
8     (int (*)(...))0
16    (int (*)(...))A::funA

Class A
   size=16 align=8
   base size=16 base align=8
A (0x0x7f982b13e000) 0
    vptr=((& A::_ZTV1A) + 16)

Vtable for B
B::_ZTV1B: 3 entries
0     (int (*)(...))0
8     (int (*)(...))0
16    (int (*)(...))B::funB

Class B
   size=16 align=8
   base size=16 base align=8
B (0x0x7f982b13e0c0) 0
    vptr=((& B::_ZTV1B) + 16)

Vtable for C
C::_ZTV1C: 4 entries
0     (int (*)(...))0
8     (int (*)(...))0
16    (int (*)(...))A::funA
24    (int (*)(...))C::funcC

Class C
   size=24 align=8
   base size=24 base align=8
C (0x0x7f982af7d1a0) 0
    vptr=((& C::_ZTV1C) + 16)
  A (0x0x7f982b13e180) 0
      primary-for C (0x0x7f982af7d1a0)

Vtable for D
D::_ZTV1D: 7 entries
0     (int (*)(...))0
8     (int (*)(...))0
16    (int (*)(...))A::funA
24    (int (*)(...))D::funD
32    (int (*)(...))-16
40    (int (*)(...))0
48    (int (*)(...))B::funB

Class D
   size=40 align=8
   base size=40 base align=8
D (0x0x7f982af8e930) 0
    vptr=((& D::_ZTV1D) + 16)
  A (0x0x7f982b13e240) 0
      primary-for D (0x0x7f982af8e930)
  B (0x0x7f982b13e2a0) 16
      vptr=((& D::_ZTV1D) + 48)

使用 clang -Xclang -fdump-record-layouts test-class-layout.cpp 命令,终端输出如下:

*** Dumping AST Record Layout
         0 | class A
         0 |   (A vtable pointer)
         8 |   int64_t a
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class C
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int64_t a
        16 |   int64_t c
           | [sizeof=24, dsize=24, align=8,
           |  nvsize=24, nvalign=8]

*** Dumping AST Record Layout
         0 | class B
         0 |   (B vtable pointer)
         8 |   int64_t b
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class D
         0 |   class A (primary base)
         0 |     (A vtable pointer)
         8 |     int64_t a
        16 |   class B (base)
        16 |     (B vtable pointer)
        24 |     int64_t b
        32 |   int64_t d
           | [sizeof=40, dsize=40, align=8,
           |  nvsize=40, nvalign=8]

根据输出,可以得出类型D的内存布局情况:

+--------------+    <- ptrA, ptrD
|  vtable-A-D  |
+--------------+
|  members-A   |
+--------------+    <- ptrB
|  vtable-B    |
+--------------+
|  members-B   |
+--------------+
|  members-D   +
+--------------+

观察可以发现:

  • 父类虚函数表和成员变量排放在子类成员变量之前;
  • 从每个父类继承来的虚表和成员变量按照声明顺序依次排列,每个父类的虚表和成员变量紧密排列;
  • 成员变量按照声明顺序依次排列;
  • 子类型的虚函数追加在第一个父类的虚函数表尾部;
  • 子类型地址存入不同父类指针时,指向各自类型对应的虚函数表位置;
  • 直接获取子类型地址时,指向对象头部,也就是第一个虚函数表位置。

结论

可以看到,当使用父类指针操作子类对象时,指针指向父类虚函数表坐在偏移位置,此时内存分布和直接操作一个父类对象是一致的。
这种设计,尽可能保证了多态之下成员变量操作的效率。

扩展

会有什么问题吗?
有的!

考虑菱形继承,此时祖先类型的成员变量和虚函数会在子类型中重复出现!
这同样是出于上面所说的操作效率考虑,保证使用父类指针操作时,无论使用哪一个父类都能高效操作祖先类型的成员,因而必须在两个父类各自保存一份祖先类型的信息。

存在解决办法吗?存在。
如果是内存空间非常有限的情况,可以考虑使用虚继承,砍掉重复的成员;代价是操作效率会降低。
对比一下多继承和虚继承,前者时间高效空间低效,后者空间高效时间低效。

不过一般而言,空间都没有那么紧张,所以虚继承很少使用。事实上,就连多继承都是不提倡的,一般只使用单继承……

posted @ 2022-06-10 12:33  与MPI做斗争  阅读(215)  评论(0编辑  收藏  举报