TL;DR:三种不同类型的未定义行为:生命周期问题,访问联合的非活动成员(没有非标准扩展)和通过成员取消引用无效指针值example.ex
(对声明的联合代表的误解)。
看起来你可以使用简单的引用。最后描述了完整的解决方案。
更深入的分析
这实际上是一个非常有趣的问题,因为这里发生了很多事情!三种不同类型的未定义行为。让我们逐个回顾这些。
首先,就像评论中提到的那样,您将参数的地址分配values
给x
,y
和z
(成员的地址)。该参数values
具有自动存储持续时间,这意味着它在构造函数的末尾被破坏ConfigCustomDataTypeExample
。
struct ConfigCustomDataTypeExample {
public:
ConfigCustomDataTypeExample() = default;
ConfigCustomDataTypeExample(CustomDataTypeExample values) {
x = &values.x;
y = &values.y;
z = &values.z;
} // Pass this line x, y and z store invalid pointer values
// (addresses to now destructed members of values).
// Any indirection through these pointers is undefined behavior.
...
使用您的程序,您仍然能够读取 和 的y
值z
。这是未定义行为的本质:有时您可能会得到合理的结果,但没有任何保证。例如,当我尝试运行您的程序时,我得到的结果y
和z
. 这是第一个明确的 UB。让我们检查下联合的声明,以了解它真正代表什么。
类是由一系列成员组成的类型。Union 是一种特殊类型的类,一次最多可以保存一个非静态数据成员。联合当前持有的对象称为活动成员。这意味着联合仅与其最大的数据成员一样大,如果内存使用是一个问题,这很有用。
union {
struct {
CustomDataTypeExample* ex;
};
struct {
float* x;
float* y;
float* z;
};
};
对于这个联合,成员是两个匿名结构(请注意,C++ 标准禁止匿名结构)。联合体的大小由最大的结构体决定,也就是float*
结构体。对于 64 位系统,指针类型的大小通常为 8 字节,因此对于 64 位系统,此联合的大小为 24 字节。
关于联合的使用,您显然不是为了减少内存消耗而使用联合。相反,您正在尝试做一些称为type punning的事情。类型双关语是当您尝试将一种类型的二进制表示解释为另一种类型时。根据 C++ 标准类型与联合的双关语是未定义的行为(第二个),尽管许多编译器提供了允许这样做的非标准扩展。main
让我们根据标准规则分析您的程序:
ConfigCustomDataTypeExample example({1.2f, 3.4f, 5.6f});
// The anonymous struct holding 3 float* is now the active member.
// Though, all of the pointers are invalid, as already mentioned.
float value = 565;
example.x = &value;
// example.x is now a valid ptr value
std::cout
<< example.ex->x << ", " // UB: Accessing a non-active member
<< example.ex->y << ", " // UB: non-active and invalid ptr (more on that later)
<< example.ex->z << "\n"; // UB: same as above
std::cout
<< *example.x << ", " // This is ok (active member and valid ptr)
<< *example.y << ", " // UB: indirection to an invalid ptr
<< *example.z << "\n"; // UB: same as above
再一次,未定义的行为足以565
在取消引用时打印example.ex->x
。这是因为联合的二进制表示中的float* x
和example.ex->x
重叠,尽管这仍然是未定义的行为。
ConfigCustomDataTypeExample
让我们首先通过更改将引用作为参数来快速解决生命周期问题:ConfigCustomDataTypeExample(CustomDataTypeExample& values)
并在 main.xml 中声明一个CustomDataTypeExample
变量。我也在使用 gcc 进行编译,其中使用联合的类型双关语定义明确(非标准扩展):
CustomDataTypeExample data{1.0f, 2.0f, 3.0f};
ConfigCustomDataTypeExample example(data);
float value = 565;
example.x = &value;
std::cout
<< example.ex->x << ", " // This is now ok (using gcc's non-standard extension)
<< example.ex->y << ", " // Something seems odd
<< example.ex->z << "\n"; // with these two lines
std::cout
<< *example.x << ", " // Now well defined
<< *example.y << ", " // same
<< *example.z << "\n"; // same
这里什么都没有。我的一次运行的输出是:
565, 1961.14, 4.59163e-41
565, 2, 3
好的,至少现在x
,y
和z
值是有效的,但是当取消引用example.ex
. 是什么赋予了?让我们回到我们的联合声明,想想它是如何转换成它的二进制表示的。这是一个粗略的图表:
[float* x, float* y, float* z]
所以我们联合的内存布局是三个浮点指针,每个指针指向一个浮点值(相当于一个存储三个浮点指针的数组,例如float* arr[3]
)。然而,example.ex
我们试图将 解释float* x
为 3 个浮点数组。这是因为CustomDataTypeExample
的内存布局相当于一个包含 3 个浮点值的数组,并且试图引用它的成员相当于数组索引。
我认为 gcc 的扩展基于example->ex
C90 标准第 6.5.2.2 节脚注 82 的解释:
如果用于访问联合对象内容的成员与上次用于在对象中存储值的成员不同,则该值的对象表示的适当部分被重新解释为新类型中的对象表示,如 6.2 中所述.6(有时称为“类型双关语”的过程)。这可能是一个陷阱表示。
我们还可以通过查看编译器如何将这三行转换为汇编来验证这一点:
example.x = &value;
std::cout
<< example.ex->x << ", "
<< example.ex->y << ", "
<< example.ex->z << "\n";
使用Godbolt,我们得到以下信息(我只取了相关的部分):
// Copies the value of rax to the memory pointed by QWORD PTR [rbp-48]
mov QWORD PTR [rbp-48], rax // example.x = &value;
// Copy a 32-bit value from memory address rax to eax.
// (eax register is used here to pass the value to std::cout)
// No surprises yet, as this address has a well defined floating point value (526).
mov eax, DWORD PTR [rax] // example.ex->x
// Not good, tries to copy a floating point value from memory address
// [rax + 4 bytes]. Equivalent to *(&value + 1). This is gonna get
// whatever random junk is in that part of memory.
mov eax, DWORD PTR [rax+4] // example.ex->y
我们可以很清楚地看到编译器如何尝试将指向的地址解释example.ex
为内存中包含 3 个浮点值的区域,即使它只包含一个. 因此,第一次读取没问题,但第二次和第三次取消引用就大错特错了。
这段代码产生了极其相似的程序集,这并不奇怪,因为行为是等价的:
float* value_ptr = &value;
std::cout
<< *value_ptr << ", " // equivalent to example.ex->x, OK
<< value_ptr[1] << ", " // equivalent to example.ex->y, plain UB
<< value_ptr[2] << '\n'; // equivalent to example.ex->z, plain UB
这是未定义行为的情况与第一种情况非常相似。该程序正在通过无效的指针值(第三个)执行间接寻址。
这三个未定义的行为结合在一起导致在执行main
. 现在上解决方案。
解决方案
首先,让我们摆脱一些小问题。CustomDataTypeExample
显然是一个仅将数据包含在其中的聚合,因此无需为它显式声明特殊的成员函数(在这种情况下为构造函数)。特殊的成员函数被隐式声明(并且很简单):
struct CustomDataTypeExample {
float x;
float y;
float z;
};
// Construct an instance of CustomDataTypeExample by aggregate initializing.
// This was also utilized earlier.
CustomDataTypeExample data{1.0f, 2.0f, 3.0f};
解决方案是什么,看起来您正试图为一个简单的问题提出一个额外的抽象层。简单的引用应该可以解决问题。没有理由进行复杂的联合设置,正如您可能已经注意到的那样,它很容易出错。在 C++ 中,联合只能真正用于减少内存是稀缺资源的系统上的内存消耗。
因此,我将摆脱ConfigCustomDataTypeExample
并使用如下引用:
CustomDataTypeExample data{1.0f, 2.0f, 3.0f};
CustomDataTypeExample& data_ref = data;
// Modifies the contents of the existing data
data_ref.x = 565;
std::cout
<< data_ref.x << ", "
<< data_ref.y << ", "
<< data_ref.z << '\n';
当您使用具有自动存储持续时间的变量时,参考是要走的路。与指针相比,使用引用的生命周期问题更难创建,并且整体解决方案通常更简单。