C和C++中联合体的目的

327

我以前使用联合体感到很自在;今天当我读到这篇文章后,我感到很震惊,因为我发现这段代码存在风险。

union ARGB
{
    uint32_t colour;

    struct componentsTag
    {
        uint8_t b;
        uint8_t g;
        uint8_t r;
        uint8_t a;
    } components;

} pixel;

pixel.colour = 0xff040201;  // ARGB::colour is the active member from now on

// somewhere down the line, without any edit to pixel

if(pixel.components.a)      // accessing the non-active member ARGB::components

访问联合体的非最近写入成员实际上是未定义的行为,即会导致未定义的行为。如果这不是联合体预期的用法,那么什么是呢?有人可以详细解释一下吗?

更新:

事后我想澄清几件事情。

  • 对于 C 和 C++,问题的答案并不相同;我的无知年少时标记了两个标签。
  • 在研究过 C++11 的标准后,我无法得出结论:访问/检查非活动联合成员是否未定义/未指定/实现定义。我能找到的只有 §9.5/1:

    如果一个标准布局联合包含共享公共初始序列的多个标准布局结构,并且此标准布局联合类型的对象包含标准布局结构之一,则允许检查任何标准布局结构成员的公共初始序列。 §9.2/19:如果对应成员具有布局兼容类型且没有成员是位字段或两个成员是具有一个或多个初始成员相同宽度的位字段,则两个标准布局结构共享一个公共初始序列。

  • 而在 C 中,(C99 TC3 - DR 283以及之后的版本)是合法的 (感谢 Pascal Cuoq 指出这一点)。但是,如果读取的值恰好是类型不合法的值(称为“陷阱表示”),则尝试执行仍可能导致未定义行为。否则,读取的值是实现定义的。
  • C89/90 在未指定的行为下对此进行了说明(Annex J),K&R 的书称其为实现定义。来自 K&R 的引用:

    这是联合的目的——可以合法地容纳多种类型之一的单个变量。只要使用是一致的:检索的类型必须是最近存储的类型。程序员有责任跟踪哪种类型当前存储在联合中;如果将某些东西存储为一种类型并提取为另一种类型,则结果取决于实现。

  • 从 Stroustrup 的 TC++PL 中提取(强调我的)

    使用联合对于数据的兼容性可能是必不可少的[...]有时会被误用于“类型转换”

最重要的是,这个问题(标题自问以来仍未更改)的目的是了解联合的目的,而不是标准允许什么例如,当然可以使用继承进行代码重用,但是将其作为 C++ 语言特性引入的目的或原意并非如此。这就是 Andrey 的答案继续保持为被接受的原因。


12
简单地说,编译器可以在结构体的元素之间插入填充。因此,“b,g,r”和“a”可能不是连续的,因此不能匹配“uint32_t”的布局。这是除其他人指出的字节顺序问题外的另一个问题。 - Thomas Matthews
11
这正是为什么你不应该使用C和C++标签来提问的原因。 回答是不同的,但由于回答者甚至不告诉他们回答哪个标签(他们是否知道?),你会得到垃圾回答。 - Pascal Cuoq
8
谢谢您的评价,我明白您希望我神奇地理解您的不满,并且在未来不再重复 :P - legends2k
1
关于引入_union_的初衷,请记住C标准比C unions晚了几年。快速查看Unix V7可以发现一些通过联合进行的类型转换。 - ninjalj
4
在查阅C++11标准后,我无法得出结论,即访问/检查非活动联合成员是未定义的。我所能找到的只有§9.5/1。真的吗?你引用了一个例外注释,而不是段落开头的主要观点:“在联合中,最多只能有一个非静态数据成员处于活动状态,也就是说,在任何时候,联合中最多只能存储一个非静态数据成员的值。”-并且到第4页:“一般来说,必须使用显式析构函数调用和放置new运算符来更改联合的活动成员”。 - underscore_d
显示剩余4条评论
16个回答

539

联合体的目的非常明显,但由于某些原因,人们经常忽略它。

联合体的目的是通过在不同时间使用相同的内存区域来存储不同的对象,从而节省内存。就是这样。

就像一个酒店里的房间一样。不同的人在不重叠的时间段内居住在其中。这些人从未见面,通常也不知道彼此的任何事情。通过适当地管理房间的时间共享(即确保不同时将不同的人分配到一个房间中),一个相对较小的酒店可以为相对较多的人提供住宿,这就是酒店存在的意义。

