虚函数和多继承的对象布局

时间:2009-08-24 08:13:09

标签: c++ multiple-inheritance vtable virtual-inheritance memory-layout

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

  1. C类对象的确切内存布局是什么。
  2. C类的虚拟表条目。
  3. A,B,C类对象的大小(由sizeof返回)。(8,8,16 ??)
  4. 如果使用虚拟继承怎么办?当然,大小和虚拟表条目应该受到影响吗?
  5. 示例代码:

    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 个答案:

答案 0 :(得分:13)

内存布局和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个字节,为其成员int c提供4个字节。

Vtable for C
C::_ZTV1C: 6u entries
0     (int (*)(...))0
4     (int (*)(...))(& _ZTI1C)
8     A::funA
12    (int (*)(...))-0x00000000000000008
16    (int (*)(...))(& _ZTI1C)
20    B::funB

Class C
   size=20 align=4
   base size=20 base align=4
C (0x40bd5e00) 0
    vptr=((& C::_ZTV1C) + 8u)
  A (0x40bd6080) 0
      primary-for C (0x40bd5e00)
  B (0x40bd60c0) 8
      vptr=((& C::_ZTV1C) + 20u)

使用虚拟继承

class C : public virtual A, public virtual B

布局变为

Vtable for C
C::_ZTV1C: 12u entries
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

VTT for C
C::_ZTT1C: 3u entries
0     ((& C::_ZTV1C) + 16u)
4     ((& C::_ZTV1C) + 28u)
8     ((& C::_ZTV1C) + 44u)

Class C
   size=24 align=4
   base size=8 base align=4
C (0x40bd5e00) 0
    vptridx=0u vptr=((& C::_ZTV1C) + 16u)
  A (0x40bd6080) 8 virtual
      vptridx=4u vbaseoffset=-0x0000000000000000c vptr=((& C::_ZTV1C) + 28u)
  B (0x40bd60c0) 16 virtual
      vptridx=8u vbaseoffset=-0x00000000000000010 vptr=((& C::_ZTV1C) + 44u)

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

答案 1 :(得分:4)

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

答案 2 :(得分:3)

首先,多态类至少有一个虚函数,所以它有一个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 ++子集(这里是没有virtual的C ++)。我将__放在编译器生成的名称中。

请注意,这是简单模型,其中不支持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指针,类型是兼容性;在非零偏移处,我们必须使用char*上的算术进行指针调整。

对于生成的代码而言,缺乏调整似乎并不是什么大不了的事(只是一些添加即时的asm指令),但它意味着更多,这意味着这样的指针可以被视为具有不同的类型:类型A__vtable*的对象可以包含指向Der__vtable的指针,并被视为Der__vtable*A__vtable*。相同的指针对象在处理类型A__vtable的对象的函数中作为指向A的指针,在处理类型Der__vtable的对象的函数中作为指向Der的指针

// 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分配了一个新值(在这种特殊情况下,对基类构造函数的调用没有任何用处并且可以进行优化,但是对于非平凡的构造函数来说,情况并非如此。

具有多重继承:

struct C : A, B {};

C个实例将包含AB,如下所示:

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

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

  • 将指针转换为其他基类(upcast)需要一个 调整;相反,上流需要相反的调整;

  • 这意味着在使用基类进行虚拟调用时 指针,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中使用B; C__printaddr不兼容,但可用于涉及C个对象或从C派生的类的调用。

非虚拟成员函数就像一个可以访问内部资源的自由函数。虚拟成员函数是&#34;灵活点&#34;可以通过覆盖来定制。虚拟成员函数声明在类的定义中起特殊作用:与其他成员一样,它们是与外部世界的契约的一部分,但同时它们是与派生类的契约的一部分。

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

当进一步派生一个类时,说Der2来自Der,隐式转换类型Der2*A*的指针在语义上执行:第一步,验证到Der*的转换(使用通常的public / protected / private / friends规则检查Der2Der的继承关系的访问控制),然后访问控制权DerA。无法在派生类中细化或覆盖非虚拟继承关系。

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

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

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

Top::iBottomLeft::i)中只有两个Right::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之间建立的继承关系不能通过进一步的派生来修改,因此存在类似关系的事实RightTop之间的关系不会影响这种关系。特别是,这意味着可以在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基类位于vBottom__subobject的固定位置,因此vBottom__subobject.__ptr用作整个vBottom的vptr。

语义:

继承关系由所有派生类共享;这意味着共享权限是共享的,因此vRight可以覆盖vLeft::foo。这样就可以分担责任:vLeftvRight必须就如何自定义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

在这里我们看到了一个冲突:vLeftvRight寻求定义唯一的foo虚函数的行为,并且vBottom定义因缺少常见的覆盖而出错。 / p>

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

实现:

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

具有虚拟基础的非虚基类的构造更复杂:在构造过程中,动态类型是基类类型,但虚拟基础的布局仍然是布局尚未构造的派生类型最多,因此我们需要更多的vtable来描述这种状态:

// 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的虚函数(如{{1}中所定义)翻译对象)。

语义:

在初始化期间,显然我们必须小心不要在初始化之前使用对象。因为C ++在对象完全初始化之前为我们命名,所以很容易做到:

vBottom__complete

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

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

很明显,必须仔细检查ctor-init-list中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可以传递给其他函数并在某些集合中注册(直到破坏开始)。

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

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

  • 然后基类按从左到右的顺序构建:Top子对象被构造并变为普通vLeft(但具有vLeft布局),所以vBottom基类子对象现在有一个Top动态类型;

  • vLeft子对象构造开始,基类的动态类型更改为vRight;但是vRight并非来自vRight,并且对vLeft一无所知,因此vLeft基数现已被破坏;

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

答案 3 :(得分:0)

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

让我先介绍一下对齐方式:

&#34;当a是n个字节的倍数(其中n是2的幂)时,存储器地址a被称为n字节对齐。在该上下文中,字节是存储器访问的最小单元,即每个存储器地址指定不同的字节。当以二进制表示时,n字节对齐的地址将具有log2(n)最低有效零。

b位对齐的备用字符表示b / 8字节对齐的地址(例如,64位对齐,8字节对齐)。

当访问的数据长度为n个字节且数据地址为n字节对齐时,称存储器访问是对齐的。如果内存访问未对齐,则称其未对齐。请注意,根据定义,字节存储器访问始终是对齐的。

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

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

数据结构可以存储在堆栈的内存中,其静态大小称为有界,或者在堆上具有称为无界的动态大小。&#34; - 来自维基......

为了保持对齐,编译器在结构/类对象的编译代码中插入填充位。 &#34; 虽然编译器(或解释器)通常在对齐的边界上分配单独的数据项,但数据结构通常具有具有不同对齐要求的成员。为了保持正确的对齐,翻译器通常会插入其他未命名的数据成员,以便每个成员都正确对齐。此外,整个数据结构可以用最终未命名的成员填充。这允许结构阵列的每个成员正确对齐。 .... ....

填充仅在结构成员后跟一个具有较大对齐要求的成员或在结构的末尾时插入&#34; - 维基

要获得有关GCC如何做的更多信息,请查看

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

并搜索文本&#34; basic-align&#34;

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

使用示例类,我为在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&amp; 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(填充字节)+ 8(VPTR B)+ 4(int b)+ 4(int c)= 32字节。所以C的总大小将是32个字节。