联合与类型搭配


76

我已经搜索了一段时间,但找不到明确的答案。

许多人说,使用union来进行pun-pun是不确定的,也是不好的做法。为什么是这样?考虑到您将原始信息写入的内存,它不会做任何未定义的事情,而不会改变它的任何原因(除非它超出了堆栈范围,但这不是联合问题) ,那将是不好的设计)。

人们引用了严格的别名规则,但是在我看来,这就像在说您不能执行,因为您无法执行。

如果不键入pun,联合的意义是什么?我在某处看到应该在不同的时间将相同的内存位置用于不同的信息,但是为什么不删除信息然后再使用呢?

总结一下:

  1. 为什么使用联合进行类型修剪是不好的?
  2. 如果不是这样,他们的目的是什么?

额外信息:我主要使用C ++,但想了解有关C和C的信息。特别是,我使用联合在浮点数和通过CAN总线发送的原始十六进制之间进行转换。


1
对于并集的一种非常常见的用法,请考虑一下编译器中的词法分析器。它可以将一个令牌-值对返回到解析器,并且取决于令牌,该值可以是例如整数,浮点数,字符或指向字​​符串的指针。您如何最好地在单个结构中表示这些不同的值类型?当然是联盟。
一些程序员哥们2014年

1
在我对为什么优化杀死该函数的答案中,我详细介绍了通过联合类型修剪在C和C ++中是否合法的细节。基本上,在C中始终合法始终不清楚在C ++中是否合法,但实际上大多数编译器都在C ++中支持它。
Shafik Yaghmour 2014年

我本来想为此添加一个答案,但是忘记了,然后我又遇到了另一个问题,再次遇到了这个问题,我只是添加了我的答案。
Shafik Yaghmour 2015年

Answers:


50

重申一下,在C中,通过并集进行类型优化非常好(但在C ++中则不是)。相反,使用指针强制转换违反C99严格的别名,并且存在问题,因为不同的类型可能具有不同的对齐要求,并且如果操作不正确,则可能引发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进行评分,而不得不等到下一个标准修订版得到修复。


对标准C ++(和C90)的GNU扩展确实明确允许使用union进行类型修剪。其他不支持GNU扩展的编译器也可能支持联合类型对齐,但​​它不是基本语言标准的一部分。


2
我没有手头的C90副本来验证上下文。我确实从委员会的讨论中记得,目的之一是该措辞应允许“调试”实现,如果访问不是最后写入的元素,则该实现会陷入困境。(当然,这是在1980年代后期; C委员会的态度此后可能已经发生了变化。)我似乎记得这是通过未定义的行为实现的,但是实现定义的也可以解决问题。(这里的主要区别是,需要实施来记录它的功能。)
James Kanze 2014年

4
该脚注是非规范性的,并且在上下文中清楚地说明了委员会为何未对此进行定义。它没有定义行为。
James Kanze 2014年

4
@JamesKanze:该值是指定成员的值。这是规范性部分,脚注已阐明。如果组成该成员的对象表示形式的所有字节均采用指定的值,并且不对应于陷阱表示,则该成员也将采用指定的值。这些字节如何到达那里并不重要(通过memcpy,通过进行修改char *,通过不同的并集成员...)。您将无法以其他方式说服我,因此,除非您改变主意,否则继续进行下去可能毫无意义……
Christoph

1
我确实从委员会的讨论中记得,目的之一是该措辞应允许“调试”实现,如果访问不是最后写入的元素,则该实现会陷入困境。在80年代可能就是这种情况。当C99禁止通过指针强制类型转换时,就需要一种不同的机制。就是这个; 可悲的是,在C99的原理中似乎没有提及它,但这似乎是发生的情况
Christoph

1
我也觉得C委员会的态度已经演变;从1990年开始,我就参与了C ++的标准化工作,但并没有紧跟C的工作。但是,基本规则仍然适用:标准未定义的任何行为都是未定义的行为。这显然属于这一类。我认为(但不能证明)目的是所有类型化操作都是未定义的行为,由实现定义。
James Kanze 2014年

17

Unions的原始目的是在希望能够表示不同类型时节省空间,我们称之为变体类型,请参见Boost.Variant是一个很好的例子。

