访问未激活的联合成员和未定义行为?

148

我原本认为访问 union 成员变量时,除最后一个已设置的成员外,访问其他成员是未定义行为,但我似乎找不到可靠的参考资料(除了一些回答声称这是未定义行为,但没有任何标准支持)。

因此,这是否为未定义行为?


3
C99(我相信C++11也是如此)明确允许使用联合体进行类型转换。因此,我认为这属于“实现定义”的行为。 - Mysticial
1
我已经多次使用它将单个int转换为char。因此,我绝对知道它不是未定义的。我在Sun CC编译器上使用过它。因此,它可能仍然与编译器有关。 - go4sri
52
很明显,你不知道行为未定义的含义。它在某些情况下似乎对你有效,并不意味着它不是未定义的。 - Benjamin Lindley
4
相关问题:C和C++中工会的目的 - legends2k
5
@Mysticial,您提供链接的博客文章非常具体地涉及C99;而这个问题仅针对C++进行标记。请注意区分。 - davmac
显示剩余4条评论
5个回答

161
混淆的是,C明确允许通过联合进行类型转换,而C++()没有这样的权限。

6.5.2.3 结构体和联合体成员

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

C++的情况:

9.5 联合体 [class.union]

在联合体中,最多只能有一个非静态数据成员处于活动状态,也就是说,在任何时候,联合体中最多只能存储一个非静态数据成员的值。

C++后来有了语言,允许使用包含共同初始序列的struct的union;然而这并不允许类型转换。

要确定C++中是否允许联合体类型转换,我们需要进一步搜索。回想一下是C++11的规范参考(而C99有类似于C11的语言允许联合体类型转换):

3.9 类型 [basic.types]

4 - 类型T的对象表示是由该类型T的对象占用的N个无符号字符对象序列,其中N等于sizeof(T)。对象的值表示是保存T类型值的一组位。对于可平凡复制类型,值表示是对象表示中确定一个值的一组位,该值是实现定义的一组离散元素之一。42
42)意图是C++的内存模型与ISO/IEC 9899编程语言C的内存模型兼容。

当我们阅读以下内容时,它变得特别有趣

3.8 对象生命周期 [basic.life]

类型T的对象的生命周期从以下情况开始: — 获得了适合类型T的对齐和大小的存储空间,并且 — 如果对象具有非平凡的初始化,则其初始化完成。

因此,对于包含在联合中的原始类型(其本身具有平凡的初始化),对象的生命周期至少包括联合本身的生命周期。这使我们能够调用

3.9.2 复合类型 [basic.compound]

如果类型T的对象位于地址A处,则类型为cv T*的指针,其值为地址A,被称为指向该对象的指针,不管该值是如何获得的。

假设我们感兴趣的操作是类型转换,即获取非活动联合成员的值,并且根据上述内容,我们拥有对该成员所引用的对象的有效引用,则该操作是左值到右值的转换:

4.1 左值到右值的转换 [conv.lval]

非函数、非数组类型T的glvalue可以转换为prvalue。如果T是不完整的类型,则需要进行此转换的程序是非法的。如果glvalue所引用的对象不是类型T的对象,也不是派生自类型T的对象,或者对象未初始化,则需要进行此转换的程序具有未定义的行为。

问题在于,非活动联合成员是否由存储初始化为活动联合成员。据我所知,这不是这种情况,因此尽管如果:
- 将联合体复制到 char 数组存储器中再返回(3.9:2),或 - 以字节方式复制到同一类型的另一个联合体中(3.9:3),或 - 被符合 ISO/IEC 9899 的程序元素跨语言边界访问(就定义而言)(3.9:4 注42),那么
通过非活动成员访问联合体是被定义的,并且被定义为遵循对象和值表示,但没有上述干预之一的访问是未定义行为。这对于允许在这样的程序上执行的优化有影响,因为实现当然可以假定未定义的行为不会发生。
也就是说,虽然我们可以合法地形成指向非活动联合成员的 lvalue(这就是为什么在没有构造的情况下分配给非活动成员是可以的原因),但它被认为是未初始化的。

