c++数据对齐/成员顺序&继承

Posted

技术标签:

【中文标题】c++数据对齐/成员顺序&继承【英文标题】:c++ data alignment /member order & inheritance 【发布时间】:2011-01-01 15:59:38 【问题描述】:

如果使用继承/多重继承,数据成员如何对齐/排序?这个编译器是特定的吗?

有没有办法在派生类中指定成员(包括来自基类的成员)应如何排序/对齐?

【问题讨论】:

相关:***.com/questions/2006216/… 请记住,结构(或类)的大小可能不等于其成员大小的总和,主要是因为允许编译器在成员之间添加填充。一种更健壮的方法是将打包数据读入unsigned char 缓冲区,然后从该缓冲区加载成员。类似地,将成员写入缓冲区,输出缓冲区。这将防止任何对齐或包装问题对您的程序造成严重破坏。 我不担心填充 - 填充很好 - 我只想能够预测一个简单结构的原始数据格式,该结构是多个其他简单结构的后代 【参考方案1】:

它是特定于编译器的。

编辑:基本上它归结为虚拟表的放置位置,并且可能因使用的编译器而异。

【讨论】:

不,只有成员之间的填充是编译器特定的。成员的顺序由语言规范定义。 虚拟表放置是“未定义的”。它只需要在那里。过去在多继承情况下,VC 和 GCC 放置虚拟表的方式是有区别的……【参考方案2】:

只要您的班级不是 POD(普通旧数据),所有赌注都将取消。您可能可以使用特定于编译器的指令来打包/对齐数据。

【讨论】:

首选不是使用编译器指令来打包结构,而是使用方法来读取和写入成员到打包缓冲区。这提供了一个更健壮的程序,尤其是在编译器供应商或版本发生变化时。 是的,但是如果结构发生变化,您需要维护更多代码并引入错误范围。可能你的结构比你的编译器更频繁地改变;)当然,当你开始时,序列化/反序列化策略没有尽头,旨在解决这两个问题。【参考方案3】:

这不仅仅是编译器特定的 - 它可能会受到编译器选项的影响。我不知道有任何编译器可以让您精细控制成员和基如何通过多重继承进行打包和排序。

如果您正在做一些依赖订单和包装的事情,请尝试在您的类中存储一个 POD 结构并使用它。

【讨论】:

有问题的数据结构是 POD 结构(至少如果从其他 POD 结构的多重继承仍然产生 POD 结构)。那么成员是否会被订购类似 'basePODStruct1_members'-padding-'basePODStruct2_members'-padding-...'derivedPODStruct_members'-padding 的东西? @genesys:如果有任何继承(或虚函数,或构造函数或析构函数),则结构不是 POD。 成员之间的填充是编译器特定的。成员的顺序由语言规范定义。【参考方案4】:

我所知道的所有编译器都将基类对象放在派生类对象中的数据成员之前。数据成员按类声明中给出的顺序排列。由于对齐,可能存在间隙。不过,我并不是说一定要这样。

【讨论】:

成员的顺序由语言规范定义,包括继承。继承期间成员和类之间的填充由实现定义。 只是出于好奇——为什么语言没有指定填充?【参考方案5】:

编译器通常对齐结构中的数据成员,以便于访问。这意味着数据元素通常会从字边界开始,并且通常会在结构中留下间隙以确保不会跨越字边界。

所以

结构体 字符一个; 诠释 b; 字符 c;

对于 32 位机器,通常会占用超过 6 个字节

通常先放置基类,然后再放置派生类。这允许基类的地址等于派生类的地址。

在多重继承中,类的地址与第二个基类的地址之间存在偏移。 >static_castdynamic_cast 将计算偏移量。 reinterpret_cast 没有。如果可能,C 样式转换会进行静态转换,否则会重新解释转换。

正如其他人所提到的,所有这些都是特定于编译器的,但以上内容应该为您提供通常情况的粗略指导。

