C99中是否未指定使用联合进行类型转换,并且在C11中是否已经被指定?

70

Stack Overflow上一个关于获取float类型的IEEE单精度位数的问题中,有很多回答建议使用union结构进行类型转换(例如:将float的位数转换为uint32_t):

union {
    float f;
    uint32_t u;
} un;
un.f = your_float;
uint32_t target = un.u;
然而,根据C99标准(至少是n1124草案),联合体的uint32_t成员的值似乎是未指定的。其中,第6.2.6.1.7节规定:

当一个值存储在联合类型对象的成员中时,对象表示的字节不对应于该成员但对应于其他成员的字节采用未指定的值。

C11 n1570草案的至少一个脚注似乎暗示这不再是情况(请参见6.5.2.3中的脚注95):

如果用于读取联合体对象内容的成员与上一次用于存储对象中值的成员不同,则将值的对象表示的相应部分重新解释为新类型的对象表示,如6.2.6所述(这个过程有时被称为 "类型判定")。 这可能是陷阱表示。

然而,在C99草案和C11草案中,第6.2.6.1.7节的文本是相同的。

在C99下,这种行为是否真的是未指定的?在C11中是否已经变得规范化了?我意识到大多数编译器似乎支持此功能,但是知道它是否被规范化或只是一个非常常见的扩展将是不错的。


9
技术提示:访问存储的最后一个联合成员以外的成员不会导致程序违反C标准。访问这样的联合成员将导致未指定的值(不是未定义行为),并且根据C 1999 4 3,“应为正确的程序并遵守5.1.2.3。”此外,编译器可能提供有关该值的附加保证,并保持符合实现。 - Eric Postpischil
2
@DanielFischer:n1124和n1570草案都明确列出了在附录J(可移植性问题)中未指定的内容:“联合成员的值,除最后一个存储的成员外(6.2.6.1)”。对我来说,这似乎意味着可能存在一种C99(或C11)编译器,使用联合进行类型转换时不会产生我们所期望的结果。 - sfstewman
4
请再读一遍,它说那些对应于另一个成员而不是写入的成员的字节具有未指定的值。这意味着对应于该成员的字节(即两个成员共同的字节)有一个特定的值,即被写入的值。该段仅用于解释未被写入的字节发生了什么(或没有发生),仅此而已。 - Jens Gustedt
4
@sfstewman,附录J不具备规范性。 - Jens Gustedt
2
如果在写入第一个值和读取第二个值之间,代码检查联合体字段所占用的字节,则标准将指示这些字节必须包含什么内容。我不知道旧标准中是否有任何东西可以防止编译器将联合体中的float优化为FPU寄存器,并将其覆盖的int优化为CPU寄存器,并仅在强制遵守char *别名规则时才从内存中读取/写入这些寄存器。 - supercat
显示剩余13条评论
4个回答

46

从C89到C99,联合类型强制转换的行为发生了变化。C99中的行为与C11相同。

正如Wug在他的答案中指出的那样,C99 / C11允许类型强制转换。当联合成员大小不同时,读取可能是陷阱的未指定值。

C99在Clive D.W. Feather Defect Report #257之后添加了脚注。

最后,从C90到C99的一个变化是移除了对于在最后一次存储不同成员时访问联合体任意成员的限制。其基本原理是行为将取决于值的表示方式。由于这一点经常被误解,因此在标准中明确说明可能非常有价值。
为了解决“类型转换”的问题,在6.5.2.3#3中的“命名成员”一词后添加新的脚注78a: 如果用于访问联合对象内容的成员与上次用于在对象中存储值的成员不同,则该值的对象表示的适当部分将根据6.2.6中描述的方式重新解释为新类型的对象表示(有时称为“类型转换”)。这可能是陷阱表示。
Clive D.W. Feather的措辞已被C委员会接受为技术勘误,并在缺陷报告#283的答复中得到了确认。

