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

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

二十多年前,我会(也不会)想到使用 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;

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

"Obviously",我们可以读入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 表示:

For any object (other than a base-class subobject) of trivially copyable type T, whether or not the object holds a valid value of type T, the underlying bytes (1.7) making up the object can be copied into an array of char or unsigned char.

42 If the content of the array of char or unsigned char is copied back into the object, the object shall subsequently hold its original value.

¶4 说:

The object representation of an object of type T is the sequence of N unsigned char objects taken up by the object of type T, where N equals sizeof(T).

[basic.lval] ¶10 说:

If a program attempts to access the stored value of an object through a glvalue of other than one of the following types the behavior is undefined:54
...
— a char or unsigned char type.

54 The intent of this list is to specify those circumstances in which an object may or may not be aliased.

综合起来,我认为这是"you can form an unsigned char or char pointer to any trivially copyable (and thus POD) type and read or write its bytes"的标准说法。事实上,在给了我们现代措辞的 N2342 中,介绍性的 table 说:

Programs can safely apply coding optimizations, particularly std::memcpy.

later

Yet the only data member in the class is an array of char, so programmers intuitively expect the class to be memcpyable and binary I/O-able.

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

听起来真的像N2342想说的"we need to update the wording to make it so you can do I/O like read() and write() for these types",而且更新后的措辞真的像是标准

此外,我经常听到提到 "the std::memcpy() hole" 或类似的地方,您可以在其中使用 std::memcpy() 基本上 "allow aliasing"。但该标准似乎并没有特别指出 std::memcpy()(事实上,在一个脚注中提到它与 std::memmove() 一起,并将其称为 "example" 的一种方法)。

此外,I/O 函数如 read() 往往是 POSIX 中的 OS 特定的,因此没有在标准中讨论.


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

严格别名是指通过 pointer/reference 访问一个对象,而不是该对象的实际类型。但是,严格的别名规则permit accessing any object of any type through a pointer to an array of bytes。这条规则至少从 C++14 开始就存在了。

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

您正在阅读文件中的 "the underlying bytes making up [an] object" 吗?

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

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

以下两种情况之一会引起关注:

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

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

该标准不管理两个程序之间的互操作性。但是,C++20 确实提供了一个工具,可以有效地表示 "if the bytes in this memory contain a legitimate object representation of a T, then I'll return a copy of what that object would look like." It's called std::bit_cast;你可以给它传递一个 sizeof(T) 的字节数组,它会 return 那个 T.

的副本

如果你说谎,你会得到未定义的行为。如果 T 不可复制,bit_cast 甚至无法编译。

但是,从技术上不是 S 但完全可能是 S 的源直接将字节复制到实时 S 中是另一回事.标准中没有措辞来实现这一点。

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

迄今为止,每个版本的 C 和 C++ 标准中的类型访问规则都基于 C89 规则,编写这些规则时假定用于各种任务的实现将坚持 C 原则中描述的精神发布的基本原理 "Don't prevent [or otherwise interfere with] the programmer from doing what needs to be done [to accomplish those tasks]." C89 的作者认为没有理由担心编写的规则是否实际要求编译器支持每个人都同意他们应该支持的结构(例如,通过 malloc 分配存储空间) ,将其传递给 fread,然后将其用作标准布局结构类型),因为他们希望客户需要它们的任何编译器都支持此类构造,而不管规则是否实际编写需要这样的支持。

在很多情况下,应该 "obviously" 工作的构造实际上调用了 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 派生的时候做出这样的假设,无论标准是否禁止它,都会被普遍认为是迟钝的,但问题是编译器究竟何时应该预期 "see" 如何生成指针是标准管辖范围之外的实施质量问题。

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

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