另一个常见的用途是类型修剪,其有效性尚有争议,但实际上大多数编译器都支持它,我们可以看到gcc记录了它的支持

从与最近写过的工会成员不同的工会成员那里进行阅读的做法很常见(称为“类型操纵”)。即使使用-fstrict-aliasing,只要通过联合类型访问内存,也可以进行类型修剪。因此,上面的代码按预期工作。

请注意,即使使用-fstrict-aliasing,也允许进行类型修剪进行这表明存在别名问题。

Pascal Cuoq辩称缺陷报告283澄清了这在C语言中是允许的。缺陷报告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确实会生成相同的代码,并且如果编译器未生成相同的代码,则会产生参数,因此应将其视为错误:

如果这对于您的实现而言是正确的,那么我建议您在上面提交一个错误。为了解决某些特定编译器的性能问题,打破真正的优化(任何基于基于类型的别名分析的优化)对我来说似乎是一个坏主意。

博客文章Type Punning,Strict Aliasing和Optimization也得出了类似的结论。

未定义行为的邮件列表讨论:键入punning以避免复制会涉及很多相同的方面,我们可以看到该区域的灰色程度。


1
关于memcpy产生相同代码的说法忽略了以下事实:如果编译器将记录可识别别名的模式,则编译器将只产生少量的轻微悲观(但可能是准确的)即可生成更有效的代码。假设,而memcpy通常会迫使编译器做出更悲观的假设。memcpy本身的代码看起来不错,但是对周围代码的影响却不那么大。
超级猫

值得一提的是用C ++ 17我们正在std::variant为一个变量类型
贾斯汀

2
可能还需要提及的std::memcpy是,仅当类型为TriviallyCopyable时才有效
Justin

@supercat如果您可以提供显示此效果的Godbolt示例,将非常有帮助。据我了解理查德的立场,事实并非如此,也许那是一个错误。
Shafik Yaghmour

@ShafikYaghmour:给定代码uint16_t *outptr; void store_double_halfword(uint32_t dat) { uint32_t *dp = (uint32_t*)outptr; outptr = dp+1; memcpy(dp, &dat, sizeof (uint32_t)); } void store_loop1(uint32_t *src){ for (int i=0; i<100; i++) store_next_word1(src[i]); },编译器将无法避免outptr在代码使用时每次循环都要重新加载和重新存储 memcpy。如果可以依靠编译器将强制转换从uint16_t*视为该函数可以访问typeuint16_t或的东西的标志uint32_t,从而允许使用代码...
supercat

6

在C99中是合法的:

来自标准: 6.5.2.3结构和工会成员

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


6
@JamesKanze您可以扩展“如何将值的对象表示的适当部分重新解释为新类型的对象表示,如6.2.6中所述(有时称为”类型校正”的过程)。这可能是陷阱的表示形式”,这是一种说法不明的行为吗?在我看来,它似乎表明所读内容是对新类型的重新解释,并且这是一种奇妙的方式,可以说它是实现定义的行为(如果有的话)。
Pascal Cuoq 2014年

@PascalCuoq可能导致陷阱的任何事情都是未定义的行为。
James Kanze 2014年

8
@JamesKanze我采用“这可能是陷阱表示形式”的意思是,如果新类型具有陷阱表示形式,则在实现定义的条件下,类型调整的结果可能就是其中之一。
Pascal Cuoq 2014年

1
@JamesKanze:通过联合进行类型定义是明确定义的,只要它不会导致陷阱表示(并且源类型的大小不小于目标类型);根据所涉及的类型和值,这是个案决定;C99中有一个脚注,可以很清楚地表明类型拼合是合法的;(!非规范)附件所列它错误地作为指定(未定义)行为; 附件已固定C11
克里斯托夫

1
@JamesKanze:是的,这仅适用于C;但是,以这种方式使用联合从来就不是不确定的行为。参见C89草案,第3.3.2.3节:如果在将值存储到对象的其他成员之后访问并集对象的成员,则该行为是实现定义的
Christoph

4