5
DR不够清晰,而脚注并不规范,只能解释其他地方定义的内容。此外,DR真的没有澄清任何事情。问题很混乱,因为WG本身就存在困惑。(另外,Wug关于“类型转换”的含义是错误的。) - curiousguy
引用的文本似乎并不支持你的结论。它说:“这可能是一个陷阱表示法。” - andrewrk
3
我的结论是,在C99和C11中允许类型别名。在写入成员之后读取另一个成员的陷阱表示不会改变这个结论。这意味着在某些系统上,使用某些特定的值可能会导致未定义行为。类似地,如果您使用一些特定的操作数值来使用二元运算符*,您也容易遇到未定义的行为(有符号整数溢出),这并不意味着该运算符本身就是UB或者不允许使用。 - ouah
@curiousguy 我的观点是,他们选择通过脚注来澄清混淆并不是最好的方式。他们还应该修改C99中的6.2.6.1p7,使事情更加明确和规范化。 - ouah
即使它们是相同的大小,您仍可以获得陷阱表示。不确定为什么您要将其单独用于不同大小的情况。 - Peter Cordes

21

原始的C99规范没有具体说明这一点。

C99技术勘误之一(我认为是TR2)添加了脚注82来纠正这个疏漏:

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

该脚注保留在C11标准中(在C11中是脚注95)。


我认为我们在问题的评论中已经讨论过了,这是由1999年C标准规定的,对于相同大小的成员,至少应定义足够推断值的信息,以满足符合要求的实现。 - Eric Postpischil
@EricPostpischil中写道:“规范实现需要定义足以推断出值的信息”。这是在哪里要求的? - curiousguy
1
@curiousguy:正如在该问题的评论中所述,C 1999年6.2.6.1条款2(以及C 2011年的同一段)指出形成对象的字节的数量、顺序和编码要么是显式地由标准指定的,要么是由实现定义的。 - Eric Postpischil
@EricPostpischil 谢谢您。 (我发现这个事实有点令人惊讶。) - curiousguy
2
不幸的是,写代码的人没考虑到在写一个联合体成员并读取另一个时缺少定义值,这是为了当函数传递指向联合体对象的不同成员的指针时,来使编译器行为合理化。如果真的合法,直接使用联合体成员的地址并使用所得到的指针,而不是先转换为"char"类型或使用memcpy,那么标准中没有任何理由来证明通过指针写入一个联合体成员并读取另一个会和直接写入和读取联合体成员有不同的行为。 - supercat

12
这一直以来都有点“靠不住”。正如其他人所指出的,通过技术勘误,C99中添加了一个脚注。它的内容如下:
如果用于访问联合对象的成员与最后用于在对象中存储值的成员不同,那么该值的对象表示的适当部分将被重新解释为新类型的对象表示,如6.2.6中所述(有时称为“类型切换”)。这可能是一个陷阱表示。
然而,脚注在前言中被规定为非规范性的:
附录D和F构成本标准的规范部分;附录A、B、C、E、G、H、I、J、参考文献和索引仅供参考。根据ISO/IEC指南第3部分的规定,本前言、介绍、注释、脚注和示例也仅供参考。
那就是说,脚注不能规定行为;它们只应该澄清现有的文本。这是一个不受欢迎的观点,但上面引用的脚注实际上在这方面失败了 - 在规范文本中并没有禁止这样的行为。事实上,还存在着矛盾的部分,比如6.7.2.1:
“...联合对象中最多只能存储一个成员的值”
与6.5.2.3(关于使用“.”操作符访问联合成员)一起:
“该值是指定成员的值”
也就是说,如果只能存储一个成员的值,那么另一个成员的值就不存在;由于“该值是指定成员的值”,命名一个当前未存储值的成员必须产生一个不存在的值。这强烈暗示通过联合进行类型转换是不可能的。C11文档中仍然存在相同的文本。
很明显,添加脚注的目的是为了明确允许类型转换;只是委员会似乎在违反不包含规范文本的脚注规则时引入了矛盾。要接受这个脚注,你必须真正忽视那部分说脚注不是规范的内容,或者试图找出如何解释规范文本以支持脚注的结论(我已经尝试过,但失败了),然后你还需要将其与我上面提到的“不存在的值”问题协调一致。
关于批准这个脚注,我们能做的最好的办法就是对6.2.5中关于联合体定义为“重叠对象集合”的一些假设:

