10

我最近在一次采访中被问及涉及虚拟功能和多重继承的对象布局。
我在上下文中解释了它是如何在不涉及多重继承的情况下实现的(即编译器如何生成虚拟表,在每个对象中插入指向虚拟表的秘密指针等等)。
在我看来,我的解释中缺少一些东西。
所以这里有问题(见下面的例子)

  1. C类对象的确切内存布局是什么。
  2. C 类的虚拟表条目。
  3. A、B 和 C 类对象的大小(由 sizeof 返回)。(8、8、16 ??)
  4. 如果使用虚拟继承会怎样。肯定会影响大小和虚拟表条目吗?

示例代码:

class A {  
  public:   
    virtual int funA();     
  private:  
    int a;  
};

class B {  
  public:  
    virtual int funB();  
  private:  
    int b;  
};  

class C : public A, public B {  
  private:  
    int c;  
};   

谢谢!

4

4 回答 4

14

内存布局和 vtable 布局取决于您的编译器。例如,使用我的 gcc,它们看起来像这样:

sizeof(int) == 4
sizeof(A) == 8
sizeof(B) == 8
sizeof(C) == 20

请注意,sizeof(int) 和 vtable 指针所需的空间也可能因编译器和平台而异。sizeof(C) == 20 而不是 16 的原因是 gcc 为 A 子对象提供 8 个字节,为 B 子对象提供 8 个字节,为其成员提供 4 个字节int c

用于 C 的 Vtable
C::_ZTV1C: 6u 个条目
0 (int (*)(...))0
4 (int (*)(...))(& _ZTI1C)
8 A::funA
12 (int (*)(...))-0x00000000000000008
16 (int (*)(...))(& _ZTI1C)
20 B::funB

C类
   大小=20 对齐=4
   基本尺寸=20 基本对齐=4
C (0x40bd5e00) 0
    vptr=((& C::_ZTV1C) + 8u)
  一个(0x40bd6080)0
      主要用于 C (0x40bd5e00)
  B (0x40bd60c0) 8
      vptr=((& C::_ZTV1C) + 20u)

使用虚拟继承

class C : public virtual A, public virtual B

布局更改为

用于 C 的 Vtable
C::_ZTV1C: 12u 条目
0 16u
4 8u
8 (int (*)(...))0
12 (int (*)(...))(& _ZTI1C)
16 0u
20 (int (*)(...))-0x00000000000000008
24 (int (*)(...))(& _ZTI1C)
28 A::funA
32 0u
36 (int (*)(...))-0x00000000000000010
40 (int (*)(...))(& _ZTI1C)
44 B::funB

C 的 VTT
C::_ZTT1C: 3u 个条目
0 ((& C::_ZTV1C) + 16u)
4 ((& C::_ZTV1C) + 28u)
8 ((& C::_ZTV1C) + 44u)

C类
   尺寸=24 对齐=4
   基本尺寸=8 基本对齐=4
C (0x40bd5e00) 0
    vptridx=0u vptr=((& C::_ZTV1C) + 16u)
  A (0x40bd6080) 8 个虚拟
      vptridx=4u vbaseoffset=-0x0000000000000000c vptr=((& C::_ZTV1C) + 28u)
  B (0x40bd60c0) 16 个虚拟
      vptridx=8u vbaseoffset=-0x00000000000000010 vptr=((& C::_ZTV1C) + 44u)

使用 gcc,您可以添加-fdump-class-hierarchy以获取此信息。

于 2009-08-24T08:40:36.363 回答
5

多重继承的一件事是,当转换为(通常不是第一个)子类时,您的指针可能会发生变化。在调试和回答面试问题时应该注意的事情。

于 2009-08-24T09:20:16.720 回答
5

首先,多态类至少有一个虚函数,所以它有一个 vptr:

struct A {
    virtual void foo();
};

编译为:

struct A__vtable { // vtable for objects of declared type A
    void (*foo__ptr) (A *__this); // pointer to foo() virtual function
};

void A__foo (A *__this); // A::foo ()

// vtable for objects of real (dynamic) type A
const A__vtable A__real = { // vtable is never modified
    /*foo__ptr =*/ A__foo
};

struct A {
    A__vtable const *__vptr; // ptr to const not const ptr
                             // vptr is modified at runtime
};

// default constructor for class A (implicitly declared)
void A__ctor (A *__that) { 
    __that->__vptr = &A__real;
}

备注:C++ 可以编译为另一种高级语言,如 C(如 cfront 所做的那样),甚至可以编译为 C++ 子集(此处为 C++,没有virtual)。我输入__了编译器生成的名称。