【讨论】:

结构在对齐和打包方面与类没有区别。重要的是结构/类是否是 POD。 即使是单继承也可能存在偏移。当具有虚函数的类派生自 POD 类型时,流行的编译器会将内存排列为 vtableptr + POD + derived【参考方案6】:

你真的在这里问了很多不同的问题,所以我会尽力依次回答每个问题。

首先您想知道数据成员是如何对齐的。成员对齐是编译器定义的,但是由于 CPU 处理未对齐数据的方式,它们都倾向于遵循相同的

结构应根据最严格的成员(通常但不总是最大的内在类型)对齐的准则,并且结构始终对齐,以使数组的元素全部对齐。

例如:

struct some_object

    char c;
    double d;
    int i;
;

这个结构将是 24 字节。因为该类包含一个 double 它将是 8 字节对齐的,这意味着 char 将被填充 7 个字节,而 int 将被填充 4 以确保在 some_object 的数组中,所有元素都是 8 字节对齐的(大小一个对象的总是它的对齐的倍数)。一般来说,这取决于编译器,尽管您会发现对于给定的处理器架构,大多数编译器对齐数据相同。

您提到的第二件事是派生类成员。派生类的排序和对齐有点痛苦。类单独遵循我上面描述的结构规则,但是当你开始谈论继承时,你就会陷入混乱的地盘。给定以下类:

class base

    int i;
;

class derived : public base // same for private inheritance

    int k;
;

class derived2 : public derived

    int l;
;

class derived3 : public derived, public derived2

    int m;
;

class derived4 : public virtual base

    int n;
;

class derived5 : public virtual base

    int o;
;

class derived6 : public derived4, public derived5

    int p;
;

base 的内存布局是:

int i // base

派生的内存布局是:

int i // base
int k // derived

derived2 的内存布局为:

int i // base
int k // derived
int l // derived2

derived3 的内存布局为:

int i // base
int k // derived
int i // base
int k // derived
int l // derived2
int m // derived3

您可能会注意到,base 和derived 都在这里出现了两次。这就是多重​​继承的奇妙之处。

为了解决这个问题,我们有虚拟继承。

derived4 的内存布局为:

void* base_ptr // implementation defined ptr that allows to find base
int n // derived4
int i // base

derived5 的内存布局为:

void* base_ptr // implementation defined ptr that allows to find base
int o // derived5
int i // base

derived6 的内存布局为:

void* base_ptr // implementation defined ptr that allows to find base
int n // derived4
void* base_ptr2 // implementation defined ptr that allows to find base
int o // derived5
int i // base

您会注意到派生的 4、5 和 6 都有一个指向基础对象的指针。这是必要的,因此在调用任何 base 函数时,它都有一个对象可以传递给这些函数。这个结构依赖于编译器,因为它没有在语言规范中指定,但几乎所有编译器都实现它。

当您开始谈论虚函数时,事情会变得更加复杂,但同样,大多数编译器也以相同的方式实现它们。参加以下课程:

class vbase

    virtual void foo() 
;

class vbase2

    virtual void bar() 
;

class vderived : public vbase

    virtual void bar() 
    virtual void bar2() 
;

class vderived2 : public vbase, public vbase2

;

这些类中的每一个都至少包含一个虚函数。

vbase 的内存布局是:

void* vfptr // vbase

vbase2 的内存布局是:

void* vfptr // vbase2

vderived 的内存布局为:

void* vfptr // vderived

vderived2 的内存布局为:

void* vfptr // vbase
void* vfptr // vbase2

关于 vftables 的工作原理,人们有很多不明白的地方。首先要了解的是,类只存储指向 vftable 的指针,而不是整个 vftable。

这意味着无论一个类有多少个虚函数,它都只会有一个 vftable,除非它通过多重继承从其他地方继承了一个 vftable。几乎所有编译器都将 vftable 指针放在类的其他成员之前。这意味着您可能在 vftable 指针和类的成员之间有一些填充。

