将memcpy / memmove复制到联合成员,这会设置“活动”成员吗?

27

重要澄清:一些评论者似乎认为我在从一个联合体中复制。请注意 memcpy,它是从一个普通的 uint32_t 的地址复制的,而不是从联合体中包含的变量。另外,我是通过 memcpy 复制到联合体的一个特定成员 (u.a16&u.x_in_a_union),而不是直接复制整个联合体本身 (&u)。

C++ 在联合体方面非常严格 - 只有在最后写入的成员是当前活动成员时,才应从该成员读取:

9.5 联合体 [class.union] [[c++11]] 在联合体中,最多只能有一个非静态数据成员处于活动状态,也就是说,最多只能将一个非静态数据成员的值存储在联合体中。

(当然,编译器不会跟踪哪个成员是活动的。开发人员需要自己确保跟踪)


更新:以下代码块是主要问题,直接反映了问题标题中的文本。如果这段代码没问题,我还有一个关于其他类型的后续问题,但我现在意识到这个第一个代码块本身就很有趣。

#include <cstdint>
uint32_t x = 0x12345678;
union {
    double whatever;
    uint32_t x_in_a_union; // same type as x
} u;
u.whatever = 3.14;
u.x_in_a_union = x; // surely this is OK, despite involving the inactive member?
std::cout << u.x_in_a_union;
u.whatever = 3.14; // make the double 'active' again
memcpy(&u.x_in_a_union, &x); // same types, so should be OK?
std::cout << u.x_in_a_union; // OK here? What's the active member?

很可能在评论和答案中,紧接着上面这个代码块的部分是主要问题。事后看来,在这个问题中我不需要混合类型!基本上,假设类型相同,u.a = bmemcpy(&u.a,&b, sizeof(b)) 是一样的吗?


首先,一个相对简单的memcpy函数让我们可以将一个uint32_t按照uint16_t数组读取:

#include <cstdint> # to ensure we have standard versions of these two types
uint32_t x = 0x12345678;
uint16_t a16[2];
static_assert(sizeof(x) == sizeof(a16), "");
std:: memcpy(a16, &x, sizeof(x));

具体行为取决于您平台的字节序,并且您必须小心陷阱表示等问题。但是通常认为(我认为?欢迎反馈!),在正确的上下文和平台上避免有问题的值,上述代码可以完全符合标准。

(如果您对上面的代码有任何问题,请在评论或编辑问题时指出。 在继续下面的“有趣”代码之前,我想确保我们有一个非争议性的版本。)


只有当上述两个代码块都不是未定义行为时,我才想要将它们组合如下:

uint32_t x = 0x12345678;
union {
    double whatever;
    uint16_t a16[2];
} u;
u.whatever = 3.14; // sets the 'active' member
static_assert(sizeof(u.a16) == sizeof(x)); //any other checks I should do?
std:: memcpy(u.a16, &x, sizeof(x));

// what is the 'active member' of u now, after the memcpy?
cout << u.a16[0] << ' ' << u.a16[1] << endl; // i.e. is this OK?

在这个联合体中,u.whateveru.a16中哪一个是“活动成员”?


最后,我自己的猜测是,我们关心这个问题的原因在于优化编译器可能无法注意到memcpy发生的事实,从而对哪个成员是活动成员以及哪些数据类型是“活动”的做出错误的假设(但标准允许),因此导致关于别名的错误。编译器可能会以奇怪的方式重新排序memcpy这是否是我们关心这个问题的适当总结?


2
由你来跟踪“活动”的成员是谁,编译器不会为你做这件事。 - Jonathan Potter
2
"language-lawyer" "rules" 是什么,@Lorehead? - Aaron McDaid
2
到目前为止,有很多天真的评论和答案。当然,天真的答案是“当然联合没有改变:它是相同的,不是吗”。这个好问题令人惊讶地深刻。我并不完全相信你甚至可以使用memcpy复制一个联合,因为可能会读取未初始化的内存。请参见https://dev59.com/O1wX5IYBdhLWcg3wyB_T,尽管这是在C标签上。 - Bathsheba
2
我认为由于标准对这种行为的规定非常模糊,所以没有“正确”的答案。每个编译器可能都允许它,并且结果可能相同,但这并不符合“标准兼容性”。 - Hatted Rooster
1
联合语义在P0137R1之前并不十分明确。有了这个,很清楚地知道改变活动成员需要放置新的或使用=(在某些情况下)。 memcpy不能胜任。另一方面,可以说它重用了存储并结束了double的生命周期,在这种情况下,您将拥有一个没有活动成员的联合体。 - T.C.
显示剩余46条评论
4个回答