请注意,这是一个不支持 RTTI的简单模型;真正的编译器会在 vtable 中添加数据以支持typeid.

现在,一个简单的派生类:

struct Der : A {
    override void foo();
    virtual void bar();
};

非虚拟 (*) 基类子对象是类似于成员子对象的子对象,但成员子对象是完整对象,即。它们的真实(动态)类型是它们声明的类型,基类子对象不完整,并且它们的真实类型在构造过程中发生了变化。

(*) 虚拟基础非常不同,例如虚拟成员函数与非虚拟成员不同

struct Der__vtable { // vtable for objects of declared type Der
    A__vtable __primary_base; // first position
    void (*bar__ptr) (Der *__this); 
};

// overriding of a virtual function in A:
void Der__foo (A *__this); // Der::foo ()

// new virtual function in Der:
void Der__bar (Der *__this); // Der::bar ()

// vtable for objects of real (dynamic) type Der
const Der__vtable Der__real = { 
    { /*foo__ptr =*/ Der__foo },
    /*foo__ptr =*/ Der__bar
};

struct Der { // no additional vptr
    A __primary_base; // first position
};

这里的“第一个位置”意味着成员必须是第一个(其他成员可以重新排序):它们位于偏移量零处,因此我们可以reinterpret_cast指针,类型是兼容的;在非零偏移处,我们必须使用算术 on 进行指针调整char*

就生成的代码而言,缺少调整可能看起来没什么大不了的(只是一些添加了立即的 asm 指令),但它的意义远不止于此,这意味着这些指针可以被视为具有不同的类型:类型的对象A__vtable*可以包含指向Der__vtable并被视为 aDer__vtable*或 a的指针A__vtable*。同一个指针对象用作指向A__vtable处理类型对象的 in 函数的指针和指向处理类型对象的 in 函数A的指针。Der__vtableDer

// default constructor for class Der (implicitly declared)
void Der__ctor (Der *__this) { 
    A__ctor (reinterpret_cast<A*> (__this));
    __this->__vptr = reinterpret_cast<A__vtable const*> (&Der__real);
}

您会看到 vptr 定义的动态类型在构造过程中发生变化,因为我们为 vptr 分配了一个新值(在这种特殊情况下,对基类构造函数的调用没有任何用处,可以优化掉,但它不是t 具有非平凡构造函数的情况)。

多重继承:

struct C : A, B {};

一个C实例将包含 aA和 a B,如下所示:

struct C {
    A base__A; // primary base
    B base__B;
};

请注意,这些基类子对象中只有一个可以拥有偏移量为零的特权;这在很多方面都很重要:

  • 将指针转换为其他基类(向上转换)将需要调整;相反,向上转型需要相反的调整;

  • 这意味着当使用基类指针进行虚拟调用时,this派生类覆盖器中的条目具有正确的值。

所以下面的代码:

void B::printaddr() {
    printf ("%p", this);
}

void C::printaddr () { // overrides B::printaddr()
    printf ("%p", this);
}

可以编译为

void B__printaddr (B *__this) {
    printf ("%p", __this);
}

// proper C::printaddr taking a this of type C* (new vtable entry in C)
void C__printaddr (C *__this) {
    printf ("%p", __this);
}

// C::printaddr overrider for B::printaddr
// needed for compatibility in vtable
void C__B__printaddr (B *__this) {
    C__printaddr (reinterpret_cast<C*>(reinterpret_cast<char*> (__this) - offset__C__B));
}

我们看到C__B__printaddr声明的类型和语义是兼容的B__printaddr,所以我们可以&C__B__printaddr在 ; 的 vtable 中使用BC__printaddr不兼容,但可用于涉及C对象或从C.

非虚拟成员函数就像一个可以访问内部内容的自由函数。虚拟成员函数是可以通过重写来定制的“灵活性点”。虚成员函数声明在类的定义中起着特殊的作用:像其他成员一样,它们是与外部世界的契约的一部分,但同时它们也是与派生类的契约的一部分。

非虚拟基类就像一个成员对象,我们可以通过覆盖来改进行为(我们也可以访问受保护的成员)。对于外部世界,Ain的继承Der意味着指针将存在隐式派生到基的转换,aA&可以绑定到Der左值等。对于进一步的派生类(派生自Der),这也意味着A在 : 中继承的Der虚函数A可以在进一步的派生类中被覆盖。