这正是联合体所做的。如果你知道程序中的几个对象具有非重叠的值寿命,则可以将这些对象“合并”为一个联合体,从而节省内存。就像一个旅馆房间每次只有一个“活跃”的租户一样,一个联合体每次程序时间只有一个“活跃”的成员。只有“活跃”成员可以被读取。通过写入其他成员,你可以将“活跃”状态切换到该其他成员。

由于某些原因,联合体的这一原始目的被完全不同的东西所“取代”:写入联合体的一个成员,然后通过另一个成员进行检查。这种内存重新解释(又称“类型游戏”)不是联合体的有效用法。它通常会导致未定义的行为在 C89/90 中被描述为产生实现定义的行为。

编辑:在C99标准的技术勘误中,对于使用联合体进行类型游走(即写入一个成员然后读取另一个成员)提供了更详细的定义(请参见DR#257DR#283)。但是,请记住,严格来说这不能保护您免受尝试读取陷阱表示所导致的未定义行为的影响。

51
+1 是为了表扬你详细阐述,提供简单实用的例子,并谈论工会的遗产! - legends2k
7
我对这个答案的问题是,我看到大多数操作系统都有头文件可以完成这个功能。例如,我在早期(64位之前)的Windows和Unix系统中都看到过<time.h>中有这样的头文件。如果我需要理解以这种方式工作的代码,那么简单地将其视为“无效”和“未定义”是不够的。请给出更完整的解释。 - T.E.D.
36
“直到最近,使用联合体进行类型混用从未被认为是合法的。”:2004年并不算“非常近”,尤其考虑到仅有C99最初措辞不当,似乎使得通过联合体进行类型混用成为未定义行为。实际上,通过联合体进行类型混用在C89中就是合法的,在C11中也是合法的,并且在C99中一直都是合法的,尽管直到2004年委员会才修正了错误的措辞,并随后发布了TC3。http://www.open-std.org/jtc1/sc22/wg14/www/docs/dr_283.htm - Pascal Cuoq
10
编程语言是由标准定义的。C99 标准的技术勘误 3 在脚注 82 中明确允许类型转换,我邀请您自行阅读。这不是电视节目,在那里摇滚明星接受采访并发表他们对气候变化的观点。 Stroustrup 的观点对 C 标准的规定没有任何影响。 - Pascal Cuoq
9
“我知道个人的观点并不重要,只有标准才重要。” 编译器作者的观点比(极其糟糕的)语言“规范”更加重要。 - curiousguy
显示剩余23条评论

49
您可以使用联合体创建类似以下结构的结构体,其中包含一个字段,告诉我们实际使用了联合体的哪个组件:
struct VAROBJECT
{
    enum o_t { Int, Double, String } objectType;

    union
    {
        int intValue;
        double dblValue;
        char *strValue;
    } value;
} object;

2
我完全同意,避免进入未定义行为的混乱状态,也许这是我能想到的联合体最好的预期行为;但是当我只是使用intchar*来处理10个对象[]时,它不会浪费空间吗?在这种情况下,我实际上可以为每种数据类型声明单独的结构体,而不是VAROBJECT吗?这样做不会减少混乱并节省空间吗? - legends2k
3
传说:在某些情况下,你根本无法实现这一点。当你在Java中使用Object时,在某些情况下,你可以像在C语言中使用VAROBJECT一样使用它。 - Erich Kitzmueller
“标记联合”(tagged unions)的数据结构似乎是联合(unions)唯一合法的用途,正如您所解释的那样。 - legends2k
同时提供一个如何使用这些值的示例。 - Ciro Santilli OurBigBook.com
@legends2k:想象一下一个回调函数,它有一个指向VAROBJECT结构的指针。如果没有联合体,你需要三个不同的回调来实现同样的功能:一个在存储的值是int时触发,另一个在存储的值是double时触发,还有一个在存储的值是char*时触发。使用联合体,您可以节省三个不同函数原型的麻烦,但客户端必须通过检查相关的enum来确定实际值的类型。 - Daniel Kamil Kozar
看起来实现 Rust 的 Option<T> 的正确方式。 - Kotauskas

34

从语言角度来看,这种行为是不确定的。考虑到不同的平台可能有不同的内存对齐和字节序约束。在大端与小端机器上的代码将以不同的方式更新结构中的值。在语言中修复这种行为需要所有实现都使用相同的字节序(以及内存对齐约束...),这将限制使用。

如果您正在使用C++(您使用了两个标签),并且您真正关心可移植性,那么您可以使用结构体并提供一个设置器,该设置器采用uint32_t并通过位掩码操作适当设置字段。在C中也可以使用函数进行相同操作。

编辑:我希望AProgrammer能够写下一个答案以投票并关闭此问题。正如一些评论所指出的,字节序在标准的其他部分中处理,让每个实现决定如何处理,并且对齐和填充也可以以不同的方式处理。现在,AProgrammer隐含地提到的严格别名规则在这里是一个重要的问题。编译器可以对变量的修改(或缺乏修改)做出假设。在联合的情况下,编译器可以重新排序指令并将对每个颜色分量的读取移动到对颜色变量的写入上方。


2
@legends2k,问题在于优化器可能会假设写入uint8_t时不会修改uint32_t,因此当优化使用该假设时,您会得到错误的值... @Joe,只要访问指针就会出现未定义行为(我知道,有一些例外情况)。 - AProgrammer
1
@legends2k/AProgrammer:reinterpret_cast 的结果是实现定义的。使用返回的指针不会导致未定义的行为,只会导致实现定义的行为。换句话说,行为必须是一致和定义的,但它并不具备可移植性。 - JoeG
1
@legends2k:任何像样的优化器都会识别选择整个字节的位运算,并生成读/写字节的代码,与联合相同但定义明确(且可移植)。例如:uint8_t getRed() const { return colour & 0x000000FF; }void setRed(uint8_t r) { colour = (colour & ~0x000000FF) | r; } - Ben Voigt
1
@curiousguy:标准规定,如果sizeof(某类型)报告N,则将指向该类型的指针转换为char *并读取N个值将产生一些(不一定唯一的)无符号char值序列。它还指定,使用这样的char值序列覆盖对象将将其值设置为将产生该序列的值。标准可以指定联合体必须表现得好像它持有一系列无符号char值,并且读写的效果将根据这些值的效果来定义。 - supercat
@curiousguy:我不知道是否有任何实现定义相反的行为(而不是未指定任何行为),因此始终以这种方式行事的实现应该能够满足所有行为期望。 - supercat
显示剩余24条评论

31

我经常遇到的最常见的union用法是别名

考虑以下内容:

union Vector3f
{
  struct{ float x,y,z ; } ;
  float elts[3];
}

这是什么作用?它允许通过名称来整洁、简洁地访问Vector3f vec;的成员:

vec.x=vec.y=vec.z=1.f ;

或通过数组的整数访问

for( int i = 0 ; i < 3 ; i++ )
  vec.elts[i]=1.f;
在某些情况下,按名称访问是您可以执行的最清晰的操作。在其他情况下,特别是当轴是通过编程选择时,更容易做的事情是通过数值索引访问轴 - x为0,y为1,z为2。

3
这也被称为“类型品纳”,这也在问题中提到过。问题中的示例也展示了一个类似的例子。 - legends2k
7
不是类型双关。在我的例子中,类型匹配,所以没有“双关”,只是别名。 - bobobobo
4
是的,但从语言标准的绝对角度来看,被写入和读取的成员是不同的,正如问题中提到的那样,这是未定义的。 - legends2k
6
希望未来的标准可以修复这个特殊情况,使其符合“公共初始子序列”规则。然而,按照当前措辞,数组不参与该规则。 - Ben Voigt
3
@curiousguy: 显然没有要求结构成员必须没有任意填充。如果代码检查结构成员的位置或结构大小,那么如果直接通过联合访问,代码应该可以工作,但是对标准的严格解读表明,获取联合或结构成员的地址会产生一个指向不能用作自身类型指针的指针,而必须先转换回包含类型或字符类型的指针。任何可行的编译器都会扩展语言,使更多的事情能够正常工作... - supercat
显示剩余14条评论

10

正如你所说,这是严格未定义的行为,尽管它在许多平台上“工作”。使用联合的真正原因是创建变体记录。

union A {
   int i;
   double d;
};

A a[10];    // records in "a" can be either ints or doubles 
a[0].i = 42;
a[1].d = 1.23;

当然,您还需要某种鉴别器来说明变体实际包含的内容。请注意,在C++中,联合并不是很有用,因为它们只能包含POD类型 - 实际上是那些没有构造函数和析构函数的类型。

你用过它吗(就像问题中的那样)? :) - legends2k
有点学究气,但我不太接受“变体记录”。也就是说,我确定它们在设计时考虑过,但如果它们很重要,为什么不提供呢?“提供构建块,因为它可能对构建其他东西也有用”似乎更符合直觉。特别是考虑到可能还有至少一个应用程序 - 内存映射I/O寄存器,在其中输入和输出寄存器(虽然重叠)是具有自己名称、类型等的不同实体。 - user180247
@Stev314如果这是他们当初的意图,他们本可以让它不会出现未定义行为。 - anon
@Neil:第一个提到实际使用而不触发未定义行为的人+1。我猜他们本可以像其他类型转换操作(reinterpret_cast等)一样将其定义为实现定义。但就像我问的那样,你用它进行类型转换了吗? - legends2k
@Neil - 内存映射寄存器示例并非未定义,通常的字节序等因素除外,并给定“volatile”标志。在这种模型中写入地址不会引用与读取相同地址的相同寄存器。因此,没有“你正在读回什么”的问题,因为你没有读回 - 无论你写入该地址的任何输出,在读取时,你只是读取独立的输入。唯一的问题是确保你读取联合的输入端并写入输出端。在嵌入式设备中很常见 - 可能仍然如此。 - user180247
@legends2k 我不使用它,因为我给出的原因在C++中它并不真正起作用,而且我认为使用任何类型的变量通常都是不好的设计。 - anon

