C++类对象内存映射

7

当我们创建一个类的对象时,它的内存映射是什么样子的?我更感兴趣的是对象如何调用非虚拟成员函数。编译器是否会创建一个类似于vtable的表格,该表格在所有对象之间共享?

class A
{
public:
  void f0() {}
  int int_in_b1;
};

A * a = new A;
a的内存映射将是什么?

4
如果想了解C++对象如何被建模(我说“可以”是因为有多种实现C++内部的方式),我建议阅读Stanley Lippman的《深度探索C++对象模型》。 - R Samuel Klatchko
如果你纠正了你的代码,为什么不用汇编输出运行你的编译器,看看它生成了什么? - JUST MY correct OPINION
4个回答

13
你可以想象一下这段代码:
struct A {
  void f() {}
  int int_in_b1;
};

int main() {
  A a;
  a.f();
  return 0;
}

被转化成类似于:

struct A {
  int int_in_b1;
};
void A__f(A* const this) {}

int main() {
  A a;
  A__f(&a);
  return 0;
}

调用f很简单,因为它是非虚函数。(有时候对于虚函数的调用,如果对象的动态类型已知,那么可以避免虚分派,就像这里一样。)


一个更长的例子,它要么让你了解虚函数是如何工作的,要么会让你感到非常困惑:
struct B {
  virtual void foo() { puts(__func__); }
};
struct D : B {
  virtual void foo() { puts(__func__); }
};

int main() {
  B* a[] = { new B(), new D() };
  a[0]->foo();
  a[1]->foo();
  return 0;
}

变成类似这样的东西:

void B_foo(void) { puts(__func__); }
void D_foo(void) { puts(__func__); }

struct B_VT {
  void (*foo)(void);
}
B_vtable = { B_foo },
D_vtable = { D_foo };

typedef struct B {
  struct B_VT* vt;
} B;
B* new_B(void) {
  B* p = malloc(sizeof(B));
  p->vt = &B_vtable;
  return p;
}

typedef struct D {
  struct B_VT* vt;
} D;
D* new_D(void) {
  D* p = malloc(sizeof(D));
  p->vt = &D_vtable;
  return p;
}

int main() {
  B* a[] = {new_B(), new_D()};
  a[0]->vt->foo();
  a[1]->vt->foo();
  return 0;
}

每个对象仅有一个vtable指针,并且您可以将许多虚方法添加到类中,而不会影响对象大小。(vtable会增长,但这是存储在每个类中一次,不会占用重要的空间开销。)请注意,在此示例中,我已经简化了许多细节,但它确实有效:析构函数未被处理(此处应该另外是虚拟的),它会泄漏内存,并且 __func__ 值将略有不同(它们由编译器生成以当前函数名称为基础)。还有其他问题。


第二个例子是我几周前写的,现在我看到我忘记添加this指针了,即使它们没有被使用。如果您不知道如何添加它们,请告诉我,我可以进行编辑;否则,我将保持与codepad链接中编译代码相同的方式。 - Roger Pate

3
认识到C++语言并没有规定或强制规定有关对象内存布局的所有内容。尽管如此,大多数编译器都会做相同的事情。
在您的示例中,类型A的对象仅需要足够的内存来容纳一个int。由于它没有虚函数,因此不需要虚表。如果f0成员被声明为虚的,则类型A的对象通常以指向类A虚表(由所有类型A的对象共享)的指针开头,后跟int成员。
反过来,虚表具有每个虚函数的指针,这些函数被定义、继承或覆盖。为对象调用虚函数包括从对象到虚表的指针,然后使用虚表中的固定偏移量(针对每个虚函数在编译时确定)查找要调用的函数的地址。

1
@Peter:函数对类的大小和布局没有影响。函数就像你编写的任何其他函数一样,它们驻留在内存中等待调用。成员函数唯一的特殊之处在于它们有一个隐式的“this”指针,你看不到它。 - GManNickG
1
@Peter:和其他函数一样。不涉及任何技术寻址业务,该函数具有一些编译地址。编译器使用此地址在类实例上调用函数。 - GManNickG
1
对于编译器而言,在 a.f0() 中找到要调用的函数地址与调用全局函数没有区别:它只是一个全局函数名。编译器生成直接调用指令,链接器进行修复。关键在于,对于非虚函数,编译器会“缠绕”名称,因此两个不同类中具有相同名称的非虚函数,在完整程序中将具有两个不同的全局名称。例如,Class A 的 f0() 可能在全局被称为 __1A2f0vv。 - MtnViewMark
1
@Peter:可访问性(public,protected,private)在编译时进行检查。如果你违反了它,编译器将会报错并不发出代码(例如汇编)。同样地,在检查非静态成员函数是否用类的实例调用时也是如此:尝试使用无效内容就会得到一个错误。 - Roger Pate
@Peter:为了更清楚,一旦编译完成,就没有强制执行可访问性。例如,在汇编中编写,可以调用私有或受保护的成员,并调用非静态方法而不需要实例。 - MtnViewMark
显示剩余3条评论

1

函数不是按照它们所在的类进行存储的。

通常编译器会将任何成员函数视为其他函数一样处理,只是会添加一个“this”指针参数。当您调用该函数时,该指针会自动传递到基于所调用对象的地址的函数中。

所有函数(静态函数、成员函数或虚拟成员函数)都以相同的方式存储在内存中,它们只是函数而已。

当编译器构建代码时,它几乎会硬编码它在内存中的位置,然后链接器会通过您的代码并将“调用该名称的函数”命令替换为“调用该硬编码地址处的函数”。


0
class A
{
public:
  void f0() {}
  void f1(int x) {int_in_b1 = x; }
  int int_in_b1;
};

A *a = new A();

在内部实现时(函数名称实际上是被压缩的),它被表示为:

struct A
{
  int int_in_b1;
};

void Class_A__constructor(struct a*) {} // default constructor
void Class_A__f0(struct a*) {}
void Class_A__f1(struct a*, int x) {a->int_in_b1 = x;}

// new is translated like this: (inline)
void* new() {
  void* addr = malloc(sizeof(struc a));
  Class_A__constructor(addr);
  return addr;
}

可以通过在目标文件上执行“nm”命令(带有混淆名称的结果)来进行验证。

@Roger:谢谢,我没有注意到。 - Phong

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接