联合类型描述了一个重叠的非空成员对象集合,每个对象都可以有一个可选的名称和可能不同的类型。

很遗憾,对于“重叠”一词并没有详细的解释。一个对象被定义为在执行环境中的“数据存储区域,其内容可以表示值”(根据上述“重叠对象”的定义,同一存储区域可以被两个或更多不同的对象所标识,也就是说,对象具有与其存储区域分离的身份)。合理的假设似乎是联合体成员(特定联合体实例的成员)使用相同的存储区域。
即使我们忽略6.7.2.1/6.5.2.3,并允许根据脚注的建议,读取任何联合体成员都返回与相应存储区域的内容相对应的值——这将允许类型转换——然而,6.5中令人头疼的严格别名规则(strict-aliasing rule)禁止(除了某些小的例外情况)通过非本类型来访问对象。由于“访问”是指(3.1)“读取或修改对象的值的〈执行时动作〉”,并且由于修改一组重叠对象中的一个必然会修改其他对象,因此通过写入联合体成员(无论是否通过另一个成员进行读取)可能会违反严格别名规则。
例如,根据标准的措辞以及每个成员作为一个独立对象存在且重叠存储的概念,以下情况似乎是非法的:
union {
   int a;
   float b;
} u;

u.b = 0.5;  // store a float value in the union object subobject
u.a = 0; // (#1) modifies a float object by an lvalue of type int
int *pa = &u.a;
*pa = 1; // (#2) also modifies a float object, without union lvalue involved

具体来说,标记为#1和#2的行将违反严格别名规则。在这两种情况下,如果将值存储到成员中会清除先前活动成员的值,那么可能可以避免这种情况,正如6.7.2.1所建议的那样,尽管先前指出这基本上禁止了通过联合进行类型转换。
严格来说,脚注涉及一个单独的问题,即读取非活动联合成员;然而,严格别名规则与上述其他部分结合使用严重限制了其适用性,特别是意味着它不允许一般的类型转换(只允许特定类型的组合)。
令人沮丧的是,负责制定标准的委员会似乎打算通过联合通常实现类型转换,但似乎并不担心标准的规范文本仍然没有对此提出要求。
还值得注意的是,共识理解(由编译器供应商)似乎是允许通过联合进行类型转换,但“访问必须通过联合类型”(例如上面示例中的第一行注释,但不包括第二行)。目前还不太清楚这是否适用于读取和写入访问,并且在标准文本中没有任何支持(忽略脚注)。
总结一下:虽然普遍认为通过联合体进行类型游戏是合法的(大多数人认为只有在“通过联合类型”访问时才允许,可以这么说),但标准的规范措辞禁止除某些琐碎情况外的所有类型游戏,并且实际上存在着超出(非规范的)脚注所暗示的类型游戏的限制。
你引用的那一节需要仔细阅读:“当一个值存储在联合类型对象的成员中时,与该成员不对应但与其他成员对应的对象表示的字节将具有未指定的值。”
然而,必须仔细阅读这句话。“与该成员不对应的对象表示的字节”是指超出成员大小的字节,这本身并不是类型游戏的问题(除了你不能假设写入联合成员会保持任何更大成员的“额外”部分不变)。

