7

二十多年前,我会(并且没有)想到使用 POD 结构进行二进制 I/O:

struct S { std::uint32_t x; std::uint16_t y; };
S s;
read(fd, &s, sizeof(s)); // assume this succeeds and reads sizeof(s) bytes
std::cout << s.x + s.y;

(我忽略了填充和字节顺序问题,因为它们不是我要问的部分。)

“显然”,我们可以读入s并且编译器需要假定 和 的内容s.xs.y的别名read()。因此,s.xread()不是未定义的行为之后(因为s未初始化)。

同样的情况下

S s = { 1, 2 };
read(fd, &s, sizeof(s)); // assume this succeeds and reads sizeof(s) bytes
std::cout << s.x + s.y;

编译器不能假定它s.x仍然1read().

快进到现代世界,我们实际上必须遵循别名规则并避免未定义的行为,等等,我无法向自己证明这是允许的。

例如,在 C++14 中,[basic.types] ¶2 说:

对于普通可复制类型 T 的任何对象(基类子对象除外),无论该对象是否拥有类型 T 的有效值,构成该对象的底层字节(1.7)都可以复制到 char 或无符号的字符。

42 如果将 char 或 unsigned char 数组的内容复制回对象,则该对象随后应保持其原始值。

¶4 说:

T 类型对象的对象表示是由 T 类型对象占用的 N 个 unsigned char 对象的序列,其中 N 等于 sizeof(T)。

[basic.lval] ¶10 说:

如果程序尝试通过非下列类型之一的泛左值访问对象的存储值,则行为未定义:54
...
— char 或 unsigned char 类型。

54此列表的目的是指定对象可能或可能不会别名的情况。

总而言之,我认为这是标准的说法,即“您可以形成一个unsigned charchar指向任何可简单复制(因此是 POD)类型并读取或写入其字节的指针”。事实上,在N2342中,它给了我们现代措辞,介绍性表格说:

程序可以安全地应用编码优化,尤其是 std::memcpy。

后来:_

然而,该类中唯一的数据成员是一个 char 数组,因此程序员直观地期望该类是 memcpyable 和二进制 I/O-able。

使用建议的解决方案,可以通过使默认构造函数变得微不足道(使用 N2210,语法将是 endian()=default)将类变成 POD,从而解决所有问题。

听起来 N2342 确实在试图说“我们需要更新措辞以使其能够像这些类型一样进行 I/O read()write(),而且看起来更新后的措辞确实已成为标准。

另外,我经常听到提到“std::memcpy()洞”或类似的东西,你可以用它std::memcpy()来基本上“允许混叠”。但是该标准似乎并没有std::memcpy()特别指出(实际上在一个脚注中提到了它,std::memmove()并将其称为实现此目的的“示例”)。

另外,像这样的 I/O 函数read()往往是 POSIX 特定于操作系统的,因此在标准中没有讨论。


因此,考虑到所有这些,我的问题是:

  • 什么真正保证我们可以对 POD 结构进行真实世界的 I/O(如上所示)?

  • 我们真的需要std::memcpy()将内容进出unsigned char缓冲区(当然不需要)还是我们可以直接读入 POD 类型?

  • 操作系统 I/O 函数是否“承诺”它们会“像通过读取或写入unsigned char值一样”或“像通过std::memcpy()”一样操作底层内存?

  • 当我和原始 I/O 函数之间存在层(例如Asio )时,我应该注意什么?

4

2 回答 2

6

严格的别名是关于通过指针/引用访问一个对象,该类型不是该对象的实际类型。但是,严格别名规则允许通过指向字节数组的指针访问任何类型的任何对象。这条规则至少从 C++14 开始就已经存在了。

现在,这并不意味着什么,因为必须定义这种访问的含义。为此(就写作而言),我们实际上只有两条规则:[basic.types]/2 和 /3,它们涵盖了复制 Trivially Copyable 类型的字节。问题最终归结为:

您是否正在从文件中读取“构成 [an] 对象的基础字节”?

如果您正在读入的数据s实际上是从 的实时实例的字节中复制的S,那么您就 100% 没问题。从标准中可以清楚地看出,执行fwrite将给定字节写入文件,并fread从文件中读取这些字节。因此,如果您将现有S实例的字节写入文件,并将这些写入的字节读取到现有的S,则相当于复制这些字节。

当你开始进入解释的杂草时,你会遇到技术问题。将标准解释为定义此类程序的行为是合理的,即使写入和读取发生在同一程序的不同调用中。

在以下两种情况之一出现问题:

1:写入数据的程序实际上与读取数据的程序不同。

2:当写入数据的程序实际上并没有写入类型的对象S,而是写入恰好可以合法解释为S.

该标准不管理两个程序之间的互操作性。但是,C++20 确实提供了一个工具,该工具有效地表示“如果此内存中的字节包含 a 的合法对象表示T,那么我将返回该对象外观的副本。” 它被称为std::bit_cast; 你可以给它传递一个字节数组sizeof(T),它会返回那个的副本T

如果你是骗子,你会得到未定义的行为。如果不是简单可复制的bit_cast,甚至都不会编译T

但是,将字节复制直接S从技术上不是S但完全可以是的源直接复制到现场S,是另一回事。标准中没有使这项工作起作用的措辞。

我们的朋友P0593提出了一种明确声明这种假设的机制,但它并没有完全融入 C++20。

于 2019-07-27T20:00:36.227 回答
0

迄今为止,每个版本的 C 和 C++ 标准中的类型访问规则都基于 C89 规则,这些规则是在假定用于各种任务的实现将维护已发布的基本原理中描述的 C 精神原则“Don '不要阻止[或以其他方式干扰]程序员做需要做的事情[完成那些任务]。” C89 的作者认为没有理由担心所编写的规则是否实际上要求编译器支持每个人都同意的构造(例如,通过 分配存储malloc,将其传递给fread,然后将其用作标准布局结构类型),因为他们希望客户需要它们的任何编译器都支持此类构造,而不考虑所编写的规则是否实际需要此类支持。

在许多情况下,应该“显然”工作的构造实际上调用了 UB,因为例如标准的作者不需要担心规则是否会例如禁止给定代码的编译器:

struct S {int dat[10]; } x,y;
void test(int i)
{
  y = x;
  y.dat[i] = 1; /// Equivalent to *(y.dat+i) = 1;
  x = y;
}

从假设 objecty类型的对象struct S不可能被int*标记行 (*) 上的取消引用访问,因此不需要复制回 object x。对于编译器来说,当它可以看到指针派生自 a 时做出这样的假设struct S将被普遍认为是钝的,无论标准是否会禁止它,但是确切何时应该预期编译器的问题“请参阅“如何产生指针是标准管辖范围之外的实施质量问题。

(*) 事实上,所写的规则将允许编译器做出这样的假设,因为可用于访问 a 的唯一左值类型struct S将是该结构类型、它的限定版本、从它派生的类型,或字符类型。

很明显,像这样的函数fread()应该可以在标准布局结构上使用,质量编译器通常会支持这种用法,而不考虑标准是否真的要求它们这样做。将此类问题从实施质量问题转移到实际的一致性问题将需要采用新的术语来描述语句int *p = x.dat+3;对 x 的存储值的作用[它应该使其可以通过以下方式访问p至少在某些情况下],更重要的是要求标准本身确认一个目前归入已发布的基本原理的观点——它并不打算对只在适合的实现上运行的代码说任何坏话。它的目的,也不是说任何好的实现,虽然符合,但不适合他们声称的目的。

于 2019-07-27T20:54:15.443 回答