可以使用联合体进行类型转换吗?如果不行的话,为什么?

115
我已经搜索了一段时间,但找不到明确的答案。
很多人说使用联合类型转换是未定义的和不良的做法。为什么会这样呢?我看不出任何原因会导致未定义的行为,因为你写入原始信息的内存不会自己改变(除非它在堆栈上超出作用域,但这不是联合的问题,那将是糟糕的设计)。
人们引用了严格别名规则,但在我看来,这就像是说你不能这样做,因为你不能这样做。
此外,如果不是为了类型转换,联合的意义是什么?我在某个地方看到说它们应该用于在不同的时间使用相同的内存位置存储不同的信息,但为什么不在再次使用之前删除信息呢?
总结一下:
1. 为什么使用联合进行类型转换是不好的? 2. 如果不是用于类型转换,它们的作用是什么?
额外信息:我主要使用C++,但想了解C和C++。具体来说,我正在使用联合将浮点数转换为原始十六进制以通过CAN总线发送。

3
对于联合体的一个非常常见的用途,请考虑编译器中的词法分析器。它可以向解析器返回一个标记-值对,根据标记,该值可以是整数、浮点数、字符或指向字符串的指针。如何在单个结构中最好地表示这些不同的值类型?当然是使用 union(联合体)。 - Some programmer dude
1
我在回答为什么优化会破坏这个函数?时详细介绍了联合体类型转换在C和C++中是否合法的细节。基本上,在C中总是合法的,但在C++中是否合法并不清楚,但实际上大多数编译器都支持它在C++中的使用。 - Shafik Yaghmour
我本来想在一段时间前回答这个问题,但是忘记了,然后我又遇到了这个问题,正在研究其他事情,所以我刚刚添加了我的答案。 - Shafik Yaghmour
参见:使用联合转换的可移植性 - Gabriel Staples
5个回答

78
重新强调一下,在C语言中,通过联合类型进行类型转换是完全可以的(但在C++中不行)。相比之下,使用指针强制转换进行类型转换会违反严格别名规则,并且因为不同类型可能具有不同的对齐要求,如果操作错误可能会引发SIGBUS信号,而使用联合类型就不存在这个问题。
相关的C标准如下:
C89 第3.3.2.3节第5款:
如果在将值存储在联合对象的一个成员之后访问该对象的另一个成员,则其行为是实现定义的。
C11 第6.5.2.3节第3款:
后缀表达式后跟"."运算符和标识符指定结构或联合对象的成员。该值是命名成员的值。
附注95如下:
如果用于读取联合对象内容的成员与最后用于在对象中存储值的成员不同,则适当的对象表示的部分将根据6.2.6中描述的过程重新解释为新类型的对象表示(有时称为“类型切换”)。这可能是一个陷阱表示。
这应该是非常清楚的。
James感到困惑,因为C11第6.7.2.1节第16项如下所示:
联合对象中最多只能存储一个成员的值。
这似乎自相矛盾,但实际上不是这样:与C++相比,在C中,没有活动成员的概念,完全可以通过不兼容类型的表达式访问单个存储值。
另请参见C11附录J.1第1项:
与最后一个存储的成员不同的联合成员的值[未指定]。
在C99中,它的描述如下:
除了最后一个存储的成员之外的联合成员的值[未指定]。

这是不正确的。由于附录不是规范性文件,因此它没有自己的TC评级,必须等待下一次标准修订才能解决。


GNU对标准C++(以及C90)进行扩展明确允许在联合体中使用类型转换。其他不支持GNU扩展的编译器可能也支持联合体类型转换,但这不是基本语言标准的一部分。


