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个回答

4

其他人已经提到了架构差异(小端-大端)。

我读到的问题是,由于变量的内存是共享的,因此通过写入一个变量,其他变量也会发生改变,并且根据它们的类型,值可能是无意义的。

例如。 union{ float f; int i; } x;

如果您从x.f读取数据,则对x.i进行写入将是无意义的 - 除非这正是您打算查看浮点数的符号、指数或尾数组件的方式。

我认为还存在对齐问题:如果某些变量必须按字对齐,则可能无法获得预期的结果。

例如。 union{ char c[4]; int i; } x;

如果在某台机器上char必须按字对齐,则c [0]和c [1]将与i共享存储空间,但c [2]和c [3]则不会。


必须对齐的字节?这没有任何意义。按定义,字节没有对齐要求。 - curiousguy
是的,我可能应该使用一个更好的例子。谢谢。 - philcolbourn
@curiousguy:有许多情况下,人们希望将字节数组对齐到字边界。如果有许多大小为1024字节的数组,并且经常需要将一个数组复制到另一个数组中,则在许多系统上,将它们对齐到字边界可能会使memcpy()从一个数组复制到另一个数组的速度翻倍。一些系统可能会为了这个原因和其他原因推测性地对齐在结构/联合体之外发生的char[]分配。在现有的示例中,假设i将与c[]的所有元素重叠是不可移植的,但这是因为没有保证sizeof(int)==4 - supercat

4
作为联合使用的另一个实际示例,CORBA框架使用带标记的联合方法对对象进行序列化。所有用户定义的类都是一个(巨大的)联合的成员,并且整数标识符告诉解组器如何解释该联合。

4
在1974年记录的C语言中,所有结构体成员共享一个命名空间,“ptr->member”的含义是将成员的位移添加到“ptr”上,并使用成员的类型访问结果地址。这种设计使得可以使用相同的ptr和来自不同结构定义但具有相同偏移量的成员名称;程序员利用了这种能力来实现各种目的。
当结构体成员被赋予自己的命名空间时,声明两个具有相同位移的结构体成员变得不可能。将联合体添加到语言中使得可以实现早期版本语言中可用的相同语义(尽管无法将名称导出到封闭上下文可能仍需要使用查找/替换将foo->member替换为foo->type1.member)。重要的不是添加联合体的人们有任何特定的目标用途,而是他们提供了一种方式,使得那些依赖早期语义的程序员,无论出于何种目的,仍然能够实现相同的语义,即使他们必须使用不同的语法来实现它。

感谢这段历史课,然而由于标准将某些东西定义为未定义,而在过去的C时代,K&R书是唯一的“标准”,因此人们必须确保不使用它_无论出于什么目的_并进入UB领域。 - legends2k
2
@legends2k:当标准被编写时,大多数C实现都以相同的方式处理联合,这种处理方式非常有用。然而,少数实现并没有这样做,标准的作者们不愿意将任何现有的实现标记为“不符合标准”。相反,他们认为,如果实现者不需要标准告诉他们要做什么(正如他们已经在做的那样),将其未指定或未定义只会保留现状。这种想法应该使事情比标准编写之前更加不确定... - supercat
2
这似乎是一个更近期的创新。尤其令人遗憾的是,如果针对高端应用程序的编译器编写者能够想出如何向上世纪90年代实现的大多数编译器添加有用的优化指令,而不是削弱被“仅”90%的实现支持的功能和保证,那么结果将是一种比超现代C语言表现更好、更可靠的语言。 - supercat

3
正如其他人提到的那样,将联合体与枚举结合并包装到结构中可以用于实现带标记的联合。一个实际的用途是实现 Rust 的 Result<T, E>,它最初是使用纯 enum 实现的(Rust 可以在枚举变量中保存额外数据)。这里是一个 C++ 示例:
template <typename T, typename E> struct Result {
    public:
    enum class Success : uint8_t { Ok, Err };
    Result(T val) {
        m_success = Success::Ok;
        m_value.ok = val;
    }
    Result(E val) {
        m_success = Success::Err;
        m_value.err = val;
    }
    inline bool operator==(const Result& other) {
        return other.m_success == this->m_success;
    }
    inline bool operator!=(const Result& other) {
        return other.m_success != this->m_success;
    }
    inline T expect(const char* errorMsg) {
        if (m_success == Success::Err) throw errorMsg;
        else return m_value.ok;
    }
    inline bool is_ok() {
        return m_success == Success::Ok;
    }
    inline bool is_err() {
        return m_success == Success::Err;
    }
    inline const T* ok() {
        if (is_ok()) return m_value.ok;
        else return nullptr;
    }
    inline const T* err() {
        if (is_err()) return m_value.err;
        else return nullptr;
    }

    // Other methods from https://doc.rust-lang.org/std/result/enum.Result.html

    private:
    Success m_success;
    union _val_t { T ok; E err; } m_value;
}

1

你可以使用联合体来达到两个主要目的:

  1. 方便以不同的方式访问相同的数据,就像你的例子一样
  2. 当有不同的数据成员,其中只有一个可以“活动”时,可以节省空间

1 更多地是 C 风格的 hack,用于快速编写代码,因为你知道目标系统的内存体系结构如何工作。如已经提到的,如果你不实际针对很多不同的平台,则通常可以不使用。我相信一些编译器也可能允许你使用打包指令(我知道它们在结构体上这样做)?

一个很好的 2 的例子可以在 COM 中广泛使用的 VARIANT 类型中找到。


1

@bobobobo的代码是正确的,正如@Joshua所指出的(可悲的是我不允许添加评论,所以在这里进行,我认为不允许首先禁止它是一个糟糕的决定):

https://en.cppreference.com/w/cpp/language/data_members#Standard_layout表明自C++14以来这样做是可以的。

在标准布局联合中,如果有非联合类类型T1的活动成员,则允许读取另一个非联合类类型T2的联合成员的非静态数据成员m,前提是m是T1和T2的公共初始序列的一部分(除了通过非易失性glvalue读取易失性成员是未定义的)。

因为在当前情况下,T1和T2无论如何都表示相同的类型。


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