实践中的联合、别名和双关语:什么有效,什么无效?

Unions, aliasing and type-punning in practice: what works and what does not?

我无法理解使用 GCC 联合可以做什么和不能做什么。我阅读了有关它的问题(特别是 here and here),但它们关注的是 C++ 标准,我觉得 C++ 标准与实践(常用的编译器)之间存在不匹配。

特别是,我最近在阅读编译标志 -fstrict-aliasing 时在 GCC online doc 中发现了令人困惑的信息。它说:

-fstrict-aliasing

Allow the compiler to assume the strictest aliasing rules applicable to the language being compiled. For C (and C++), this activates optimizations based on the type of expressions. In particular, an object of one type is assumed never to reside at the same address as an object of a different type, unless the types are almost the same. For example, an unsigned int can alias an int, but not a void* or a double. A character type may alias any other type. Pay special attention to code like this:

union a_union {
  int i;
  double d;
};

int f() {
  union a_union t;
  t.d = 3.0;
  return t.i;
}

The practice of reading from a different union member than the one most recently written to (called “type-punning”) is common. Even with -fstrict-aliasing, type-punning is allowed, provided the memory is accessed through the union type. So, the code above works as expected.

这是我认为我从这个例子中理解的和我的疑惑:

1) 别名只适用于相似类型,或 char

结果 1): 别名 - 顾名思义 - 是当你有一个值和两个成员访问它时(即相同的字节);

疑问:当两种类型的字节大小相同时,它们是否相似?如果不是,相似类型是什么?

结果 1) 对于非相似类型(无论这意味着什么),别名不起作用;

2) 类型双关是指我们读取的成员与写入的成员不同;这很常见,只要通过联合类型访问内存,它就会按预期工作;

疑问: 是否在类型相似的特定类型双关案例中使用了别名?

我很困惑,因为它说 unsigned int 和 double 不相似,所以别名不起作用;然后在示例中,它在 int 和 double 之间使用别名,它清楚地表明它按预期工作,但称之为类型双关: 不是因为类型相似或不相似,而是因为它是从一个它没有写的成员那里读取的。但是从一个它没有写的成员那里读取是我理解的别名是为了(正如这个词所暗示的)。我迷路了。

题目: 有人可以澄清别名和类型双关之间的区别,以及这两种技术的哪些用途在 GCC 中按预期工作?编译器标志有什么作用?

别名可以按字面意思理解:当两个不同的表达式引用同一个对象时。类型双关是"pun"一种类型,即将某种类型的对象作为不同的类型使用。

形式上,类型双关是未定义的行为,只有少数例外。当您 fiddle 不小心

时,通常会发生这种情况
int mantissa(float f)
{
    return (int&)f & 0x7FFFFF;    // Accessing a float as if it's an int
}

例外情况是(简化)

  • 访问整数作为它们的 unsigned/signed 对应物
  • charunsigned charstd::byte
  • 访问任何内容

这被称为严格别名规则:编译器可以安全地假设两个不同类型的表达式永远不会引用同一个对象(除了上述例外情况),否则它们将具有未定义的行为。这有助于优化,例如

void transform(float* dst, const int* src, int n)
{
    for(int i = 0; i < n; i++)
        dst[i] = src[i];    // Can be unrolled and use vector instructions
                            // If dst and src alias the results would be wrong
}

gcc 说的是它稍微放宽了规则,并允许通过联合进行类型双关,即使标准不要求它

union {
    int64_t num;
    struct {
        int32_t hi, lo;
    } parts;
} u = {42};
u.parts.hi = 420;

这是 gcc 保证会起作用的类型双关语。其他案例可能看起来有效,但可能有一天会悄无声息地被打破。

在 ANSI C(AKA C89)中,您有(第 3.3.2.3 节结构和联合成员):

if a member of a union object is accessed after a value has been stored in a different member of the object, the behavior is implementation-defined

在 C99 中你有(第 6.5.2.3 节结构和联合成员):

If the member used to access the contents of a union object is not the same as the member last used to store a value in the object, the appropriate part of the object representation of the value is reinterpreted as an object representation in the new type as described in 6.2.6 (a process sometimes called "type punning"). This might be a trap representation.

IOW,C 中允许基于联合的类型双关,尽管实际语义可能不同,具体取决于支持的语言标准(请注意,C99 语义比 C89 的实现定义的更窄).

在 C99 中你还有(第 6.5 节表达式):

An object shall have its stored value accessed only by an lvalue expression that has one of the following types:

— a type compatible with the effective type of the object,

— a qualified version of a type compatible with the effective type of the object,

— a type that is the signed or unsigned type corresponding to the effective type of the object,

— a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object,

— an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union), or

— a character type.

C99 中有一节(6.2.7 兼容类型和复合类型)描述了兼容类型:

Two types have compatible type if their types are the same. Additional rules for determining whether two types are compatible are described in 6.7.2 for type specifiers, in 6.7.3 for type qualifiers, and in 6.7.5 for declarators. ...

然后(6.7.5.1 指针声明符):

For two pointer types to be compatible, both shall be identically qualified and both shall be pointers to compatible types.