6
3.8/1 表示当一个对象的储存空间被重用时,该对象的生命周期结束。这意味着联合体中非激活成员的生命周期已经结束,因为其存储空间已被用于激活成员。这意味着您在使用成员时受到限制(3.8/6)。 - bames53
2
在这种解释下,那么每个内存位同时包含所有类型的对象,这些对象都是可以平凡初始化并具有适当对齐的。那么,任何非平凡可初始化类型的生命期是否立即结束,因为它们的存储被重用于所有这些其他类型(而不会重新开始,因为它们不是平凡可初始化的)? - bames53
3
4.1这个规则完全有误,已被修正。它禁止了一些完全合法的操作:比如禁止使用自定义的memcpy实现(使用unsigned char左值访问对象),禁止在int *p = 0; const int *const *pp = &p;后访问*p(尽管从int **const int *const *的隐式转换是有效的),甚至禁止在struct S s; const S &c = s;后访问c。新的表述是否允许这些操作?还有关于[lval]的内容。 - user743382
2
@Omnifarious:这样做是有道理的,但也需要澄清(顺便说一下,C标准也需要澄清)当应用于联合成员时,一元&运算符的含义。我认为生成的指针应该可用于访问该成员,至少在下一次直接或间接使用任何其他成员lvalue之前,但在gcc中,指针甚至不能使用那么长时间,这引发了一个问题,即&运算符应该意味着什么。 - supercat
4
关于 "记得 c99 是 C++11 的规范参考" 这一问题,它是否只在 C++ 标准明确引用 C 标准(例如 C 库函数)时才相关? - MikeMB
显示剩余17条评论

31
C++11标准这样规定:
9.5 联合体
在联合体中,最多只能有一个非静态数据成员处于活动状态,也就是说,在任何时候,最多只能存储一个非静态数据成员的值。
如果只存储了一个值,那么如何读取另一个值呢?它就不存在了。

gcc文档将此列在实现定义的行为

  • 使用不同类型的成员访问联合对象的成员(C90 6.3.2.3)。

对象表示中相关字节被视为使用于访问的类型的对象。请参见类型转换。这可能是一个陷阱表示。

表明这不是C标准所要求的。


2016年1月5日:通过评论,我被链接到了C99缺陷报告#283,该报告在C标准文档中添加了类似的文本作为脚注:

78a)如果用于访问联合对象内容的成员与用于在对象中存储值的最后一个成员不同,则该值的对象表示的适当部分将根据6.2.6中描述的方式重新解释为新类型的对象表示(有时称为“类型游戏”)。这可能是一个陷阱。

不确定它是否澄清了很多问题,考虑到脚注对于标准来说并非规范性的。


13
@LuchianGrigore: "UB并不是标准所说的UB,而是标准没有描述其工作方式的情况。这正是这种情况。标准是否描述了会发生什么?它是否说这是实现定义?答案都是否定的。因此,这就是UB。此外,关于“成员共享同一内存地址”的论点,您将不得不参考别名规则,这将再次导致UB。" - Yakov Galka
5
很清晰了解active的意思,即“在联合体中最多只能存储一个非静态数据成员的值。” - Benjamin Lindley
6
是的,确实有。标准无法涵盖所有情况,这是无限的。(C++是一个图灵完备的虚拟机,所以它是不完整的)。那又怎样?它确实解释了“活跃”的含义,在引用上面的语句后,“即”之后。 - Yakov Galka
9
@LuchianGrigore:根据定义部分,行为的明确定义被省略也属于未经考虑的未定义行为。 - jxh
5
@Claudiu,这个UB的原因不同——它违反了严格别名规则。 - Mysticial
显示剩余20条评论

19
我认为标准所说的未定义行为最接近的地方是定义了包含共同初始序列的联合体的行为(C99,§6.5.2.3/5):

为了简化对联合体的使用,提供了一个特殊的保证:如果一个联合体包含多个结构,这些结构共享一个公共初始顺序(见下文),并且如果该联合体对象当前包含其中之一,则允许在任何声明联合体的完整类型可见的地方检查它们中任何一个的公共初始部分。 如果相应成员具有兼容类型(对于位域,宽度相同)的话,则两个结构共享公共初始序列,用于一个或多个初始成员的序列。