7
我的理解是,只要类型是“平凡可复制的(trivially copyable)”,就可以安全地使用std::memcpy函数。从9类中可以看出,联合体union是类类型,因此“平凡可复制的”也适用于它们。
“联合体”是使用“class-key union”定义的类,它一次只能保存一个数据成员(9.5)。“平凡可复制的类”是指:
  • 没有非平凡的复制构造函数(12.8),
  • 没有非平凡的移动构造函数(12.8),
  • 没有非平凡的复制赋值运算符(13.5.3, 12.8),
  • 没有非平凡的移动赋值运算符(13.5.3, 12.8),
  • 有一个平凡的析构函数(12.4)。
“平凡可复制的”确切含义在3.9类型中给出:
对于任何“平凡可复制的”类型T的对象(除了基类子对象),无论该对象是否保存了T类型的有效值,组成该对象的底层字节(1.7)都可以复制到charunsigned char数组中。如果将charunsigned char数组的内容复制回对象中,则该对象随后将保存其原始值。
对于任何“平凡可复制的”类型T,如果两个指向不同T对象obj1obj2的指针,其中obj1obj2都不是基类子对象,如果组成obj1的底层字节(1.7)被复制到obj2中,则obj2随后将保存与obj1相同的值。
标准还明确给出了一个例子。
因此,如果要复制整个联合体,答案毫无疑问是肯定的,活动成员也将随数据一起“复制”。(这很重要,因为它表明std::memcpy必须被视为改变联合体活动元素的有效手段,因为整个联合体复制时明确允许使用它。)
现在,您要复制到联合体的一个“成员”。标准似乎不需要任何特定的方法来分配给联合体成员(从而使其活动)。它只是指定(9.5),

[注意:一般情况下,必须使用显式析构类和放置 new 运算符来更改联合的活动成员。 ——结束语]

当然,这是因为 C++11 允许在联合中使用非平凡类型的对象。请注意开头的“一般情况下”,它明确表明在特定情况下允许使用其他更改活动成员的方法;我们已经知道赋值是被允许的。当然,在合法的情况下,使用 std::memcpy 是没有禁止的。

所以我的答案是:是的,这是安全的,并且它会改变活动成员。


1
当输入不同大小的类型时,如果实现了 union 并在较小的类型激活时将额外的空间用于魔术标记,然后在运行时进行检查以确保其有效性,那么这种实现方式是否合法?(请注意,所有的赋值和放置 new 也将执行此任务...) - Yakk - Adam Nevraumont
1
@Lorehead 1) 单独的成员访问表达式本身并不涉及“访问”的含义,即[defns.access]。2) [class.union]/5仅适用于“当赋值运算符的左操作数涉及指定联合成员的成员访问表达式([expr.ref])”时。*p不是成员访问表达式。 - T.C.
1
@Lorehead 我不知道别人怎么看,但我认为这些事情的处理方式是:在实践中,你需要知道标准规定和你的实现允许什么。知道某个结构是非标准的可以让你做出明智的决策,是否使用它,并防止你做出危险的推断,比如“如果A可行,那么B肯定也可行”;如果A是非标准的,实现可能会选择允许它,但同时严格遵循B的标准。更重要的是,你不太可能找到一个不允许... - bogdan
1
...让你执行T* p = (T*)malloc(...);,其中T是一个平凡类型,然后将*p视为指定类型为T的对象的lvalue;周围有太多这样的代码了。但是,将数据复制到非活动联合成员中,或将指向这样的成员的指针传递给函数并期望对*p的赋值将切换活动成员...在某些严格的设置下可能会产生意外情况,也许现在,也许将来。 - bogdan
1
@Lorehead 对于你上一个问题:,那是未定义的行为,正如我们之前讨论过的那样。请阅读[class.union]/5的第一句话。 - bogdan
显示剩余30条评论