2
我手头没有C90的副本来验证上下文;我记得从委员会的讨论中,其中一个意图是措辞应允许“调试”实现,如果访问不是最后一个元素写入,则陷入。 (当然,这是在1980年代末; C委员会的态度可能已经发生了变化。)我似乎记得这是通过未定义行为实现的,但实现定义也可以做到这一点。 (这里的主要区别在于,实现将需要记录它所做的事情。) - James Kanze
5
注脚并非规范性内容,在上下文中,它明确阐述了委员会为什么没有定义这个术语。它并不定义行为。 - James Kanze
8
价值在于所命名成员的值,这是规范部分,并由脚注进行了澄清。如果构成该成员对象表示的所有字节都具有指定值并且不对应于陷阱表示,则该成员也将采用指定值。这些字节的来源并不重要(通过memcpy,通过char *进行修改,通过其他联合成员等方式)。除非你改变想法,否则你无法说服我,因此继续下去可能没有意义... - Christoph
1
我记得委员会讨论中的一个意图是措辞应该允许“调试”实现,如果访问不是最后一个写入的元素,则陷入困境。这可能是在80年代的情况;当C99通过指针转换禁止类型游戏时,需要另一种机制;这就是它;可悲的是,在C99的理由中似乎没有提到它,但这是有道理的。 - Christoph
1
我也有这样的印象,即C委员会的态度已经发生了变化;自1990年以来,我一直参与C++的标准化工作,没有密切关注C。然而,基本规则仍然适用:标准未定义的任何内容都是未定义行为。而这显然属于这个范畴。我认为(但无法证明)意图是所有类型转换都是未定义行为,由实现定义。 - James Kanze
显示剩余10条评论

25

工会最初的目的是为了节省空间,当您想要表示不同类型时,可以使用所谓的变体类型,请参见Boost.Variant作为一个很好的例子。

另一个常见用途是类型转换,这个有效性存在争议,但实际上大多数编译器都支持它,我们可以看到gcc文档支持

从与最近写入的成员不同的联合成员读取(称为“类型游戏”)是常见的。即使使用-fstrict-aliasing,也允许类型游戏,只要通过联合类型访问内存。因此,上面的代码按预期工作。

请注意,它说即使使用-fstrict-aliasing,也允许类型游戏,这表明存在别名问题。

Pascal Cuoq认为defect report 283阐明了在C语言中是允许这样做的。Defect report 283作为澄清增加了以下脚注:
“如果用于访问联合对象内容的成员与上次用于在对象中存储值的成员不同,则将值的对象表示的适当部分重新解释为新类型的对象表示,如6.2.6所述(有时称为“类型游戏”)。 这可能是一个陷阱表示。”
在C11中,这将是脚注95。
尽管在std-discussion邮件组主题通过联合进行类型游走中提出了这个问题是未明确规定的,这似乎是合理的,因为DR 283没有增加新的规范措辞,只有一个脚注:

在我看来,这是C语言中一个未明确规定的语义泥潭。实现者和C委员会之间尚未就哪些情况具有定义行为,哪些情况没有达成共识[...]

在C++中是否具有定义行为是不清楚的
这个讨论还涉及到为什么允许通过联合进行类型游走是不可取的至少有一个原因:

[...]C标准的规则破坏了当前实现执行的基于类型的别名分析优化。

它破坏了一些优化。对此的第二个反对意见是,使用memcpy应该生成相同的代码,并且不会破坏优化并具有良好定义的行为,例如:
std::int64_t n;
std::memcpy(&n, &d, sizeof d);

改为这个:

union u1
{
  std::int64_t n;
  double d ;
} ;

u1 u ;
u.d = d ;

我们可以看到使用godbolt,这确实生成了相同的代码,如果你的编译器不能生成相同的代码,那么应该被视为一个bug:

如果这对你的实现来说是真的,我建议你在上面提交一个bug。为了解决某个特定编译器的性能问题而破坏真正的优化(基于类型别名分析的任何东西),在我的看来似乎不是一个好主意。

博客文章类型Punning、严格别名和优化也得出了类似的结论。

未定义行为邮件列表讨论:类型Punning以避免复制涵盖了很多相同的内容,我们可以看到这个领域有多么模糊。


