实践中的联合体、别名和类型转换:什么有效,什么无效?

19
我不理解使用GCC的union可以做什么,也不能做什么。我阅读了关于这个问题的文章(特别是这里这里),但它们都集中在C ++标准上,我感觉C++标准和实践之间存在不匹配(常用的编译器)。
特别地,最近我在阅读有关编译标志-fstrict-aliasingGCC在线文档时发现了混乱的信息。它说:

-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中按预期工作?编译器标志做什么?


5
“我感觉规格和实际情况不太相符。” 直到你升级编译器,否则一切都会造成混乱!(真实故事) - YSC
1
当你真正需要类型转换时:https://dev59.com/tGMm5IYBdhLWcg3wMs3R#17790026 - hegel5000
5个回答

13

别名可以按字面意思理解:当两个不同的表达式引用同一个对象时,就称为别名。类型切换是将某种类型的对象用作不同类型的“切断”操作。

从正式上讲,类型切换是未定义行为,只有很少几个例外。如果您粗心地操纵位,这种情况经常发生。

int mantissa(float f)
{
    return (int&)f & 0x7FFFFF;    // Accessing a float as if it's an int
}

这些例外情况(简化版):

  • 访问整数作为它们的无符号/有符号对应类型
  • 将任何内容作为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保证可行的类型判定方法,其他情况可能看起来可行,但未来可能会默默地出现问题。


2
我认为你的例子失败了,因为该结构中位域的布局本身是实现定义的。C语言中位域的定义不够明确,这是一件非常令人恼火的事情,可能已经太晚去修复了。类型转换在GCC中可以,但位域可能会或可能不会按照你的期望工作。 - Dan Mills
@DanMills 公平,但我脑海中想不出一个好玩又简单的双关语。我认为如果我想展示实际可行的东西,那就应该全力以赴。 - Passer By
2
@PasserBy,一个比较常见的例子是这样的 union { long long x; struct { unsigned low, high } }(或者使用 unsigned[2],你懂的)。 - Dan M.
除了gcc/clang对“strict aliasing”规则的解释之外,在其他情况下,“别名(aliasing)”一词不会用于描述一个引用被用来派生另一个引用的情况,然后新引用被用来访问对象并在对象以任何其他方式使用之前被丢弃。 - supercat
@supercat,我不太理解您的观点。别名并不是一个解释性的含义,而是一个英文单词。在C++标准中,并没有“Strict-aliasing”这个短语,但通常指的是[expr.lval]。 - Passer By
1
@路人甲:两个引用在不同时间指向同一对象的事实并不意味着它们是别名。同样,一个引用被用来派生另一个引用并立即用于访问对象的事实也不意味着它们是别名。这两种情况都是预期的访问模式,而术语“别名”指的是在无法看到它们之间的任何关系的情况下可以看到引用的生命周期重叠的情况。 - supercat

4
术语是个好东西,我可以随心所欲地使用它,其他人也可以!
两种类型的大小相同,它们就相似吗?如果不是,那么哪些类型是相似的呢?粗略地说,当类型的constness或signedness不同时,它们是相似的。仅仅通过字节大小来判断是绝对不够的。
别名是一种特殊情况的类型游戏,其中涉及在相同地址处放置不同类型的对象。只有当类型相似时,才允许别名,否则禁止。此外,可以通过char(或类似于char)lvalue访问任何类型的对象,但反过来访问char类型的对象(即通过不同类型的lvalue访问char类型的对象)是不允许的。这由C和C++标准保证,GCC只是实现标准规定的内容。
GCC文档似乎将“类型游戏”用于狭义的读取联合成员而不是最后一个写入的成员的情况。即使类型不相似,C标准也允许这种类型游戏。另一方面,C++标准不允许这样做。GCC可能会或不会将权限扩展到C++,文档没有明确说明。
在没有-fstrict-aliasing的情况下,GCC显然会放宽这些要求,但具体程度不清楚。请注意,在执行优化构建时,默认情况下使用-fstrict-aliasing。
总之,只需按照标准进行编程。如果GCC放宽了标准的要求,则其影响不大,也不值得麻烦。

1
标准的作者故意允许专用实现以使它们的行为不适合大多数目的。尽管在通用实现中启用“-fstrict-aliasing”所产生的90%以上的优化是合理的,但剩下的10%(虚假的“优化”)使得该模式对许多目的不适用。 - supercat

2
根据C11草案N1570中的脚注88,“严格别名规则”(6.5p7)旨在指定编译器必须允许可能存在别名的情况,但没有试图定义什么是别名。在某个时候,出现了一种普遍的观念,即除了由规则定义的访问之外的访问表示“别名”,而允许的访问则不表示,但实际上恰恰相反。
给定一个像这样的函数:
int foo(int *p, int *q)
{ *p = 1; *q = 2; return *p; }

第6.5节p7并没有说如果pq标识了相同的存储空间,它们就不会发生别名。相反,它指定它们被允许发生别名。

请注意,并非所有涉及将一种类型的存储器作为另一种类型访问的操作都表示别名。对于一个从另一个对象派生的lvalue进行的操作不会“别名”那个其他对象。相反,它是针对该对象的操作。如果在创建对某个存储器的引用和使用它的时间之间以某种方式引用相同的存储器,或者代码进入其中发生这种情况的上下文中,则会发生别名。