2
在联合中,最多只能有一个成员处于活动状态,在其生命周期内它是活动的。
在C++14标准(§9.3或草案中的9.5)中,所有非静态联合成员都被分配为如果它们是struct的唯一成员,并且共享相同的地址。这不会开始生命周期,但是非平凡的默认构造函数(只有一个联合成员可以拥有)会开始生命周期。有一个特殊规则,即分配给联合成员会使其活动,即使您通常不能对尚未开始生命周期的对象进行此操作。如果联合是平凡的,则它及其成员没有需要担心的非平凡析构函数。否则,您需要担心活动成员的生命周期何时结束。根据标准(§3.8.5):

程序可以通过重用对象占用的存储空间或显式调用具有非平凡析构函数的类类型对象的析构函数来结束任何对象的生命周期。[...如果没有显式调用析构函数或者没有使用delete-expression释放存储空间,则析构函数不应被隐式调用,依赖析构函数产生的副作用的任何程序均具有未定义的行为。

一般来说,显式调用当前活动成员的析构函数并使用放置new使另一个成员处于活动状态更加安全。标准给出了以下示例:
u.m.~M();
new (&u.n) N;

你可以使用std::is_trivially_destructible在编译时检查第一行是否必需。按照标准的严格解释,只有通过初始化联合、对其进行赋值或放置new才能开始联合成员的生命周期,但是一旦你这样做了,你就可以安全地使用memcpy()将一个平凡可复制对象复制到另一个对象上。(§ 3.9.3, 3.8.8)
对于平凡可复制类型,值表示是对象表示中的一组位,用于确定该值,而T的对象解释是sizeof(T)unsigned char对象的序列。 memcpy()函数复制此对象表示。所有非静态联合成员具有相同的地址,并且您可以在对象的生命周期开始之前分配并使用该地址作为void*存储,因此当成员处于非活动状态时,可以将其传递给memcpy()。如果联合是标准布局联合,则联合本身的地址与其第一个非静态成员的地址相同,因此所有非静态成员的地址也相同。(如果不是,则可以与static_cast互换。)
如果类型has_unique_object_representations,则它是平凡可复制的,并且没有两个不同的值共享相同的对象表示;也就是说,没有位填充。
如果类型is_pod(Plain Old Data),那么它是平凡可复制的,并且具有标准布局,因此其地址也与其第一个非静态成员的地址相同。
在C中,我们保证可以读取兼容类型的非活动联合成员,直到最后一个被写入。在C++中,我们没有这样的保证。有一些特殊情况下可以工作,例如包含相同类型对象地址的指针、相同宽度的带符号和无符号整数类型以及布局兼容结构。但是,在你的示例中使用的类型有一些额外的保证:如果它们存在,uint16_tuint32_t具有精确的宽度和没有填充,每个对象表示都是唯一的值,并且所有数组元素在内存中是连续的,因此uint32_t的任何对象表示也是uint16_t[2]的某些有效对象表示,尽管这种对象表示在技术上是未定义的。你得到的结果取决于字节顺序。(如果您真正想安全地拆分32位,则可以使用位移和位掩码。)
概括来说,如果源对象是Pod类型,则可以通过其对象表示严格进行复制,并将其放在新地址的兼容布局对象上。如果目标对象大小相同并且具有唯一的对象表示,则它也是可平凡复制的,并且不会丢弃任何位数——但是可能存在陷阱表示。 如果您的联合不是平凡的,则需要删除活动成员(非平凡联合的一个成员只能具有非平凡默认构造函数,并且默认情况下处于活动状态),并使用放置new使目标成员活动。
在C或C++中复制数组时,您总是希望检查缓冲区溢出。在这种情况下,您采用了我的建议并使用了static_assert()。这没有运行时开销。您还可以使用memcpy_s():如果源和目标都是Pod类型(具有标准布局的可平凡复制类型),并且联合具有标准布局,则memcpy_s(&u,sizeof(u),&u32,sizeof(u32)) 会起作用。它永远不会溢出或下溢联合。它将用零填充联合的任何剩余字节,这可以使您担心的许多错误变得可见且可重现。

1
@Yakk 是的,根据 [basic.life]/7.1,因为它正在访问对象生命周期外的值。 - bogdan
3
“@AaronMcDaid u.u32_in_a_union = 3;”是有明确定义的,因为左侧使用了一个构造,该构造在[class.union]/5中指定为开始联合成员的生命周期(如果它还没有被激活)。只有一组非常特定的表达式可以做到这一点;与memcpy使用的那些表达式不属于该组 - 请参见我对Yakk答案的评论。 - bogdan
1
@xaxxon http://eel.is/c++draft/basic.types#3 但是[basic.life]似乎表明,即使是可以平凡构造的联合成员,在初始化时也不会开始它们的生命周期。如果您想绝对安全,可以使用[class.union]中的任一方法显式地使要复制的成员首先处于活动状态,然后使用memcpy()覆盖活动成员。 - Davislor
1
@AaronMcDaid 这意味着我不得不反对Lorehead上面说的一些话:并非所有访问尚未开始生命周期的对象都是UB。实际上,据我所知,任何访问超出其生命周期的对象都是UB。请注意,获取对象的地址不是访问。有趣的是,即使它在名称中包含“access”(是的,我知道;不要打信使:-)),类成员访问表达式本身也不是一个访问。 - bogdan
2
让我们在聊天中继续这个讨论 - bogdan
显示剩余57条评论