C++11在§9.2 / 19给出了类似的要求/允许:

如果标准布局联合包含两个或多个共享公共初始序列的标准布局结构,并且如果标准布局联合体对象当前包含其中之一,则允许检查其中任何一个的公共初始部分。 如果相应成员具有布局兼容类型,并且没有成员是位字段 或者两个成员都是具有相同宽度的位字段,则两个标准布局结构分享共同的初始序列,用于一个或多个初始成员的序列。

虽然两者都没有直接说明,但两者都表明“允许”(读取)成员的“检查”仅在以下情况下才被“允许”,即1)它是最近写入的成员的一部分,或者2)是公共初始序列的一部分。
这不是直接声明否则会产生未定义行为,但这是我所知道的最接近的。

为了使这个完整,你需要知道 C++ 中的“布局兼容类型”或 C 中的“兼容类型”是什么。 - Michael Anderson
2
@MichaelAnderson:是的和不是。如果您想确定某些内容是否属于此异常,那么需要处理这些内容。但真正的问题在于,明显不属于该异常的内容是否会产生未定义行为。我认为这里已经暗示得足够强烈,以使意图清晰,但我认为它从未直接说明。 - Jerry Coffin
这个“共同的初始序列”可能会让我从重写垃圾箱中挽救2或3个项目。当我第一次读到大多数联合使用的双关语是未定义的时,我感到非常愤怒,因为一个特定的博客给了我这样的印象,认为这是可以的,并围绕它构建了几个大型结构和项目。现在我认为我可能还好,因为我的联合包含具有相同类型的类在前面。 - underscore_d
@underscore_d:C标准至少在某种程度上涵盖了这个问题:“适当转换的结构对象指针指向其初始成员(或者如果该成员是位域,则指向其所在的单元),反之亦然。” - Jerry Coffin
谢谢,Jerry。我有两个疑问:我假设这是C++规范中的C版本/部分?指针(别名)的规则是否也隐含地适用于联合(类型转换)?在试图理解这一点的过程中,我经常看到这两个问题被讨论为等价的,但我不知道这是否正确。 - underscore_d
显示剩余2条评论

12

目前可用的答案中还没有提到的是6.2.5节第21段中的脚注37:

请注意,聚合类型不包括联合类型,因为具有联合类型的对象一次只能包含一个成员。

这个要求似乎明确暗示着您不能在一个成员中写入并在另一个成员中读取。 在这种情况下,由于缺乏规范,可能会出现未定义行为。


许多实现都会记录它们的存储格式和布局规则。这样的规范在许多情况下会暗示读取一种类型的存储并将其写入另一种类型时的效果,除非有规则说明编译器不必在使用字符类型指针读取和写入时实际使用其定义的存储格式。 - supercat

-5

我将用一个示例来说明这个问题。
假设我们有以下联合:

union A{
   int x;
   short y[2];
};

我假设sizeof(int)为4,sizeof(short)为2。
当你写union A a = {10}时,它将创建一个新的A类型变量,并将值10放入其中。

你的内存应该看起来像这样:(请记住,联合体成员都占用相同的位置)

       |                   x                   |
       |        y[0]       |       y[1]        |
       -----------------------------------------
   a-> |0000 0000|0000 0000|0000 0000|0000 1010|
       -----------------------------------------

正如你所看到的,a.x的值为10,a.y1的值也是10,而a.y[0]的值为0。

现在,如果我这样做会发生什么呢?

a.y[0] = 37;

我们的内存将会是这样的:

       |                   x                   |
       |        y[0]       |       y[1]        |
       -----------------------------------------
   a-> |0000 0000|0010 0101|0000 0000|0000 1010|
       -----------------------------------------

这将把a.x的值转换为2424842(十进制)。

现在,如果你的联合体有一个浮点数或双精度浮点数,你的内存映射将会更加混乱,因为你存储精确数字的方式不同。 你可以在这里获得更多信息。


21
这不是我所问的。我知道内部发生了什么,我知道它有效。我问的是它是否符合标准。 - Luchian Grigore

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