C/C++严格的别名、对象生存期和现代编译器

C/C++ strict aliasing, object lifetime and modern compilers

本文关键字:生存期 对象 编译器 别名 C++      更新时间:2023-10-16

我对C++严格混叠规则及其可能的含义感到困惑。请考虑以下代码:

int main() {
  int32_t a = 5;
  float* f = (float*)(&a);
  *f = 1.0f;
  int32_t b = a;   // Probably not well-defined?
  float g = *f;    // What about this?
}

查看C++规范,第 3.10.10 节,从技术上讲,给定的代码似乎都没有违反那里给出的"别名规则":

如果程序尝试通过以下类型之一以外的左值访问对象的存储值,则行为是未定义的:
...合格访问器类型的列表...

  • *f = 1.0f;不会违反规则,因为无法访问存储的值,即我只是通过指针写入内存。我不是从内存中读取或试图解释这里的值。
  • 线路int32_t b = a;不违反规则,因为我通过其原始类型进行访问。
  • 出于
  • 同样的原因,float g = *f;行不会违反规则。

在另一个线程中,成员CortAmmon实际上在响应中提出了同样的观点,并补充说,通过写入活动对象而产生的任何可能的未定义行为,如*f = 1.0f;,将由标准对"对象生存期"的定义来解释(这对于POD类型来说似乎是微不足道的)。

然而:互联网上有大量证据表明,上面的代码会在现代编译器上产生UB。例如,请参阅此处和此处。
在大多数情况下,论证是编译器可以自由地将&af视为不会相互混叠,因此可以自由地重新调度指令。

现在最大的问题是,这种编译器行为是否真的是对标准的"过度解释"。
该标准唯一一次专门讨论"别名"是在 3.10.10 的脚注中,其中明确指出这些是管理别名的规则。
正如我之前提到的,我没有看到上述任何代码违反标准,但它会被很多人(可能还有编译器人员)认为是非法的。

我真的很感激在这里澄清一下。

小更新:
正如成员BenVoigt正确指出的那样,在某些平台上,int32_t可能与float不一致,因此给定的代码可能违反了"存储足够的对齐和大小"规则。我想声明的是,int32_t是故意选择的,以与大多数平台上的float保持一致,并且这个问题的假设是类型确实对齐。

小更新#2:
正如几位成员指出的那样,int32_t b = a;线可能违反了标准,尽管不是绝对确定的。我同意这一观点,并且不改变问题的任何方面,请读者从我上面的陈述中排除这一行,即没有任何代码违反标准。

你的第三个要点(也许也是第一个)是错误的。

您声明"行float g = *f;不会出于相同的原因违反规则",其中"只是相同的原因"(有点模糊)似乎指的是"通过其原始类型访问"。但这不是你正在做的事情。您正在通过 float 类型的左值(从表达式 *f 获取)访问int32_t(名为 a)。所以你违反了标准。

我也相信(但在这一点上不太确定)存储值是对存储值的访问,因此即使*f = 1.0f;也违反了规则。

我认为这种说法是不正确的:

行int32_t b = a; 不违反规则,因为我是通过其原始类型访问的。

存储在位置 &a 的对象现在是浮点型,因此您尝试通过错误类型的左值访问浮点数的存储值。

对象生存期和访问的规范中存在一些明显的歧义,但根据我对规范的阅读,这里有一些代码问题。

float* f = (float*)(&a);

这将执行一个reinterpret_cast,只要float不需要比int32_t更严格的对齐,那么您可以将结果值转换回int32_t*,您将获得原始指针。在任何情况下,都不会另行定义使用结果。

*f = 1.0f;

假设*f具有a的别名(并且int32_t的存储具有适合float的对齐方式和大小),则上面的行结束了int32_t对象的生存期,并将float对象放在其位置:

类型 T

对象的生存期从以下时间开始:获得具有类型 T 的正确对齐方式和大小的存储,如果对象具有非平凡的初始化,则其初始化完成。

类型 T 对象的生存期在以下时间结束:[...] 对象占用的存储被重用或释放。

—3.8 对象生存期 [basic.life]/1

我们正在重用存储,但如果int32_t具有相同的大小和对齐要求,那么似乎float始终存在于同一个地方(因为存储是"获得"的)。也许我们可以通过将这一行更改为 new (f) float {1.0f}; 来避免这种歧义,因此我们知道float对象的生命周期始于初始化完成或之前。

此外,"访问"并不一定仅仅意味着"读取"。它可以意味着读取和写入。因此,*f = 1.0f;执行的写入可以通过覆盖存储值来被视为"访问存储值",在这种情况下,这也是混叠冲突。

所以现在假设存在一个浮点对象并且int32_t对象的生存期已经结束:

int32_t b = a;

此代码通过类型为 int32_t 的 glvalue 访问float对象的存储值,并且显然是混叠冲突。该程序在 3.10/10 下具有未定义的行为。

float g = *f;

假设int32_t具有正确的对齐方式和大小要求,并且指针f是以允许其使用良好定义的方式获得的,那么这应该合法地访问使用 1.0f 初始化的float对象。

我已经学会了艰难的方式,如果不同时查看 6.5.6,从 C99 标准中引用 6.5.7 是没有帮助的。有关相关报价,请参阅此答案。

6.5.6明确指出,在某些情况下,对象的类型在其生命周期中可以更改很多次。它可以采用最近写入它的值的类型。这真的很有用。

我们需要区分"声明类型"和"有效类型"。局部变量或静态全局变量具有声明的类型。我认为,在该对象的生命周期内,您一直坚持使用这种类型。您可以使用char *从对象读取,但不幸的是,"有效类型"不会改变。

但是malloc返回的内存"没有声明的类型"。在free d之前,这将保持不变。它永远不会有声明的类型,但它的有效类型可以根据 6.5.6 进行更改,始终采用最新写入的类型。

所以,这是合法的:

int main() {
    void * vp = malloc(sizeof(int)+sizeof(float)); // it's big enough,
                    //  and malloc will look after alignment for us.
    int32_t *ap = vp;
    *ap = 5;      // make int32_t the 'effective type'
    float* f = vp;
    *f = 1.0f;    // this (legally) changes the effective type.
    // int32_t b = *ap;   // Not defined, because the
                          // effective type is wrong
    float g = *f;    // OK, because the effective type is (currently) correct.
}

因此,基本上,写入 malloc -ed 空间是更改其类型的有效方法。但我想这并不能让我们通过一种新型的"镜头"来看待预先存在的事物,这可能很有趣;我认为,除非我们使用各种char*例外来查看"错误"类型的数据,否则这是不可能的。