POD 类型的二进制 I/O 如何不违反别名规则

How does binary I/O of POD types not break the aliasing rules?

本文关键字:何不违 别名 规则 类型 二进制 POD      更新时间:2023-10-16

二十多年前,我会(并且没有)考虑使用 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()。 因此,read()之后s.x不是未定义的行为(因为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.xread()之后仍处于1状态。

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

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

对于简单可复制类型 T 的任何对象(基类子对象除外),无论该对象是否具有 T 类型的有效值,都可以将构成该对象的基础字节 (1.7) 复制到数组中 字符或无符号字符。

42 如果字符数组或无符号字符的内容被复制回对象中,则对象随后应保持其原始值。

¶4 说:

类型 T 的对象的对象表示形式是 N 个无符号字符对象的序列,由 类型 T 的对象,其中 N 等于大小 (T)。

[basic.lval] ¶10 说:

如果程序尝试通过 glvalue 而不是 以下类型行为未定义:54
...
— 字符或无符号字符类型。

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

综上所述,我认为这是标准的说法,即"您可以形成一个指向任何可复制(因此是 POD)类型的unsigned charchar指针,并读取或写入其字节"。 事实上,在为我们提供现代措辞的 N2342 中,介绍性表格说:

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

后来:

然而,类

中唯一的数据成员是一个字符数组,因此程序员直观地期望该类是可记忆的和二进制 I/O 的。

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

听起来真的像 N2342 试图说"我们需要更新措辞以使其强大,以便您可以对这些类型进行像read()write()这样的 I/O",而且似乎更新的措辞是标准的。

此外,我经常听到提到"std::memcpy()孔"或类似内容,您可以使用std::memcpy()基本上"允许混叠"。 但是该标准似乎并没有特别指出std::memcpy()(事实上,在一个脚注中提到了它和std::memmove(),并称其为执行此操作的方法的"示例")。

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


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

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

  • 我们是否真的需要将内容std::memcpy()unsigned char缓冲区中和从缓冲区中取出(当然不需要),或者我们可以直接读取 POD 类型?

  • OS I/O 函数是否"承诺"它们"像读取或写入unsigned char值一样"或"像std::memcpy()一样"来操作底层内存?

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

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

现在,这并没有多大意义,因为必须定义这种访问的含义。为此(在编写方面),我们实际上只有两个规则:[basic.types]/2 和/3,它们涵盖了复制 Trivially Copyable 类型的字节。问题最终归结为:

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

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

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

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

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

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

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

如果你是一个骗子,你会得到不确定的行为。如果T不是可复制的,bit_cast甚至不会编译。

但是,将字节复制到实时S中,该源在技术上不是S但完全可能是S,是另一回事。标准中没有措辞来使其工作。

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

迄今为止,每个版本的 C 和 C++ 标准中的类型访问规则都基于 C89 规则,这些规则的编写假设是,用于各种任务的实现将坚持已发布的基本原理中描述的 C 精神原则,即"不要阻止 [或以其他方式干扰] 程序员做需要做的事情 [以完成这些任务]。 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;
}

假设struct S类型的对象y不可能被标记行 (*) 上的取消引用int*访问,因此不需要复制回对象x。 对于编译器来说,当它可以看到指针来自一个struct S时做出这样的假设,无论标准是否禁止它,都会被普遍认为是迟钝的,但是编译器何时应该"看到"指针是如何产生的问题是标准管辖范围之外的实现质量问题。

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

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