简要回答: 在某些情况下,类型化修剪可能是安全的。另一方面,尽管这似乎是一种非常众所周知的做法,但似乎对将其正式化并不十分感兴趣。

我只会谈论C(不是C ++)。

1.类型标记和标准

正如人们已经指出的那样,标准C99和C11(在6.5.2.3小节中)都允许键入punning。但是,我将以自己对问题的理解来重写事实:

  • 标准文档C99和C11的6.5节开发了表达主题。
  • 6.5.2小节涉及后缀表达式
  • 6.5.2.3小节讨论了结构和联合
  • 6.5.2.3(3)段说明了应用于或对象的点运算符,以及将获得哪个值。脚注95 就在此处。该脚注说: structunion

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

这一事实型夯实勉强出现,并作为注脚,它给出了一个线索,这不是在C语言中的相关问题。
实际上,使用的主要目的unions是为了节省空间(在内存中)。由于多个成员共享相同的地址,因此,如果知道每个成员都将在程序的不同部分使用(永远不会在同一时间使用),则union可以使用a代替astruct来节省内存。

  • 提到了第6.2.6小节。
  • 6.2.6小节讨论对象的表示方式(例如在内存中)。

2.类型的表示及其问题

如果您关注标准的各个方面,则几乎可以肯定:

  • 指针的表示方式没有明确规定。
  • 最糟糕的是,具有不同类型的指针可能具有不同的表示形式(如内存中的对象)。
  • union成员在内存中共享相同的标题地址,并且与union对象本身的地址相同。
  • struct通过从struct对象本身完全相同的内存地址开始,成员的相对地址不断增加。但是,可以在每个成员的末尾添加填充字节。多少?这是不可预测的。填充字节主要用于内存分配。
  • 算术类型(整数,浮点实数和复数)可以通过多种方式表示。这取决于实现方式。
  • 特别是,整数类型可能具有填充位。我相信对于台式机来说,这是不正确的。但是,标准为此保留了大门。填充位用于特殊目的(奇偶校验,信号,知道),而不用于保存数学值。
  • signed 类型可以有3种表示方式:1的补码,2的补码,正负号。
  • 这些char类型仅占用1个字节,但是1个字节可以具有8个不同的位数(但不能少于8个)。
  • 但是,我们可以确定一些细节:

    一种。该char类型没有填充位。
    b。的unsigned整数类型被表示完全一样以二进制形式。
    C。unsigned char正好占用1个字节,没有填充位,并且因为使用了所有位,所以没有任何陷阱表示。此外,它表示整数形式的二进制格式,没有任何歧义。

3. TYPE PUNNING与TYPE REPRESENTATION

所有这些观察表明,如果我们试图做的类型双关语union为成员类型的不同unsigned char,我们可以有很大的模糊性。它不是可移植的代码,尤其是,我们可能会有不可预测的行为。
但是,该标准允许这种访问

即使我们确定每种类型都在实现中表示的特定方式,我们也可以有一系列的位,在其他类型中完全没有意义(陷阱表示)。在这种情况下,我们无法做任何事情。

4.安全情况:未签名的字符

使用的唯一可靠方式型双关是具有unsigned char或井unsigned char阵列(因为我们知道数组对象的成员是严格连续的,并且没有任何填充字节时它们的尺寸被计算同sizeof())。

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

由于我们知道该unsigned char代码以严格的二进制形式表示,没有填充位,因此可以在此处使用punning类型来查看member的二进制表示形式data
在特定的实现中,可以使用此工具来分析如何表示给定类型的值。

在标准规范下,我看不到另一种安全且有用的punning类型应用程序。

5.关于Casts的评论...

如果要使用类型,最好定义自己的转换函数,或者只使用casts。我们可以记住这个简单的例子:

  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

@underscore_d:在类型绑定中没有对字符类型的明确引用。我通过收集事实得出了自己:我可以在标准C11中阅读到:(1)类型联合是通过联合成员在C中进行的有效操作,(2)尽管问题可能来自陷阱表示(3)但是字符类型没有陷阱表示形式,(4)每个字符类型都恰好占用1个字节。因此,字符类型的数组可用于“读取”联合成员中任何其他对象的字节。但是,访问原子联合(或结构)的成员时,存在未定义的行为。
pablo1977