我还可以告诉您,几乎所有编译器都实现了编译指示包功能,允许您手动强制结构对齐。一般来说,除非你真的知道自己在做什么,否则你不想这样做,但它就在那里,而且有时是必要的。

您问的最后一件事是您是否可以控制订购。您始终控制排序。编译器将始终按照您编写它们的顺序对事物进行排序。我希望这个冗长的解释能够满足您需要了解的所有内容。

【讨论】:

是的 - 这非常非常好!多谢!还有一个问题 - 如果定义了非默认构造函数和析构函数,这些规则是否也成立? 当然。构造函数和析构函数对类布局没有影响,除非析构函数是虚拟的,在这种情况下必须存在 vfptr。另外,我没有进入它,因为它有点超出了问题的范围,但是要注意初始化的顺序。初始化总是从低内存地址到高地址进行,除非在虚拟继承的情况下,首先构造虚拟继承的对象。类似地,析构函数从高地址到低地址调用,除了在虚拟继承的情况下,最后调用虚拟继承类的析构函数。 我刚刚阅读了以下内容(在另一种情况下):“语言标准说,保证字节对字节的副本仅适用于 POD。std::pair不是类聚合,因为它有一个用户定义的构造函数,这意味着它也不是一个 POD。”这只是对为什么无法预测 std::pair 的内存布局的一个不好的解释,还是一旦用户定义的构造函数存在就会发生变化? 这是一个复杂的问题,但我会尝试简要回答。一旦指定了用户声明的(甚至不需要定义)构造函数,就不再保证该对象具有默认构造函数,这意味着它不是 POD。这并不意味着它不能通过 memcpy 复制,它只是意味着该语言不保证 memcpy 会生成对象的真实副本。这样做的原因(我相信)是保证复杂或多态对象的字节对字节副本将是一种痛苦,并且可能会导致代码执行速度变慢。 derived6 的内存布局不包括本地字段 p - 大概这是无意的,它应该立即出现在基本 ptr 之后,但是比我更有资格的人可以确认/反驳这一点并更新帖子以更正/澄清?【参考方案7】:

多重继承中对象的顺序并不总是您指定的。根据我的经验,编译器将使用指定的顺序,除非它不能。当第一个基类没有虚函数而另一个基类有虚函数时,不能使用指定的顺序。在这种情况下,类的第一个字节必须是虚函数表指针,但第一个基类没有。编译器将重新排列基类,以便第一个具有虚函数表指针。

我已经使用 msdev 和 g++ 对此进行了测试,并且它们都重新排列了类。令人讨厌的是,他们似乎对如何做这件事有不同的规则。如果您有 3 个或更多基类,而第一个没有虚函数,这些编译器将提供不同的布局。

为了安全起见,选择两个并避开另一个。

    在使用多重继承时不要依赖基类的顺序。

    使用多重继承时,将所有具有虚函数的基类放在任何没有虚函数的基类之前。

    使用 2 个或更少的基类(因为在这种情况下编译器都以相同的方式重新排列)

【讨论】:

【参考方案8】:

我可以回答其中一个问题。

如果使用继承/多重继承,数据成员如何对齐/排序?

我创建了一个工具来可视化类的内存布局、函数的堆栈帧和其他 ABI 信息(Linux、GCC)。您可以查看来自 mysql++ 库 here 的 mysqlpp::Connection 类(继承 OptionalExceptions)的结果。

【讨论】:

【参考方案9】:

成员在内存中的顺序等于它们在程序中指定的顺序。非虚拟基类的元素位于派生类的元素之前。在多重继承的情况下,第一个(最左边的)类的元素首先出现(依此类推)。虚拟基类排在最后。

从虚拟基类派生的每个类/结构都有一个为其元素前置的指针类型(理论上取决于实现)。

类/结构的对齐方式等于其成员的最大对齐方式(理论上取决于实现)。