虽然识别lvalue是否来自另一个lvalue是实现质量问题,但标准的作者必须期望实现能够识别超出规定范围的某些构造。没有一般的许可权限可以通过使用成员类型的lvalue来访问与结构体或联合体相关联的任何存储器,标准中也没有明确说明涉及someStruct.member的操作必须被认为是对someStruct的操作。相反,标准的作者预计编译器编写者应该比委员会更有能力判断客户的需求并满足它们,只要他们合理努力支持客户需要的构造。由于任何一种编译器只要合理努力识别派生引用都会注意到someStruct.member是从someStruct派生的,因此标准的作者认为没有必要明确规定。

不幸的是,像以下这样的构造的处理方式:

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

从"它足够明显,actOnStruct和指针解引用应该作用于someUnion(因此作用于其所有成员),没有必要强制执行这种行为"发展到"由于标准不要求实现识别上述操作可能会影响someUnion,任何依赖此类行为的代码都是有问题的,并且不需要支持"。除了在-fno-strict-aliasing模式下,gcc或clang都不可靠地支持上述构造,即使大多数将被阻止的“优化”生成的代码是“高效但无用”的。

如果您在任何具有此选项的编译器上使用-fno-strict-aliasing,几乎任何内容都可以工作。如果您在icc上使用-fstrict-aliasing,它将尝试支持使用类型转换而不使用别名的构造,尽管我不知道是否有关于它处理哪些构造的确切文档。如果您在gcc或clang上使用-fstrict-aliasing,任何能够工作的东西纯粹是偶然发生的。

原始答案已经从“应该”到“没有必要”进行了改变。在gcc或clang中,除非在-fno-strict-aliasing模式下,否则不可靠地支持上述构造。如果在具有此选项的编译器上使用-fno-strict-aliasing,几乎任何内容都可以工作。如果在icc上使用-fstrict-aliasing,它将尝试支持使用类型转换而不使用别名的构造。如果您在gcc或clang上使用-fstrict-aliasing,任何能够工作的东西纯粹是偶然发生的。请注意,这些行为可能会导致代码出现问题,因此需要小心谨慎。

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

如果在将值存储在对象的另一个成员之后访问联合对象的成员,则行为是实现定义的

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

如果用于访问联合对象内容的成员与上次用于在对象中存储值的成员不同,则适当部分的值的对象表示将被重新解释为新类型的对象表示,如6.2.6所述(这个过程有时被称为“类型游戏”)。这可能是一个陷阱表示。

换句话说,在 C 中允许基于联合的类型游戏,尽管实际语义可能不同,具体取决于支持的语言标准(请注意,C99的语义比C89的“实现定义”更窄)。
在 C99 中,您还有(第6.5表达式):

只能通过以下类型之一的lvalue表达式访问对象的存储值:

— 与对象的有效类型兼容的类型,

— 与对象的有效类型兼容的类型的限定版本,

— 与对象的有效类型相对应的有符号或无符号类型,

— 与有效类型的限定版本相对应的有符号或无符号类型,

— 包括上述类型之一在其成员中(包括子聚合体或包含联合体的成员)的聚合或联合类型,或

— 字符类型。

C99 还有一个描述兼容类型的章节(6.2.7 兼容类型和组合类型):

如果它们的类型相同,则两种类型具有兼容类型。确定两种类型是否兼容的其他规则在类型说明符的6.7.2中描述,在类型限定符的6.7.3中描述,在声明符的6.7.5中描述。...

然后是(6.7.5.1 指针声明符):

为了使两个指针类型兼容,两者都必须标识相同,并且都必须是指向兼容类型的指针。

简化一下,这意味着在 C 中,通过使用指针,您可以将有符号整数访问为无符号整数(反之亦然),并且可以在任何内容中访问单个字符。其他任何操作都会导致别名违规。
您可以在各个版本的 C++ 标准中找到类似的语言。但是,就我所看到的 C++03 和 C++11 中,基于联合的类型游戏并没有明确允许(不像 C)。

UV:这个答案阐明了“兼容类型”的概念(我想这就是他们所说的“类似类型”)。我完全同意它不被标准明确允许,但在某些情况下,它可以在GCC中工作。这是一个“不被明确允许”并不意味着禁止的情况。 - L.C.
1
@L.C. 这并不意味着它不会在不同的编译器、架构、操作系统甚至新的编译器版本上突然崩溃。 - Dan M.
你说得没错,我明白了...但是编写开源、灵活、可移植等代码并不总是主要目标。这不够优雅,也不是一个好的实践,但有时候我们只想要一个在当前机器/操作系统上运行的二进制文件...所以如果编译器生成的“有效”代码能够达到预期的效果...为什么不呢! - L.C.

2
我认为添加一份补充回答很好,因为当我提出问题时,我不知道如何在不使用UNION的情况下满足我的需求:我固执地使用它,因为它似乎恰好符合我的需求。
进行类型转换并避免未定义行为(取决于编译器和其他环境设置)的最佳方法是使用std::memcpy,并将一个类型的内存字节复制到另一个类型。 例如,在这里这里有所解释。
我还读到过,即使编译器使用联合进行类型转换生成有效代码时,它也会生成与使用std::memcpy相同的二进制代码。
最后,即使这些信息不能直接回答我的原始问题,但与之密切相关,我觉得在这里添加它是有用的。

1
在 C++20 之前,std::memcpy 是 ISO C++ 保证的唯一完全可移植的类型转换方式,直到出现了 std::bit_cast<T>。联合体类型转换仅由 GNU C++(以及 ISO C,但不包括 C++,即使对于 POD/平凡可复制类型也是如此)保证。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接