2
memcpy产生相同代码的说法忽略了一个事实,即如果编译器能够记录别名识别的模式,那么可以生成更有效率的代码,在这种情况下,编译器只需要做出少量的温和悲观(但很可能准确)的假设,而memcpy则经常会迫使编译器做出更悲观的假设。memcpy本身的代码可能看起来很好,但它对周围代码的影响却不尽如人意。 - supercat
3
可能也值得一提的是,只有当类型是TriviallyCopyable时,std::memcpy才是有效的。 - Justin
@supercat如果您能提供一个Godbolt示例来展示这种效果,那将非常有帮助。据我了解Richard的立场是不应该出现这种情况,也许这是一个bug。 - Shafik Yaghmour
通过安全地使用分配给 *dp 的赋值,而不是使用 memcpy,可以消除重新加载和重新存储。 - supercat
@ShafikYaghmour:轻微更正[当我尝试更改标识符名称使其更有意义时,我搞错了]: 将store_next_word替换为store_double_halfword。添加显而易见的#include指令,代码将可以在gcc.godbolt.com上粘贴得很好。 - supercat
显示剩余3条评论

8

这种未定义行为背后至少有(或者在C90中曾经有)两个动机。其一是编译器可以生成额外的代码来跟踪联合体中的内容,并在访问了错误的成员时生成信号。实际上,我不认为有人曾经这么做过(也许CenterLine公司有?)。另一个动机是优化的可能性,这也是被使用的。我用过的某些编译器会推迟写入,直到最后一刻,因为它可能是不必要的(因为变量超出范围了,或者后续写入了不同的值)。从逻辑上讲,人们本能地期望在联合体可见时关闭此优化,但在Microsoft C的早期版本中并没有这样做。

类型转换方面的问题比较复杂。C委员会(在1980年代末)基本上采取了这样一个立场,即应该使用强制转换(在C++中是reinterpret_cast),而不使用联合体,尽管当时两种技术都很常见。自那时以来,一些编译器(例如g++)已经持相反的观点,支持使用联合体,但不支持使用强制转换。实际上,如果此时不存在类型转换,那么这两种方法都无法正常工作。这可能是g++持此观点的动机。如果访问联合体成员,那么可能存在类型转换就会立即变得明显。但当然,如果有这样一个例子:

int f(const int* pi, double* pd)
{
    int results = *pi;
    *pd = 3.14159;
    return results;
}

调用方式:

union U { int i; double d; };
U u;
u.i = 1;
std::cout << f( &u.i, &u.d );

根据标准的严格规则,这是完全合法的,但在g ++(以及可能许多其他编译器)中会失败。当编译f时,编译器假设pi和pd不能别名,并重新排序对* pd的写入和对*pi的读取。(我认为从来没有想过保证这一点。但是标准的当前措辞确实保证了这一点。)
编辑:
由于其他答案认为其行为实际上已定义(主要基于引用非规范说明的论点,该说明被断章取义),因此正确答案是pablo1977的答案:标准并未尝试定义涉及类型转换时的行为。这样做的可能原因是没有便携式行为可以定义。这并不妨碍特定的实现定义它;尽管我不记得有关该问题的任何具体讨论,但我非常确定意图是实现定义某些内容(如果不是全部,则大部分都是)。
关于使用联合进行类型转换:当C委员会开发C90(在1980年代末期)时,很明显希望允许进行额外检查的调试实现(例如使用fat指针进行边界检查)。从当时的讨论中可以清楚地看出,意图是调试实现可能会缓存有关联合中最后一个初始化的值的信息,并在尝试访问其他任何内容时截获。这在§6.7.2.1 / 16中明确说明:“联合对象中最多可以存储一个成员的值。”访问不在那里的值是未定义行为;它可以被 assimilated 访问未初始化的变量。(当时有一些关于访问具有相同类型的不同成员是否合法的讨论。我不知道最终解决方案是什么;在大约1990年之后,我转向了C ++。)
关于从C89引用行为是实现定义的语句:在第3节(术语,定义和符号)中发现它似乎非常奇怪。我必须在家里查看我的C90副本;其已在后来版本的标准中删除的事实表明委员会认为它的存在是错误的。
标准支持的使用union的方式是模拟派生。可以定义:
struct NodeBase
{
    enum NodeType type;
};