您知道,我想我只是忽略了您所说的仅谈论C的地方。抱歉。显然,当我执行C ++研究任务时,即使不是主题,我也能看到这一切!我喜欢您对C的推理,但是将不得不假设在C ++中,它不允许进行翻页,它是UB来通过pun来实现的char(但不能通过指针来进行别名)。我觉得这些应该直接相关,但是我找不到C ++源代码,说“是的,用chara做任何您想做的事情” union。但我现在将停止旧约的答复:)
underscore_d

4

有两种(或至少是在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:标准不尝试定义涉及类型punning的行为。可能的原因是,它没有可定义的可移植行为。这不会阻止特定的实现定义它;尽管我不记得有关该问题的任何具体讨论,但是我很确定这样做的目的是实现可以定义一些东西(大多数(如果不是全部的话)都可以做到)。

关于使用联合进行类型处理:在C委员会开发C90时(在1980年代后期),明确的意图是允许调试实现进行附加检查(例如,使用胖指针进行边界检查)。从当时的讨论中可以明显看出,调试的实现可能会缓存有关在联合中初始化的最后一个值的信息,并在尝试访问其他任何内容时进行陷阱。在第6.7.2.1/16节中明确指出:“最多可以随时将一个成员的值存储在联合对象中。” 访问不存在的值存在未定义的行为;它可以被同化为访问未初始化的变量。(当时有一些关于访问具有相同类型的其他成员是否合法的讨论。我不知道最终的决议是什么。在1990年左右之后,我开始使用C ++。)

关于C89的引用,它的行为是实现定义的:在第3节(术语,定义和符号)中找到它似乎很奇怪。我必须在家中的C90副本中查找它;事实上,它已在更高版本的标准中删除,这表明委员会认为它的存在是错误的。

标准支持的并集的使用是模拟派生的一种方式。您可以定义:

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;
    //  ...
};

并合法访问base.type,即使Node是通过初始化的inner。(第6.5.2.3/6节以“做出了一项特别保证……”并继续明确允许这一事实的事实,非常有力地表明,所有其他情况都是未定义的行为。当然,是指“在本国际标准中否则以未定义的行为来表示未定义的行为”或第4/2节中省略了对行为的任何明确定义的陈述;目的是说明该行为不是未定义的,则必须显示它在标准中的定义位置。)

最后,关于类型修剪:所有(或至少我使用过的所有)实现都以某种方式支持它。当时我的印象是,意图是指针转换是实现支持它的方式。在C ++标准中,甚至有(非规范性的)文字表明reinterpret_cast熟悉基础架构的人“不足为奇”。但是,实际上,只要访问是通过并集成员,大多数实现都支持将并集用于类型修剪。大多数实现(但不支持g ++)也支持指针强制转换,前提是编译器可以清楚看到指针强制转换(对于某些未指定的指针强制转换定义)。基础硬件的“标准化”意味着:

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

实际上很便携。(当然,它不适用于大型机。)不起作用的是类似我的第一个示例的示例,在该示例中,编译器看不到别名。(我很确定这是标准中的缺陷。我似乎还记得有关于它的灾难恢复。)


3
它是在C90中由实现定义的,而不是未定义的-使其成为非法是一种C ++主义
Christoph

4
实际上,C委员会通过引入有效的类型化将指针强制转换用于类型修饰是非法的,因此使用联合是C的实现方式
Christoph

1
@Christoph在C11中仍然是未定义的行为,至少在我的副本中。第6.7.2.1/16节对此非常清楚。C ++更清晰,因为它具有对象寿命与存储持续时间分开的概念,但是即使在C语言中,访问未初始化的对象(不是按字节序列)也是未定义的行为,并且分配给并集的一个元素使所有其他“未初始化”。
James Kanze 2014年

我很抱歉,但你错误的,只要C的关注; 我写了一个答案尤其是对你,列出相关的报价
克里斯托夫

@Christoph问题在于您的论点很大程度上取决于非规范性,而不是出于上下文。重要文本在第6.7.2.1/16节中。C确实具有无效对象的概念,当访问它时会导致未定义的行为。
James Kanze 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.