2

[class.union]/5:

在联合体中,如果非静态数据成员的名称引用了一个生命周期已经开始且尚未结束的对象,则该成员处于活动状态(参见[basic.life])。在任何时候,联合类型对象的非静态数据成员最多只有一个处于活动状态,即最多只能将一个非静态数据成员的值存储在联合中。

联合体中最多只能有一个成员处于活动状态。

活动成员是指其生命周期已经开始但尚未结束的成员。

因此,如果您结束了联合体成员的生命周期,则该成员不再处于活动状态。

如果没有活动成员,使联合体的另一个成员的生命周期开始在标准下是明确定义的,并导致该成员变为活动成员。

联合体已分配足够所有成员所需的存储空间。它们都像单独存在一样被分配,并且它们可以相互转换为指针。 [class.union]/2

[basic.life]/6

在对象的生命周期开始之前但该对象将占用的存储空间已分配之后40,或在对象的生命周期结束之后但该对象占用的存储空间被重用或释放之前,可以使用代表对象所在存储位置的指针,但仅限于特定方式。有关正在构建或销毁的对象,请参见 [class.cdtor]。否则,这样的指针将引用已分配的存储空间([basic.stc.dynamic.deallocation]),并且如同指针是void*类型一样使用,则是明确定义的。

因此,您可以获取联合体成员的指针,并将其视为已分配存储空间的指针。如果这样的构造是合法的,则可以在那里构造一个对象。

在那里进行placement new是构造对象的有效方法。对于平凡可复制类型(包括POD类型),使用memcpy构造对象也是有效的。

但是,只有在不违反联合体只能有一个活动成员的规则的情况下,才能在那里构造对象。

如果根据某些条件[class.union]/6给联合体的成员赋值,则首先结束当前活动成员的生命周期,然后开始被赋值成员的生命周期。因此,即使联合体中有另一个成员处于活动状态(并使u32_in_a_union处于活动状态),您的u.u32_in_a_union = 0xaaaabbbb;是合法的。

这在placement new或者memcpy中不适用,联合体规范中没有明确的“活动成员生命周期结束”的定义。我们必须查看其他地方:

[basic.life]/5

一个程序可以通过重用对象占用的存储空间或显式调用具有非平凡析构函数的类类型对象的析构函数来结束任何对象的生命周期。

问题是,通过开始联合的另一个成员的生命周期,“重用存储”是否会结束其他联合成员的生命周期?在实践中,显然是这样(它们是指针可互换的,它们共享相同的地址等)。[class.union]/2

所以我认为是的。

因此,通过void*指针(放置新的或者如果类型允许,则使用memcpy)创建另一个对象会结束union的替代成员(如果有)的生命周期(不调用其析构函数,但通常可以接受),并立即使指向的对象处于活动状态和存在状态。

使用memcpy在存储上复制doubleint16_t数组或类似对象的生命周期是合法的。

将两个uint16_t数组复制到uint32_t或反之亦然的合法性由其他人来辩论。显然,这在C++17中是合法的。但是,该对象是联合体与合法性无关。


本答案基于与@Lorehead在其答案下的讨论。我认为应直接解决问题的核心。