填充发生在内存中的下一个元素需要它时(为了对齐)(理论上取决于实现)。

添加尾随填充以使对象的大小成为其对齐方式的倍数。

复杂的例子,

struct base1 
  char m_tag;
  int m_base1;
  base1() : m_tag(0x11), m_base1(0x1b1b1b1b)  
;

struct derived1 : public base1 
  char m_tag;
  alignas(16) int m_derived1;
  derived1() : m_tag(0x21), m_derived1(0x1d1d1d1d)  
;

struct derived2 : virtual public derived1 
  char m_tag;
  int m_derived2_a;
  int m_derived2_b;
  derived2() : m_tag(0x31), m_derived2_a(0x2d2daa2d), m_derived2_b(0x2d2dbb2d)  
;

struct derived3 : virtual public derived1 
  char m_tag;
  int m_derived3;
  virtual ~derived3()  
  derived3() : m_tag(0x41), m_derived3(0x3d3d3d3d)  
;

struct base2 
  char m_tag;
  int m_base2;
  virtual ~base2()  
  base2() : m_tag(0x51), m_base2(0x2b2b2b2b)  
;

struct derived4 : public derived2, public base2, public derived3 
  char m_tag;    
  int m_derived4;
  derived4() : m_tag(0x61), m_derived4(0x4d4d4d4d)  
;

具有以下内存布局:

 derived4 = derived2 -> ....P....O....I....N....T....E....R....
  subobject derived2 -> 0x31 padd padd padd 0x2d 0xaa 0x2d 0x2d 
                        0x2d 0xbb 0x2d 0x2d padd padd padd padd 
virual table = base2 -> ....P....O....I....N....T....E....R....
     subobject base2 -> 0x51 padd padd padd 0x2b 0x2b 0x2b 0x2b 
            derived3 -> ....P....O....I....N....T....E....R....
  subobject derived3 -> 0x41 padd padd padd 0x3d 0x3d 0x3d 0x3d 
  subobject derived4 -> 0x61 padd padd padd 0x4d 0x4d 0x4d 0x4d 
    derived1 = base1 -> 0x11 padd padd padd 0x1b 0x1b 0x1b 0x1b 
  subobject derived1 -> 0x21 padd padd padd padd padd padd padd 
                        0x1d 0x1d 0x1d 0x1d padd padd padd padd 
                        padd padd padd padd padd padd padd padd

请注意,在将derived4 对象转换为derived2 或derived3 后,新对象以指向虚拟基类的指针开始,该指针位于derived4 图像下方的某个位置,就像真正的derived2 或derived3 对象一样。

将此derived4 转换为base2 为我们提供了一个具有虚拟表指针的对象,正如它应该的那样(base2 有一个虚拟析构函数)。

元素的顺序是:先是derived2的(虚基类指针和)元素,然后是base的(虚表指针和)元素,再是derivative3的(虚基类指针和)元素,最后是元素的(的子对象)derived4 -- 所有这些都跟在 virtual 基类 derived1 之后。

还请注意,虽然真正的“派生3”对象必须以 16 个字节对齐,因为它“包含”(最后)在 16 处对齐的虚拟基类派生1,因为它有一个与该对象对齐的成员16;但是这里的多重继承中使用的 'derived3' 不是以 16 个字节对齐的。这没关系,因为没有虚拟基类的derived3 有一个最大值。只有 8 位对齐(它的虚拟基类指针;这是在 64 位机器上)。

【讨论】:

以上是关于c++数据对齐/成员顺序&继承的主要内容,如果未能解决你的问题,请参考以下文章

结构对齐填充、最大填充大小和结构成员的顺序

c++ 类成员的初始化顺序

C++创建派生类对象时,调用构造函数顺序

C++对象模型:成员变量<一>非静态成员

字节对齐1

《面向对象程序设计》高手进~~~~~~~~~~~~!!