C++ 是否保证具有单个平凡成员的“平凡”结构具有相同的二进制布局?

Mar*_* Ba 18 c++ memory-layout language-lawyer

我们的项目中有一些严格类型的整数类型:

struct FooIdentifier {
  int raw_id; // the only data member

  // ... more shenanigans, but it stays a "trivial" type.
};

struct BarIdentifier {
  int raw_id; // the only data member

  // ... more shenanigans, but it stays a "trivial" type.
};
Run Code Online (Sandbox Code Playgroud)

基本上是这里提出的或类似于Unit Library 中使用的东西。

除了类型系统之外,这些结构基本上都是整数。

我的问题在这里现在是:有没有C ++语言保证这些类型奠定了相当于100%的内存作为一个经常int会是什么?

注意:由于我可以静态检查类型是否具有相同的大小(即没有填充),我真的只对无意外填充的情况感兴趣。我应该从一开始就添加这个注释

// Precodition. If platform would yield false here, I'm not interested in the result.
static_assert(sizeof(int) == sizeof(ID_t)); 
Run Code Online (Sandbox Code Playgroud)

也就是说,以下内容是否来自 C++ 标准 POV

int integer_array[42] = {}; // zero init
ID_t id_array[42] = {}; // zero init

static_assert(sizeof(int) == sizeof(ID_t)); // Precodition. If platform would yield false here, I'm not interested in the result.

const char* const pIntArrMem = static_cast<const char*>(static_cast<const void*>(integer_array));
const char* const pIdArrMem = static_cast<const char*>(static_cast<const void*>(id_array));
assert(0 == memcmp(pIntArrMem, pIdArrMem, sizeof(int))); // Always ???
Run Code Online (Sandbox Code Playgroud)

eer*_*ika 10

TL;DR 不,标准似乎不能保证(据我所知)。从技术上讲,您必须依赖于拥有健全的 ABI。

您可能需要放弃支持 ds9k。


该标准对布局没有明确保证。充其量,我们可以根据我们确实拥有的保证,对实际实现可以做什么做出一些合理的假设。

[基础.化合物]

两个对象 a 和 b 是指针可互转换的,如果:

  • ...
  • 一个是标准布局类对象,另一个是该对象的第一个非静态数据成员,或者,如果该对象没有非静态数据成员,则是该对象的任何基类子对象 ([class.mem]) , 或者
  • 存在一个对象 c 使得 a 和 c 是指针可相互转换的,而 c 和 b 是指针可相互转换的。

如果两个对象是指针可互转换的,则它们具有相同的地址,并且可以通过 reinterpret_cast 从指向另一个的指针获取指向一个的指针。

由此,我们可以传递地知道第一个成员之前的标准布局类中实际上不能填充。

[expr.sizeof]

...当应用于一个类时,结果是该类的对象中的字节数,包括将该类型的对象放置在数组中所需的任何填充。...当应用于数组时,结果是数组中的总字节数。这意味着 n 个元素的数组的大小是元素大小的 n 倍。

这意味着既没有integer_arrayid_array没有任何数组在元素之前(或之间或之后)有填充。

鉴于int子对象之前缺少填充,您的第二个断言将是一个合理的假设,除非一个对象可以在一个上下文中具有一种表示形式,而在另一种上下文中具有另一种表示形式(自由与子对象,或不同封闭类型的子对象)。例如,一个是 big endian,另一个是 little endian。我找不到不允许这样做的标准,但我也无法想象这样的实现在实践中如何工作,因为编译器实际上无法总是知道特定的泛左值是否是子对象(以及在哪个封闭对象中)。

鉴于上述假设,第一个断言归结为“标准布局类是否可以唯一成员之后填充?实际上,如果存在alignas或某些影响语言扩展的布局,这是完全可能的 ,但如果是这样,我们可以假设否定不是这样吗?标准并没有说太多,我认为这对于实践中的语言实现来说甚至不可能添加一些填充 - 只是不是很有用。

关于对象表示的小标准是什么:

[基本.类型.一般]

T 类型对象的对象表示是 T 类型对象占用的 N 个 unsigned char 对象的序列,其中 N 等于 sizeof(T)。T 类型对象的值表示是参与表示 T 类型值的一组位。对象表示中不属于值表示的位是填充位。对于可简单复制的类型,值表示是对象表示中的一组位,用于确定值,该值是实现定义的值集的一个离散元素。35

35) 目的是使 C++ 的内存模型与 ISO/IEC 9899 编程语言 C 的内存模型兼容。


关于FooIdentifierBarIdentifier是否保证彼此之间具有相同的表示形式。

[class.mem.general]

两个标准布局结构 ([class.prop]) 类型的公共初始序列是声明顺序中最长的非静态数据成员和位字段序列,从每个结构中的第一个这样的实体开始,例如相应的实体具有与布局兼容的类型,要么两个实体都使用 no_unique_address 属性([dcl.attr.nouniqueaddr])声明,要么都不是,并且两个实体都是具有相同宽度的位域,或者都不是位域。