哦... § 6.2.5/20 表明联合成员重叠。结合“联合对象中最多只能存储一个成员的值”这一点,可以将后者解释为指出这种重叠存储空间可以同时包含恰好一个成员的值,这意味着所有非活动成员在此时都是某些或所有活动成员的替代视图(因为它们都寻址同一重叠存储空间)。 - Justin Time - Reinstate Monica
这感觉不应该是一个有效的解释,但从技术上讲它确实符合标准的字面意思。 - Justin Time - Reinstate Monica
@JustinTime “这也就意味着,所有不活跃的成员在那个时候都将是某些或所有活跃成员的备选视图(因为它们寻址相同的重叠存储空间)”,在我看来,这是一种推论而不是明确的逻辑必然性;问题在于除了通过非规范脚注之外,并没有真正指定“重叠”包含什么内容,而将对象概念仅作为存储的一种视图与严格别名规则等存在一定的不一致性。 - davmac
@JustinTime 无论如何,6.5.2.3中成员访问的定义存在问题。如果值是“命名成员的值”,而命名成员没有存储的值,则显然存在问题。它没有说“该值是由相应成员对象中存储的表示确定的值”,这就需要您的解释了,我认为。尽管如我在回答中所说,这可能是实际预期的内容。 - davmac
1
@JustinTime:在C89下,给定union {T1 v1; T2 v2;} u;u.v1 = thing1; thing2 = u.v2;T1 *p1=&u.v1; T2 *p2=&u.v2; *p1=thing1; thing2=*p2;这两种行为在标准中的定义是相同的(例如涉及兼容类型或公共初始序列规则的情况),并且在所有其他情况下都是实现定义的。实现可以根据使用的左值形式以不同的方式处理此类访问,但是C89中没有任何区别。如果在第一种情况下不允许类型转换,则C89也不能在第二种情况下这样做。 - supercat
显示剩余2条评论

0
然而,这似乎违反了C99标准(至少是草案n1124),其中第6.2.6.1.7节规定了一些内容。在C99下,这种行为是否未指定?

不,你没问题。

当一个值被存储在联合类型对象的成员中时,与该成员不对应但与其他成员对应的对象表示的字节将取未指定的值。

这适用于不同大小的数据块。也就是说,如果你有:

union u
{
    float f;
    double d;
};

如果你将某些东西分配给f,它会更改d的低4个字节,但上4个字节将处于不确定状态。

联合体主要用于类型游戏。


31
联合体并不仅仅是为了实现类型转换而存在。联合体存在的原因是有时候你想要存储一种类型的对象并在以后检索它,有时候你又想要存储另一种类型的对象并在以后检索它。 - Eric Postpischil
11
工会的存在仅仅是为了类型转换。我认为联合体被添加到语言中是为了节省空间。 - ouah
26
不,类型转换是指写入一个成员后读取另一个成员。写入某个成员并读取相同成员不算作类型转换。写入一个成员、读取它、再写入第二个成员并读取第二个成员也不算是。当您读取与最后写入的成员相同的成员时,您没有更改类型,因此没有规避类型系统。 - Eric Postpischil
14
"Type punning" 通常指的是将数据以一种类型写入,然后将相同的位作为另一种类型读取。但是,"union" 常常与 "struct" 和 "enum" 一起使用,表示联合体当前保存的是哪种类型。例如,一个解释器可能有一个 struct value,它可以包含整数或浮点值,其中 .type = T_INT 并且 .u.int_val = 123,或者 .type = T_FLOAT 并且 .u.float_val = 4.56。在这种情况下,您只希望从 .u 中读取与最初写入的相同类型,并且我不认为这被称为 "type punning"。 - Matthew Slattery
8
我认为在同一物理位置上使用单个容器结构存储不同类型的任意值是符合条件的。但很抱歉,你不能定义什么符合“类型双关”的标准。这是一个旧的、精确定义的概念(重新解释对象的字节作为另一种类型)。重用未使用的存储空间来写入另一种类型的对象绝对不是“类型双关”! - curiousguy
显示剩余4条评论

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