C++带有虚方法的对象大小

29

我对使用虚函数时对象大小有一些疑问。

1)虚函数

class A {
    public:
       int a;
       virtual void v();
    }

类 A 的大小为 8 字节,其中包含一个整数 (4 字节) 和一个虚拟指针 (4 字节)。

class B: public A{
    public:
       int b;
       virtual void w();
}

类B的大小是多少?我用“sizeof B”测试,结果输出12。

这是否意味着即使B类和A类都有虚函数,也只有一个vptr在那里?为什么只有一个vptr?

class A {
public:
    int a;
    virtual void v();
};

class B {
public:
    int b;
    virtual void w();
};

class C :  public A, public B {
public:
    int c;
    virtual void x();
};

C的大小为20字节......

在这种情况下,似乎布局中有两个vptrs.....这是怎么发生的?我认为其中一个vptr是用于类A,另一个是用于类B....所以对于类C的虚函数没有vptr?

我的问题是,在继承中vptrs数量的规定是什么?

2)虚拟继承

    class A {
    public:
        int a;
        virtual void v();
    };

    class B: virtual public A{                  //virtual inheritance 
    public:
        int b;
        virtual void w();
    };

    class C :  public A {                      //non-virtual inheritance
    public:
        int c;
        virtual void x();
    };

class D: public B, public C {
public:
    int d;
    virtual void y();
};

A类的大小为8字节 -------------- 4(int a) + 4(vptr) = 8

B类的大小为16字节 -------------- 如果没有虚函数,应该是4 + 4 + 4 = 12。为什么还有另外4个字节呢?B类的布局是什么?

C类的大小为12字节 -------------- 4 + 4 + 4 = 12。很清楚!

D类的大小为32字节 -------------- 应该是16(B类) + 12(C类) + 4(int d) = 32。正确吗?

    class A {
    public:
        int a;
        virtual void v();
    };

    class B: virtual public A{                       //virtual inheritance here
    public:
        int b;
        virtual void w();
    };

    class C :  virtual public A {                    //virtual inheritance here
    public:
        int c;
        virtual void x();
    };

  class D: public B, public C {
   public:
        int d;
        virtual void y();
    };

A的大小为8。

B的大小为16。

C的大小为16。

D的大小为28。这是否意味着28 = 16(类B)+16(类C)-8(类A)+4(这是什么?)

我的问题是,在应用虚拟继承时为什么会有额外的空间?

在这种情况下,对象大小的底层规则是什么?

当所有基类和部分基类应用虚拟继承时有什么区别?


2
这里的任何答案都是纯粹的猜测。每个编译器都可以并且确实会略有不同地执行它。由于标准没有指定如何实现它,所以想知道这个问题是毫无意义的(除非你打算编写一个编译器,在这种情况下,这是错误的地方来问这个问题)。 - Martin York
8
马丁,编写编译器本质上是一个关于编程的主题,因此在这里询问不是错误的地方。即使不编写编译器的人也可能对事物的运作方式感到好奇,特别是如果他们希望管理对象的大小,并且错误地认为大小将与虚拟方法的数量成比例。在这里得到的任何答案都可以引用一个或多个具体的编译器,这样就不会是猜测了。 - Rob Kennedy
6个回答

23

这都是实现定义。我正在使用VC10 Beta2。了解虚函数实现的关键就在于,你需要知道Visual Studio编译器中有一个秘密开关/d1reportSingleClassLayoutXXX。稍后我会讲到这个。

基本规则是vtable需要位于任何对象指针的偏移量0处。这意味着多继承需要多个vtable。

这里有几个问题,我从最上面开始:

这是否意味着即使类B和类A都有虚函数,也只有一个vptr存在?为什么只有一个vptr?

这就是虚函数的工作原理,你想让基类和派生类共享同一个vtable指针(指向派生类的实现)。

在这种情况下,看起来布局中有两个vptrs......这是怎么发生的?我认为这两个vptrs一个是给类A的,另一个是给类B的....所以类C的虚函数没有vptr吗?

这是类C的布局,由/d1reportSingleClassLayoutC报告:

class C size(20):
        +---
        | +--- (base class A)
 0      | | {vfptr}
 4      | | a
        | +---
        | +--- (base class B)
 8      | | {vfptr}