当一个类进一步派生时,比如Der2派生自,隐式转换类型为 toDer的指针在语义上逐步执行:首先,验证转换为(对from的继承关系的访问控制与通常的 public/protected /private/friend 规则),然后是to的访问控制。不能在派生类中细化或覆盖非虚拟继承关系。Der2*A*Der*Der2DerDerA

非虚成员函数可以直接调用,而虚成员必须通过 vtable 间接调用(除非编译器恰好知道真实对象类型),因此该virtual关键字为成员函数访问添加了间接性。就像函数成员一样,virtual关键字添加了对基对象访问的间接;就像函数一样,虚拟基类在继承中增加了一个灵活性点。

在进行非虚拟、重复、多重继承时:

struct Top { int i; };
struct Left : Top { };
struct Right : Top { };
struct Bottom : Left, Right { };

(和)中只有两个Top::i子对象,与成员对象一样:BottomLeft::iRight::i

struct Top { int i; };
struct mLeft { Top t; };
struct mRight { mTop t; };
struct mBottom { mLeft l; mRight r; }

没有人对有两个int子成员 (l.t.ir.t.i) 感到惊讶。

使用虚函数:

struct Top { virtual void foo(); };
struct Left : Top { }; // could override foo
struct Right : Top { }; // could override foo
struct Bottom : Left, Right { }; // could override foo (both)

这意味着有两个不同的(不相关的)虚函数称为foo,具有不同的 vtable 条目(由于它们具有相同的签名,因此它们可以具有共同的覆盖器)。

非虚基类的语义源于基本的、非虚的继承是一种排他关系:Left 和 Top 之间建立的继承关系不能通过进一步的推导来修改,因此存在相似关系的Right事实Top不能影响这个关系。特别是表示Left::Top::foo()可以在Left和 中覆盖Bottom,但是和Right没有继承关系 Left::Top,不能设置这个自定义点。

虚拟基类不同:虚拟继承是可以在派生类中自定义的共享关系:

struct Top { int i; virtual void foo(); };
struct vLeft : virtual Top { }; 
struct vRight : virtual Top { };
struct vBottom : vLeft, vRight { }; 

在这里,这只是一个基类子对象Top,只有一个int成员。

执行:

非虚拟基类的空间是基于派生类中具有固定偏移量的静态布局分配的。请注意,派生类的布局包含在更多派生类的布局中,因此子对象的确切位置不取决于对象的真实(动态)类型(就像非虚函数的地址是常量一样)。OTOH,具有虚拟继承的类中子对象的位置由动态类型确定(就像只有知道动态类型时才知道虚函数的实现地址一样)。

子对象的位置将在运行时通过 vptr 和 vtable(重用现有 vptr 意味着更少的空间开销)或指向子对象的直接内部指针(更多开销,需要更少的间接)来确定。

因为虚拟基类的偏移量仅针对一个完整对象确定,并且对于给定的声明类型是未知的,所以虚拟基类不能在偏移量零处分配,并且永远不是主基类。派生类永远不会重用虚拟基的 vptr 作为它自己的 vptr。

在可能的翻译方面:

struct vLeft__vtable { 
    int Top__offset; // relative vLeft-Top offset
    void (*foo__ptr) (vLeft *__this); 
    // additional virtual member function go here
};

// this is what a subobject of type vLeft looks like
struct vLeft__subobject { 
    vLeft__vtable const *__vptr;
    // data members go here
};

void vLeft__subobject__ctor (vLeft__subobject *__this) { 
    // initialise data members
}

// this is a complete object of type vLeft 
struct vLeft__complete {
    vLeft__subobject __sub;
    Top Top__base;
}; 

// non virtual calls to vLeft::foo
void vLeft__real__foo (vLeft__complete *__this);

// virtual function implementation: call via base class
// layout is vLeft__complete 
void Top__in__vLeft__foo (Top *__this) {
    // inverse .Top__base member access 
    char *cp = reinterpret_cast<char*> (__this);
    cp -= offsetof (vLeft__complete,Top__base);
    vLeft__complete *__real = reinterpret_cast<vLeft__complete*> (cp);
    vLeft__real__foo (__real);
}

void vLeft__foo (vLeft *__this) {
    vLeft__real__foo (reinterpret_cast<vLeft__complete*> (__this));
}

// Top vtable for objects of real type vLeft
const Top__vtable Top__in__vLeft__real = { 
    /*foo__ptr =*/ Top__in__vLeft__foo 
};

// vLeft vtable for objects of real type vLeft
const vLeft__vtable vLeft__real = { 
    /*Top__offset=*/ offsetof(vLeft__complete, Top__base),
    /*foo__ptr =*/ vLeft__foo 
};