struct InnerNode
{
    enum NodeType type;
    NodeBase* left;
    NodeBase* right;
};

struct ConstantNode
{
    enum NodeType type;
    double value;
};
//  ...

union Node
{
    struct NodeBase base;
    struct InnerNode inner;
    struct ConstantNode constant;
    //  ...
};

即使Node是通过inner初始化的,也可以合法地访问base.type。(§6.5.2.3/6以“做出一个特殊保证...”开始,并明确允许这一点,这非常强烈地表明所有其他情况都应视为未定义行为。当然,§4/2中还有这样的声明:“本国际标准中的‘未定义行为’或未明确定义行为的省略否则表明未定义行为”,为了证明行为不是未定义的,您必须展示在标准中它在哪里被定义。)

最后,关于类型转换:所有(或至少我用过的所有)实现都以某种方式支持它。当时我的印象是,指针转换是实现支持它的方式;在C++标准中,甚至有(非规范性)文本建议对于熟悉底层架构的人来说,reinterpret_cast的结果是“不令人惊讶的”。然而,在实践中,大多数实现支持使用union进行类型转换,前提是通过union成员进行访问。大多数实现(但不包括g++)还支持指针转换,前提是指针转换对编译器明显可见(对于某些未指定的指针转换定义)。而底层硬件的“标准化”意味着像这样的事情:

int
getExponent( double d )
{
    return ((*(uint64_t*)(&d) >> 52) & 0x7FF) + 1023;
}

实际上,它们相当具有可移植性(当然在大型机上不起作用)。

不起作用的是像我第一个例子那样的东西,其中别名对编译器是不可见的。(我非常确定这是标准中的缺陷。我记得甚至看到过一个关于此的DR。)


3
在C90中,它是“实现定义”,而不是“未定义”- 将其视为非法的是C++的特性。 - Christoph
4
实际上,C委员会通过引入有效类型使使用指针强制转换进行类型玩弄成为非法行为,因此使用联合体是C语言的做法。 - Christoph
2
@Christoph 在C11中仍然是未定义的行为,至少在我手头的副本中是这样的。 §6.7.2.1/16非常清楚地说明了这一点。 C ++甚至更清晰,因为它具有与存储期限分开的对象生命周期的概念,但即使在C中,访问未初始化的对象(除字节序列外)是未定义的行为,并且将一个联合的一个元素赋值给所有其他元素都会“未初始化”。 - James Kanze
对于 C 语言,您是错误的。我为您撰写了一份特别答案,列出了相关引用。 - Christoph
1
@Christoph,问题在于你的论点很大程度上依赖于一个非规范性的否定词,而且这个词是断章取义的。重要的文本在于§6.7.2.1/16。C确实有无效对象的概念,当访问它时会导致未定义的行为。 - James Kanze
显示剩余4条评论

6

在C99中是合法的:

来自标准: 6.5.2.3 结构体和联合体成员

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