稍微简化一下,这意味着在 C 中,通过使用指针,您可以将有符号整数作为无符号整数访问(反之亦然),并且您可以访问任何内容中的单个字符。任何其他情况都将构成别名违规。

您可以在各种版本的 C++ 标准中找到类似的语言。但是,据我所知,在 C++03 和 C++11 中,基于联合的类型双关并未明确允许(与 C 不同)。

术语是个好东西,我可以随心所欲地使用它,其他人也可以!

are two types similar when they have the same size in bytes? If not, what are similar types?

粗略地说,类型在常量性或符号性方面不同时是相似的。仅以字节为单位的大小绝对不够。

is aliasing a specific case of type-punning where types are similar?

类型双关是任何绕过类型系统的技术。

别名是涉及将不同类型的对象放置在同一地址的特定情况。当类型相似时通常允许使用别名,否则禁止使用别名。此外,可以通过 char(或类似于 char)左值访问任何类型的对象,但相反(即通过不同类型访问类型 char 的对象左值)是不允许的。 C 和 C++ 标准都保证了这一点,GCC 只是实现了标准要求。

GCC 文档似乎在狭义上使用 "type punning" 来读取联合成员而不是最后写入的成员。即使类型不相似,C 标准也允许这种类型双关。 OTOH C++ 标准不允许这样做。 GCC 可能会也可能不会将权限扩展到 C++,文档对此并不清楚。

如果没有 -fstrict-aliasing,GCC 显然会放宽这些要求,但不清楚具体放宽到什么程度。请注意,-fstrict-aliasing 是执行优化构建时的默认值。

最重要的是,只需按照标准进行编程。如果 GCC 放宽了标准的要求,那意义不大,不值得这么麻烦。

根据 C11 草案 N1570 中的脚注 88,"strict aliasing rule" (6.5p7) 旨在指定编译器必须允许事物可能出现别名的可能性的情况,但不会尝试定义别名 。沿线的某个地方,出现了一种流行的观点,即规则定义的访问以外的访问代表 "aliasing",而允许的则不代表访问,但实际上恰恰相反。

给定如下函数:

int foo(int *p, int *q)
{ *p = 1; *q = 2; return *p; }

第 6.5p7 节没有说 pq 如果它们标识相同的存储则不会别名。相反,它指定它们 允许 别名。

请注意,并非所有涉及将一种类型的存储访问为另一种类型的操作都代表别名。对从另一个对象新鲜可见地派生的左值的操作不会 "alias" 另一个对象。相反,它 对该对象的操作。如果在创建对某些存储的引用和使用它的时间之间,以某种方式引用相同的存储 不是从第一个 派生的,或者代码进入上下文,则会发生别名其中发生。

尽管识别左值何时派生自另一个左值的能力是实施质量问题,但标准的作者必须期望实施能够识别超出强制要求的一些构造。没有通过使用成员类型的左值来访问与结构或联合关联的任何存储的一般权限,标准中也没有明确涉及[=14的操作=] 必须被识别为对 someStruct 的操作。相反,该标准的作者希望编译器编写者做出合理的努力来支持他们的客户需要的结构,应该比委员会更好地判断这些客户的需求并满足他们。由于任何做出甚至远程合理的努力来识别派生引用的编译器都会注意到 someStruct.member 是从 someStruct 派生的,因此标准的作者认为没有必要明确规定这一点。

不幸的是,处理结构如下:

actOnStruct(&someUnion.someStruct);
int q=*(someUnion.intArray+i)

已经从"It's sufficiently obvious that actOnStruct and the pointer dereference should be expected to act upon someUnion (and consequently all the members thereof) that there's no need to mandate such behavior"发展到"Since the Standard doesn't require that implementations recognize that the actions above might affect someUnion, any code relying upon such behavior is broken and need not be supported"。除了 -fno-strict-aliasing 模式外,gcc 或 clang 都无法可靠地支持上述构造,即使大多数 "optimizations" 会因支持它们而被阻止会生成 "efficient" 但无用的代码.

如果您在任何具有此类选项的编译器上使用 -fno-strict-aliasing,几乎任何东西都可以工作。如果您在 icc 上使用 -fstrict-aliasing,它会尝试支持使用类型双关而不使用别名的构造,但我不知道是否有任何文档说明它处理或不处理哪些构造。如果你在 gcc 或 clang 上使用 -fstrict-aliasing,任何有效的东西都纯粹是偶然的。

我认为添加一个补充答案很好,只是因为当我问这个问题时,我不知道如何在不使用 UNION 的情况下满足我的需求:我固执地使用它,因为它似乎准确地回答了我的需求。

进行类型双关和避免未定义行为可能产生的后果(取决于编译器和其他环境设置)的好方法是使用 std::memcpy 并将内存字节从一种类型复制到另一种类型。这是解释 - 例如 - here and here.

我也经常读到,当编译器使用联合为类型双关生成有效代码时,它会生成与使用 std::memcpy 相同的二进制代码。

最后,即使此信息没有直接回答我最初的问题,但它是如此紧密相关,以至于我觉得将其添加到此处很有用。