void vLeft__complete__ctor (vLeft__complete *__this) { 
    // construct virtual bases first
    Top__ctor (&__this->Top__base); 

    // construct non virtual bases: 
    // change dynamic type to vLeft
    // adjust both virtual base class vptr and current vptr
    __this->Top__base.__vptr = &Top__in__vLeft__real;
    __this->__vptr = &vLeft__real;

    vLeft__subobject__ctor (&__this->__sub);
}

对于已知类型的对象,通过以下方式访问基类vLeft__complete

struct a_vLeft {
    vLeft m;
};

void f(a_vLeft &r) {
    Top &t = r.m; // upcast
    printf ("%p", &t);
}

被翻译成:

struct a_vLeft {
    vLeft__complete m;
};

void f(a_vLeft &r) {
    Top &t = r.m.Top__base;
    printf ("%p", &t);
}

这里的真实(动态)类型r.m是已知的,因此子对象的相对位置在编译时是已知的。但在这儿:

void f(vLeft &r) {
    Top &t = r; // upcast
    printf ("%p", &t);
}

的真实(动态)类型r未知,因此通过 vptr 进行访问:

void f(vLeft &r) {
    int off = r.__vptr->Top__offset;
    char *p = reinterpret_cast<char*> (&r) + off;
    printf ("%p", p);
}

此函数可以接受具有不同布局的任何派生类:

// this is what a subobject of type vBottom looks like
struct vBottom__subobject { 
    vLeft__subobject vLeft__base; // primary base
    vRight__subobject vRight__base; 
    // data members go here
};

// this is a complete object of type vBottom 
struct vBottom__complete {
    vBottom__subobject __sub; 
    // virtual base classes follow:
    Top Top__base;
}; 

请注意,vLeft基类位于 a 中的固定位置vBottom__subobject,因此vBottom__subobject.__ptr用作整个 的 vptr vBottom

语义:

继承关系由所有派生类共享;这意味着覆盖的权利是共享的,所以vRight可以覆盖vLeft::foo。这创造了责任分担:vLeft并且vRight必须就他们如何定制达成一致Top

struct Top { virtual void foo(); };
struct vLeft : virtual Top { 
    override void foo(); // I want to customise Top
}; 
struct vRight : virtual Top { 
    override void foo(); // I want to customise Top
}; 
struct vBottom : vLeft, vRight { };  // error

这里我们看到了一个冲突:vLeft并且vRight试图定义唯一的 foo 虚函数的行为,而vBottom定义是错误的,因为缺少一个通用的覆盖器。

struct vBottom : vLeft, vRight  { 
    override void foo(); // reconcile vLeft and vRight 
                         // with a common overrider
};

执行:

具有非虚拟基类和非虚拟基类的类的构造涉及以与成员变量相同的顺序调用基类构造函数,每次我们输入一个ctor时都会改变动态类型。在构造过程中,基类子对象的行为就像它们是完整的对象一样(这对于不可能的完整抽象基类子对象也是如此:它们是具有未定义(纯)虚函数的对象)。构造过程中可以调用虚函数和RTTI(当然纯虚函数除外)。

具有非虚基类的类的构造与虚基类比较复杂:构造时动态类型是基类类型,但虚基的布局仍然是尚未构造的最派生类型的布局,所以我们需要更多的虚表来描述这种状态:

// vtable for construction of vLeft subobject of future type vBottom
const vLeft__vtable vLeft__ctor__vBottom = { 
    /*Top__offset=*/ offsetof(vBottom__complete, Top__base),
    /*foo__ptr =*/ vLeft__foo 
};

虚函数是那些vLeft(在构造过程中,vBottom 对象生命周期尚未开始),而虚基位置是那些vBottom(在翻译的对象中定义vBottom__complete)。

语义:

在初始化过程中,很明显我们必须注意在初始化之前不要使用对象。因为 C++ 在对象完全初始化之前给了我们一个名字,所以很容易做到这一点:

int foo (int *p) { return *pi; }
int i = foo(&i); 

或在构造函数中使用 this 指针:

struct silly { 
    int i;
    std::string s;
    static int foo (bad *p) { 
        p->s.empty(); // s is not even constructed!
        return p->i; // i is not set!
    }
    silly () : i(foo(this)) { }
};

很明显,this在 ctor-init-list 中的任何使用都必须仔细检查。初始化所有成员后,this可以传递给其他函数并在某个集合中注册(直到销毁开始)。