6
“适当部分的对象表示被重新解释为新类型的对象表示,如6.2.6所描述的那样(有时被称为“类型切换”过程)。这可能是一个陷阱表示”,@JamesKanze能否详细说明这个说法如何表示未定义行为?在我看来,它似乎表明所读取的内容是在新类型中重新解释的,并且这是一种花哨的说法,如果有的话,它是实现定义的行为。“适当部分的对象表示被重新解释为新类型的对象表示,如6.2.6所描述的那样(有时被称为“类型切换”过程)。这可能是一个陷阱表示”,是一种花哨的说法,意思是这是未定义行为。对于读取的内容在新类型中的重新解释,实际上是一种未定义行为,因为标准没有规定该行为的具体结果,而具体结果又取决于特定实现的定义。这是C语言中的一个潜在问题,需要谨慎处理。 - Pascal Cuoq
8
我理解“这可能是一个陷阱表示法”的意思是,如果新类型具有陷阱表示,则在实现定义的条件下,类型转换的结果可能是其中之一。 - Pascal Cuoq
1
@JamesKanze:通过联合类型进行类型转换是明确定义的,只要它不会导致陷阱表示(并且源类型的大小不小于目标类型);这是一个逐个案例的决定,取决于涉及的类型和值;C99中有一个脚注非常清楚地说明了类型转换是合法的;(非规范!)附录错误地将其列为未指定行为而不是未定义行为;该附录已在C11中修正。 - Christoph
1
@JamesKanze:是的,这只适用于C语言;然而,使用联合体这种方式从来没有被定义为未定义行为;请参阅C89草案,第3.3.2.3节:如果在将值存储在对象的不同成员之后访问联合体对象的成员,则行为是实现定义 - Christoph
1
最后:引用的文本是非规范性注释的一部分;应将其解释为一种可能的理由。相关文本在§6.7.2.1/16中,明确指出联合体中最多只能有一个元素在某个时刻有效。因此,这个答案是完全错误的。 - James Kanze
显示剩余13条评论

4
简短回答:在某些情况下,类型转换是安全的。另一方面,尽管它似乎是一个非常常见的实践,但标准似乎并不太关心它是否正式。我将只谈论C(不是C ++)。 1. 类型转换和标准 正如其他人指出的那样,类型转换在标准C99和C11中是允许的,在子节6.5.2.3中。然而,我将用自己的看法重写事实:
  • 标准文档C99和C11的第6.5节开发了表达式的主题。
  • 子节6.5.2是指后缀表达式
  • 子子节6.5.2.3讨论结构体和联合体
  • 6.5.2.3(3)段解释了应用于structunion对象的点运算符,以及将获得哪个值。就在那里,脚注95出现了。这个脚注说:

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

事实上,类型转换几乎不出现,并且作为脚注,这表明它在C编程中不是一个重要的问题。实际上,使用union的主要目的是为了节省空间(在内存中)。由于几个成员共享同一个地址,如果知道每个成员将在程序的不同部分使用,从不同时使用,则可以使用union代替struct,以节省内存。

  • 提到了子节6.2.6
  • 子节6.2.6讨论了对象如何表示(在内存中,例如)。
2. 类型的表示及其问题 如果您注意标准的不同方面,您几乎可以确定什么都不确定:
  • 指针的表示没有明确定义。
  • 更糟糕的是,不同类型的指针可能具有不同的表示(作为内存中的对象)。
  • union 成员在内存中共享相同的头地址,与 union 对象本身的地址相同。
  • struct 成员具有递增的相对地址,从与 struct 对象本身完全相同的内存地址开始。但是,每个成员末尾可能会添加填充字节。有多少?这是不可预测的。填充字节主要用于内存对齐目的。
  • 算术类型(整数、浮点实数和复数)可以用多种方式表示。这取决于实现。
  • 特别是,整数类型可能具有填充位。我认为,在台式计算机上不是这样的。然而,标准为此留下了余地。 填充位用于特殊目的���奇偶校验、信号等),而不是用于保存数学值。
  • signed 类型可以有3种表示方式:1's 补码,2's 补码,仅签名位。
  • char 类型只占用 1 字节,但是 1 字节可以具有不同于 8 的位数(但绝不少于 8)。
  • 但是我们可以确定一些细节:

    a. char 类型没有填充位。
    b. unsigned 整数类型的表示与二进制形式完全相同。
    c. unsigned char 占用正好 1 字节,没有填充位,并且没有任何陷阱表示,因为所有位都被使用。此外,它遵循整数数字的二进制格式表示一个值,没有任何歧义。

3. 类型转换 vs 类型表示