如果两个标准布局结构 ([class.prop]) 类型的公共初始序列包含两个类 ([basic.types]) 的所有成员和位域,则它们是布局兼容的类。

[基础.化合物]

...指向布局兼容类型的指针应具有相同的值表示和对齐要求

这些类是布局兼容的,作为描述听起来很有希望,但对语言规则几乎没有影响。


Bjö*_*ist 5

不,不能保证。简单反例:

#include <cstdio>
struct S {
    int s;
} __attribute__ ((aligned (8)));
int main() { printf("%d %d\n", sizeof(S), sizeof(int)); }
Run Code Online (Sandbox Code Playgroud)

在我的机器上打印 8 和 4。__attribute__是非标准语法,但不能保证 gcc 将来不会默认更改为八字节对齐。

编辑:鉴于 struct 和 int 始终具有相同大小的先决条件,那么确实可以保证相同的二进制布局。至少在任何最不明智的实现中。

  • 这是回答一个不同的问题。如果你注释一下的话可以让它变得不一样吗? (3认同)
  • 您可能已经在OP提到他们可以断言“sizeof(int) == sizeof(S)”之前回答过。顺便说一句,使用不同的编译器选项(优化其中一个的大小,加快另一个的速度)而不是不同翻译单元中的属性,您甚至可以拥有两个具有不同大小的相同定义的结构;该问题与 int 与 struct 有关但不限于此。 (3认同)
  • 不,它使用注释来证明裸类型和结构的对齐要求允许不同。请参阅 [basic.align/2](http://eel.is/c++draft/basic.align#2) - 尽管该示例具有虚拟继承,但该语言仅表示_“类型所需的对齐方式可能是当它用作完整对象的类型和用作子对象的类型时是不同的"_ (2认同)

Cor*_*ica 5

挑战 eerorika 的回答,我相信你可以保证二进制兼容性。为此,我将参考 C++11 规范。

关键部分: [class/7] 这定义了一个标准布局类。很明显,我们都同意这些是标准布局。

[intro.object/5][intro.object/6]

可简单复制或标准布局类型 (3.9) 的对象应占用连续的存储字节。

除非对象是位域或零大小的基类子对象,否则该对象的地址是它占用的第一个字节的地址。

这限制了标准布局对象可以具有的形状,并指定了我们可以称之为对象“地址”的内容。

[class.mem/20]

指向标准布局结构对象的指针,使用 reinterpret_cast 适当转换,指向其初始成员(或者如果该成员是位域,则指向它所在的单元),反之亦然。[注意:因此,标准布局结构对象中可能存在未命名的填充,但不是在其开头,这是实现适当对齐所必需的。——尾注]

这表示我们至少可以将 a 转换ID_t*int*via reinterpret cast。

现在,您断言sizeof(ID_t) == sizeof(int). 这是个好消息,因为它限制了您的选择。 int* someIdAsInt = reinterpret_cast<int*>(&someId)保证成功,它将指向每个 class.mem 的第一个成员。那么问题来了,可以返回的可能地址有哪些?显然,只有一个地址可能是字节的第一个字节sizeof(int),当然也就是someId.

所以我们可以确定&someIdsomeIdAsInt引用相同的地址。并且,特别是someIdAsInt 必须指向每个 class.mem 的初始成员。

如果我要这样做*someIdAsInt = 43,结果一定和我做的一样someId.raw_id = 43,因为someIdAsInt指向someId.raw_id. 无论我用这个指针做什么来掩盖它,这个陈述都必须是真的。

这表示*someIdAsIntsomeId要么必须具有相同的布局(允许赋值),要么编译器必须跟踪 的值someIdAsInt,将其与普通的int*. 这就是为什么我离开 eerorika 的答案。无法在带有类型标记的类型系统中处理此信息(它会强制编译器能够跟踪标记,即使您执行了诸如int*在线程之间传递之类的残酷操作)。因此,任何信息标记都必须被烘焙到形成int*. C++ 规范没有说明指针值的格式。

但是,差异的程度是有限的int*,一般来说,这是无可争议的。关键是我可以std::memcpy用来将一个字节复制int到另一个字节中,并且生成的整数必须是相同的值。据我所知,这实际上并没有写入规范,但是(基本上?)所有程序员都接受它作为 C 和 C++ 的共同法则规则。事实上std::bit_cast,C++20 中的包含进一步强调了这种事情。有两种无法通过字节区分的整数格式会破坏各种各样的东西。

因此,如果您在语言律师论证中接受此普通法裁决,则 your 的布局ID_t必须与intif的布局相同sizeof(ID_t) == sizeof(int)。如果不接受普通法裁决,那么......好吧......我只想说一些自我反省是有序的=D

请注意,这并不能意味着你可以放心地走另一条路。如果您有一个int数组,则无法将其转换为ID_t*然后访问这些数组。这将违反严格的别名,因为ID_t首先在该内存地址中从未有过。然而,因为它们是相同的布局,使用std::memcpystd::bit_cast转换为ID_t具有等效位模式的 仍然是公平的游戏。