不太明显的是,当构造一个涉及共享虚拟基的类时,子对象停止构造:在构造 a 期间vBottom

  • 首先构造虚拟基础:Top构造时,它像普通主体一样构造(Top甚至不知道它是虚拟基础)

  • 然后基类按从左到右的顺序构造:vLeft子对象被构造并成为正常功能vLeft(但具有vBottom布局),因此Top基类子对象现在具有vLeft动态类型;

  • vRight对象构造开始,基类的动态类型更改为 vRight;butvRight不是从 派生的vLeft,不知道任何关于vLeft的,所以vLeft基础现在被打破了;

  • Bottom构造函数的主体开始时,所有子对象的类型都已稳定并vLeft再次起作用。

于 2015-07-27T00:18:00.437 回答
1

我不确定如何在不提及对齐或填充位的情况下将此答案视为完整答案。

让我介绍一下Alignment的背景:

“当 a 是 n 字节的倍数(其中 n 是 2 的幂)时,内存地址 a 被称为 n 字节对齐。在这种情况下,字节是内存访问的最小单位,即每个内存地址指定一个不同的字节。当以二进制表示时,一个 n 字节对齐的地址将具有 log2(n) 个最低有效零。

备用措辞 b 位对齐指定 ab/8 字节对齐地址(例如 64 位对齐是 8 字节对齐)。

当被访问的数据是 n 字节长并且数据地址是 n 字节对齐时,就称内存访问是对齐的。当内存访问未对齐时,称为未对齐。请注意,根据定义,字节内存访问始终是对齐的。

如果只允许包含 n 字节对齐的地址,则指向 n 字节长的原始数据的内存指针称为对齐,否则称为未对齐。当(且仅当)聚合中的每个原始数据都对齐时,指向数据聚合(数据结构或数组)的内存指针是对齐的。

请注意,上面的定义假设每个原始数据都是两个字节长的幂。如果不是这种情况(如 x86 上的 80 位浮点),上下文会影响数据是否对齐的条件。

数据结构可以以称为有界的静态大小存储在堆栈上的内存中,也可以以称为无界的动态大小存储在堆上。” - 来自 Wiki...

为了保持对齐,编译器在结构/类对象的编译代码中插入填充位。" 尽管编译器(或解释器)通常在对齐边界上分配单个数据项,但数据结构通常具有具有不同对齐要求的成员。为了保持正确对齐,翻译器通常插入额外的未命名数据成员,以便每个成员正确对齐。此外作为一个整体的数据结构可以用最终的未命名成员填充。这允许结构数组的每个成员正确对齐.......

仅当结构成员后跟具有较大对齐要求的成员或在结构末尾时才插入填充” - Wiki

要获取有关 GCC 如何执行此操作的更多信息,请查看

http://www.delorie.com/gnu/docs/gcc/gccint_111.html

并搜索文本“basic-align”

现在让我们来解决这个问题:

使用示例类,我为在 64 位 Ubuntu 上运行的 GCC 编译器创建了这个程序。

int main() {
    cout << "!!!Hello World!!!" << endl; // prints !!!Hello World!!!
    A objA;
    C objC;
    cout<<__alignof__(objA.a)<<endl;
    cout<<sizeof(void*)<<endl;
    cout<<sizeof(int)<<endl;
    cout<<sizeof(A)<<endl;
    cout<<sizeof(B)<<endl;
    cout<<sizeof(C)<<endl;
    cout<<__alignof__(objC.a)<<endl;
    cout<<__alignof__(A)<<endl;
    cout<<__alignof__(C)<<endl;
    return 0;
}

该程序的结果如下:

4
8
4
16
16
32
4
8
8

现在让我解释一下。由于 A 和 B 都具有虚函数,它们将创建单独的 VTABLE,并且 VPTR 将分别添加到其对象的开头。

因此,A 类的对象将有一个 VPTR(指向 A 的 VTABLE)和一个 int。指针长度为 8 字节,int 长度为 4 字节。因此在编译之前大小为 12 字节。但是编译器会在 int a 的末尾添加额外的 4 个字节作为填充位。因此编译后,A 的对象大小将为 12+4 = 16。

对于 B 类的对象也是如此。

现在 C 的对象将有两个 VPTR(每个 A 类和 B 类一个)和 3 个整数(a、b、c)。所以大小应该是 8 (VPTR A) + 4 (int a) + 4 (padding bytes) + 8 (VPTR B) + 4 (int b) + 4 (int c) = 32 bytes。所以 C 的总大小将是 32 字节。

于 2015-07-20T05:09:03.680 回答