8
在C语言中,实现类似于变量的东西是一种不错的方式。
enum possibleTypes{
  eInt,
  eDouble,
  eChar
}


struct Value{

    union Value {
      int iVal_;
      double dval;
      char cVal;
    } value_;
    possibleTypes discriminator_;
} 

switch(val.discriminator_)
{
  case eInt: val.value_.iVal_; break;

在内存较小的情况下,这种结构所使用的内存比具有所有成员的结构少。
顺便说一下,C语言提供了。
    typedef struct {
      unsigned int mantissa_low:32;      //mantissa
      unsigned int mantissa_high:20;
      unsigned int exponent:11;         //exponent
      unsigned int sign:1;
    } realVal;

访问位值。


虽然你提供的这两个例子在标准中都被完美定义了,但是使用位域(bit fields)肯定会导致代码无法移植,不是吗? - legends2k
1
不,它不是。据我所知,它得到了广泛的支持。 - Totonga
1
编译器支持并不等同于可移植性。《C语言经典教程》(http://publications.gbdirect.co.uk/c_book/chapter6/bitfields.html)指出:“C语言(包括C++)不能保证机器字内字段的顺序,因此如果您使用它们作为后者的原因,那么您的程序不仅不具备可移植性,而且还依赖于编译器。” - legends2k

5

虽然这是严格未定义的行为,但实际上它可在几乎任何编译器中运行。它是一个广泛使用的范例,任何自尊的编译器都需要在此类情况下做出“正确的事情”。当然,这比类型转换更可取,后者可能会在某些编译器中生成错误代码。


2
难道没有字节序问题吗?相对于“未定义”的情况,这是一个相对容易的修复,但如果有必要,一些项目应该考虑这个问题。 - user180247

5
在C++中,Boost Variant 实现了一个安全版本的联合体,旨在尽可能地避免未定义的行为。
它的性能与enum + union结构相同(也是堆栈分配等),但它使用类型的模板列表而不是enum :)

5
行为可能是未定义的,但这只意味着没有“标准”。所有好的编译器都提供 #pragmas 来控制打包和对齐,但可能有不同的默认值。默认值也会根据所使用的优化设置而改变。
此外,联合体并不仅仅是为了节省空间。它们可以帮助现代编译器进行类型游戏。如果你 reinterpret_cast<> 所有内容,编译器就无法做出假设。它可能不得不放弃对你的类型的了解并重新开始(强制写回内存,在当前与 CPU 时钟速度相比非常低效)。

4

从技术上讲,它是未定义的,但实际上大多数(全部?)编译器将其视为使用 reinterpret_cast 将一种类型转换为另一种类型时完全相同,其结果是由实现定义的。您不必为当前的代码担心。


“从一种类型到另一种类型的reinterpret_cast,其结果是实现定义的。”不,它并不是。实现不必定义它,大多数实现都不定义它。此外,将某些随机值强制转换为指针的允许实现定义行为是什么? - curiousguy

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