12      | | b
        | +---
16      | c
        +---
你是正确的,每个基类都有一个虚函数表(vtable),因此在多重继承中会有两个虚函数表。如果将 C* 强制转换为 B*,则指针值将调整8个字节。虚函数调用仍需要在偏移量0处有一个虚函数表才能正常工作。
在以上 A 类的布局中,类 C 的虚函数表被视为该类的虚函数表(通过 C* 调用时)。

类 B 的大小为16字节。----------------- 如果没有虚函数,则应为4 + 4 + 4 = 12。为什么这里还有另外4个字节?类B的布局是什么?

这是本例中类B的布局:
class B size(20):
        +---
 0      | {vfptr}
 4      | {vbptr}
 8      | b
        +---
        +--- (virtual base A)
12      | {vfptr}
16      | a
        +---

正如你所看到的,有一个额外的指针来处理虚拟继承。虚拟继承很复杂。

D的大小为32字节 -------------- 应该是16(类B)+ 12(类C)+ 4(int d)= 32。对吗?

不,是36字节。在这个例子中,虚拟继承也是同样的情况。D的布局如下:

class D size(36):
        +---
        | +--- (base class B)
 0      | | {vfptr}
 4      | | {vbptr}
 8      | | b
        | +---
        | +--- (base class C)
        | | +--- (base class A)
12      | | | {vfptr}
16      | | | a
        | | +---
20      | | c
        | +---
24      | d
        +---
        +--- (virtual base A)
28      | {vfptr}
32      | a
        +---
我的问题是,为什么应用虚拟继承时会出现额外的空格?
虚拟基类指针很复杂。在虚拟继承中,基类被"合并"。类不再嵌入一个基类,而是在布局中包含一个指向基类对象的指针。如果有两个使用虚拟继承的基类("菱形"类层次结构),它们将同时指向对象中的同一虚拟基类,而不是拥有该基类的独立副本。
重要的是要知道,在这种情况下,没有固定的规则:编译器可以根据需要执行任何操作。
最后一个细节是,为了制作所有这些类布局图,我正在使用以下编译指令:
cl test.cpp /d1reportSingleClassLayoutXXX

当XXX是你想要查看布局的结构体/类的子字符串匹配时,可以使用它。使用此功能,您可以自己探索不同继承方案的影响,以及为什么/在哪里添加填充等内容。


1
我想指出的是,尽管这是一个非常优秀的答案,但这些细节是特定于特定的实现的。只要实现使功能正常工作,就允许实现与此大相径庭。关于类的内存布局没有规则或保证。该答案代表了一组在几个不同编译器和平台上非常相似的实现技术,但某些编译器在某些平台上可能存在重大差异。 - Omnifarious
在将对象写入文件时,我直接使用write()将整个对象直接写入文件。因此,在写入时,虚拟机会额外添加4个字节(或8个字节,具体取决于架构)。有没有什么方法可以避免这种情况? - Rajesh
哦,我的天啊,这个命令彻底改变了我对“虚拟”的理解。所以虚函数和虚继承在内存布局上完全不同! - Harvey Dent

3
一个很好的思考方式是理解如何处理向上转型。我将通过展示你描述的类的对象的内存布局来回答你的问题。
代码示例#2
内存布局如下:
vptr | A::a | B::b
将指向B的指针向上转型为A类型将得到相同的地址,并使用相同的vptr。这就是为什么这里不需要额外的vptr的原因。
代码示例#3
vptr | A::a | vptr | B::b | C::c
正如你所猜测的那样,这里有两个vptr。为什么?因为如果我们从C向上转型为A,我们不需要修改地址,因此可以使用相同的vptr。但是,如果我们从C向上转型为B,我们需要进行修改,因此我们需要在结果对象的开头处有一个vptr。
因此,任何继承自第一个类的派生类都需要一个额外的vptr(除非该派生类没有虚拟方法,在这种情况下它没有vptr)。
代码示例#4及其后续代码
当您以虚拟方式派生时,您需要一个新的指针,称为基指针,用于指向派生类的内存布局中的位置。当然可以有多个基指针。
那么内存布局是什么样子的呢?这取决于编译器。在您的编译器中,它可能是这样的:
vptr | 基指针 | B::b | vptr | A::a | C::c | vptr | A::a \-----------------------------------------^
但是其他编译器可能会在虚拟表中合并基指针(通过使用偏移量),这需要另一个问题来解决。
您需要一个基指针,因为当您以虚拟方式派生时,派生类将仅在内存布局中出现一次(如果它也是常规派生的,则可能会出现额外的次数,就像您的示例一样),因此所有子类都必须指向完全相同的位置。
编辑:澄清-这完全取决于编译器,我展示的内存布局在不同的编译器中可能是不同的。