所有这些观察结果表明,如果我们尝试对具有不同于 unsigned char 类型的 union 成员进行类型转换,我们可能会遇到很多歧义。这不是可移植的代码,特别是我们的程序可能有不可预测的行为。
然而,标准允许此类访问

即使我们确定了每种类型在我们的实现中的具体表示方式,我们可能会有一系列在其他类型中毫无意义的比特序列(陷阱表示)。在这种情况下,我们不能做任何事情。

4. 安全情况:unsigned char

使用类型转换技巧的唯一安全方式是使用unsigned char或者是unsigned char数组(因为我们知道数组成员对象是严格连续的,而且在使用 sizeof() 计算其大小时没有任何填充字节)。

  union {
     TYPE data;
     unsigned char type_punning[sizeof(TYPE)];
  } xx;  

自从我们了解到unsigned char是以严格二进制形式表示的,没有填充位,类型混用可以在这里使用以查看成员data的二进制表示形式。该工具可用于分析给定类型在特定实现中的表示方式。
我无法看到另一个符合标准规范的安全且有用的类型混用应用程序。
5. 关于强制转换的注释...
如果想要操纵类型,最好定义自己的转换函数,或者只是使用强制转换。我们可以记住这个简单的例子:
  union {
     unsigned char x;  
     double t;
  } uu;

  bool result;

  uu.x = 7;
  (uu.t == 7.0)? result = true: result = false;
  // You can bet that result == false

  uu.t = (double)(uu.x);
  (uu.t == 7.0)? result = true: result = false;
  // result == true

我没有看到标准中有任何关于通过 char 进行类型转换的例外情况的引用,因此我非常怀疑。你有吗?请注意,这可能与严格别名定义不同,后者确实对 char 类型做出了例外。我们最好不要混淆这两个概念。 - underscore_d
1
@underscore_d:在类型转换中,并没有明确提到字符类型的引用。我通过收集事实得出了结论:我可以在C11标准中读到,**(1)** 通过联合成员,类型转换是C中的一个有效操作,**(2)** 尽管可能会出现“trap representation”问题,(3)但字符类型没有“trap representation”,(4)每种字符类型占用1个字节。因此,字符类型的数组可以用于“读取”联合成员中任何其他对象的字节。但是,当访问原子联合体(或结构体)的成员时,存在未定义的行为。 - pablo1977
1
你知道吗,我想我刚才忽略了你说你只会谈论C的部分。对不起。显然当我在进行C++研究任务时,即使它不是主题,我也只能看到C++!我喜欢你关于C的推理,但必须假设在C++中,由于不允许使用punning,通过char punning是UB的(但通过指针进行别名引用则不是)。我觉得这些应该直接相关,但我找不到一个C ++源代码说“是的,在union中可以随意使用char”。但现在我会停止对你的答案的OT :) - underscore_d
事实上,类型游戏几乎不会出现,并且作为脚注,这表明它在C编程中不是一个相关的问题。任何曾经使用嵌入式系统的人都可以告诉你,这显然是错误的。类型游戏在声明寄存器映射和执行数据序列化时非常有用。它还可以用作保护“严格别名”错误的方法。这只是C标准未将此文本作为脚注包含的次要编辑错误,但仅此而已。类型游戏实际上是联合使用的唯一有效方法... - Lundin
实际上,使用联合的主要目的是为了节省空间(在内存中)。然而,这一点已经有很多争议,普遍的共识是使用联合创建“变量”或存储不相关类型是程序设计混乱的明显迹象,因此应该避免使用。像 MISRA C 这样的安全编码标准禁止使用联合来达到这个目的。 - Lundin
关于使用字符类型进行类型转换,标准中并没有指出这是一个特殊情况,除了可能的严格别名异常。更重要的是6.3.2.3部分:“当将一个指向对象的指针转换为指向字符类型的指针时,结果指向对象中地址最低字节。结果的连续增量,直到对象大小,产生对象剩余字节的指针。”虽然这不是真正的联合类型玩弄技巧,但保证可行。 - Lundin

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