3
我不同意“对于可平凡复制类型(包括POD类型),使用memcpy构造对象是一种有效的方法”的说法。虽然memcpy接受void*指针,但这些指针需要指向对象 - 请参阅N1570 (C11草案)中的7.24.2.1:memcpy函数将从s2指向的对象复制n个字符到s1指向的对象。 7.24.1/3也指定它通过unsigned char类型的lvalue访问这些对象。它改变了目标对象的值,但没有启动任何对象的生命周期。 - bogdan
2
我找不到C ++标准中任何一段内容表明memcpy开始任何对象的生命周期; 只能使用它来访问其生命周期内现有对象的值(否则将违反[basic.life]/7.1)。 [intro.object]/1清楚地列出了启动对象生命周期的详尽列表; memcpy不在其中。 - bogdan
2
[class.union]/5 定义了可以通过赋值来启动联合成员生命周期的构造;但是,通过指向 unsigned char 的指针间接引用获得的 lvalue 进行赋值(这是 memcpy 指定要执行的操作)不在其中。 - bogdan
2
@aaron,对象的生命周期从int j;开始:它的状态是未指定的。更有趣的是std::aligned_storage_t<sizeof(int), alignof(int)> b; memcpy(&b, i, sizeof(i));--在*(int*)&b中是否有一个int?看起来答案可能是“没有”,因为开始生命周期的唯一方法似乎是声明该类型的非联合变量、新建(放置或不放置)或对pod-like联合成员字段进行某些操作(如赋值)。哦,而且可以争论的是,对整个联合体进行memcpy可能会在目标中设置活动对象。对联合体成员进行memcpy则不属于这些情况之一。 - Yakk - Adam Nevraumont
2
关于通过将整个联合对象memcpy到另一个相同类型的对象中来设置活动成员的问题是一个非常好的问题。我倾向于“不行”(别恨我:-))。如果答案是“是”,那么memcpy将能够创建新对象,因为更改活动成员涉及创建一个新的子对象-据我所知,目前没有其他方法可以启动对象的生命周期而不创建一个对象。我会说结果类似于将“int”复制到“float”中:float可能最终包含int的对象表示,但这不会创建... - bogdan
显示剩余13条评论

0
房间里的大象:在完全严格的C++中不支持联合,这是当你尝试应用所有标准条款来形式化C++直觉的失败尝试所得到的“语言”。
这是因为:
  • 左值引用一个对象,
  • 成员访问(x.m)对于任何类或联合都是正常的左值,
  • 所有活动类或联合的成员可以随时通过成员访问指定,
  • 根据严格的生命周期规则,在联合中只能有一个成员对象处于活动状态,
  • 标准中未定义左值引用即将创建的对象的概念。
因此,像下面这样简单使用联合:
union {
  char c;
  int i;
} u;

u.i = 1;

u.i的求值结果不能引用任何int对象,因为在求值时没有这样的对象。

C++委员会未能完成其使命。

事实上,没有人完全使用严格的C++来进行任何目的,人们需要放弃标准的整个部分或创造整个想象出的条款,受到书面文本的启发,或者从文本返回他们想象的意图,然后重新形式化意图,以理解它

不同的人放弃不同的部分,并最终得到完全不同的形式主义。

我的建议是放弃生命周期规则,并在可能容纳这种对象的任何地址上拥有一个对象。这解决了整个问题,没有人提出过有效的反对意见(模糊的断言“这会破坏所有不变量”不是有效的反对意见)。在任何有效地址处都有一个对象只会创建无限数量的潜在对象(特别是所有指针类型,int*int**int*** ...),但这些对象不能用于读取,因为没有写入有效值。

请注意,如果不放宽生命周期规则或lvalue的定义,你甚至无法拥有一个非平凡的“严格别名规则”,因为在没有这些规则的情况下,该规则将不适用于一个明确定义的程序。目前解释的“严格别名规则”是无用的(而且写得如此糟糕以至于没人知道它的意思)。

或者也许有人会告诉我,要理解严格别名规则,lvalue of int指的是一个对象,只是类型不同。这个解释即使能够一致地解释标准,也仍然显得令人惊讶和愚蠢,我会说这是有缺陷的。


我认为更公平的说法是,标准的作者们并不认为有必要禁止每一种低质量实现可能破坏应该可预测工作的代码的愚蠢行为。我认为单个“活动成员”的概念是错误的,需要的是更接近读写锁的东西。引用整个联合体(包括作为成员访问的左值)是唯一释放锁的操作。通过任何方式读取或写入成员都需要获取“读者”或“写者”锁。冲突的锁获取会导致未定义行为。 - supercat

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