这只是纯粹的猜测。每个编译器都可以并且确实会略有不同地执行它。 - Martin York
谢谢,我不理解你对代码示例4的注释。你能稍微解释一下为什么会这样吗?vptr | 基指针 | B::b | vptr | A::a | C::c | vptr | A::a -----------------------------------------^ - skydoor
@Martin: 当然,你是对的,但我认为这种结构在流行的编译器中很普遍。不过,我已经编辑回答来澄清了。 - Oak
@skydoor:我试图从“基指针”向第三个vptr显示一个箭头,我想我没有表达清楚我的意思。关键是以虚拟方式继承的类必须有某种指针(但不一定是实际指针-请参见我有关偏移量的注释)指向虚拟基类。否则,在将B强制转换为A时,假设对象的实际类型是C,而不是实际类型是B的情况下,想象一下会发生什么。在编译方面,强制转换是相同的,但在两种情况下,A的“距离”是不同的。 - Oak

3

引用> 我的问题是,继承中vptrs的数量规则是什么?

没有规定,每个编译器供应商都可以根据自己的看法实现继承的语义。

类B: public A {}, 大小为12。这很正常,B有一个包含两个虚方法的vtable,vtable指针+2*int = 12

类C : public A, public B {}, 大小为20。C可以任意扩展A或B的vtable。2*vtable指针+3*int = 20

虚继承:这就是你真正接触到未记录行为的边缘。例如,在MSVC中,#pragma vtordisp和/vd编译选项变得相关。在这篇文章中有一些背景信息。我研究过几次,并决定使用它时,编译选项缩写代表了我的代码可能遇到的情况。


2

请注意,所有这些都是完全由实现定义的。你不能依赖任何一点。没有什么“规则”。

在继承示例中,以下是类A和B的虚拟表可能的样子:

      class A
+-----------------+
| pointer to A::v |
+-----------------+

      class B
+-----------------+
| pointer to A::v |
+-----------------+
| pointer to B::w |
+-----------------+

正如您所看到的,如果您有一个指向类B虚表的指针,它也可以完全有效地作为类A的虚表。

在您的C类示例中,如果您仔细思考,就没有办法制作一个既适用于类C、类A和类B的虚表。因此编译器会生成两个虚表。其中一个虚表适用于类A和C(很可能),另一个虚表适用于类A和B。


我希望有人能够引用圣洁的cpp标准中的脚本。我正在尝试访问从其子类定义的基类中的变量。此外,如果我从汇编引用cpp代码,我需要知道这一点。 - user13947194

1

这显然取决于编译器的实现。 无论如何,我认为可以从下面链接的经典论文所给出的实现中总结出以下规则,并给出您的示例中获得的字节数(除了类D,它将是36个字节而不是32个字节!!!):

类T的对象大小为:

  • 其字段的大小加上T继承的每个对象的大小之和,再加上T虚拟继承的每个对象的4个字节,仅当T需要另一个v-table时再加上4个字节
  • 注意:如果类K被虚拟继承多次(在任何级别),则只需添加K的大小一次

因此,我们必须回答另一个问题:类何时需要另一个v-table?

  • 如果一个类不从其他类继承,则仅在其具有一个或多个虚拟方法时需要v-table
  • 否则,仅当它非虚拟继承的所有类都没有v-table时,该类才需要另一个v-table

规则结束(我认为可以应用于匹配Terry Mahaffey在他的答案中解释的内容):)

无论如何,我的建议是阅读Bjarne Stroustrup的以下论文(C++的创造者),其中详细解释了这些问题:使用虚拟或非虚拟继承需要多少虚拟表……以及为什么!
这真的是一篇好文章: http://www.hpc.unimelb.edu.au/nec/g1af05e/chap5.html

0

我不确定,但我认为这是由于指向虚方法表的指针。


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