引用未初始化的内存。未定义的行为?

7

请允许我先说明,出于显而易见的原因,我不推荐以下任何做法。然而,今天我进行了一次关于此事的讨论,一些人坚称使用像这样的参考作为未定义行为。

这是一个测试案例:

#include <string>

struct my_object {
   int a          = 1;
   int b          = 2;
   std::string hi = "hello";
};

// Using union purely to reserve uninitialized memory for a class.
union my_object_storage {
   char dummy;
   my_object memory;
   // C++ will yell at you for doing this without some constructors.
   my_object_storage() {}
   ~my_object_storage() {}
} my_object_storage_instance;

// This is so we can easily access the storage memory through "I"
constexpr my_object &I = my_object_storage_instance.memory;

//-------------------------------------------------------------
int main() {
   // Initialize the object.
   new (&I) my_object();
   // Use the reference.
   I.a = 1;
   // Destroy the object (typically this should be done using RAII).
   I.~my_object();

   // Phase two, REINITIALIZE an object with the SAME reference.
   // We still have the memory allocated which is static, so why not?
   new (&I) my_object();
   // Use the reference.
   I.a = 1;  
   // Destroy the object again. 
   I.~my_object();
}

https://wandbox.org/permlink/YEp9aQUcWdA9YiBI

基本上,这段代码的作用是为结构体保留静态内存,然后在main()函数中对其进行初始化。为什么要这样做呢?它并没有太大的用处,你应该只使用指针,但问题是:

有了这个语句,

constexpr my_object &I = my_object_storage_instance.memory;

定义一个未初始化内存的引用是否属于未定义行为?其他人告诉我是这样的,但我正在尝试确定是否确实如此。在C++标准中,我们看到了这段话:

引用必须初始化为引用有效对象或函数。[注:特别是,空引用不能存在于定义良好的程序中,因为创建这样的引用的唯一方法是将其绑定到通过解除引用空指针获得的“对象”,这会导致未定义行为。

具体来说,是指“有效对象”,这可能归结为:还没有调用构造函数的对象是否“有效”?什么使它无效以导致未定义行为?是否真的会出现实际的副作用?

我认为这被标记为未定义行为的理由是:

编译器可能会将其视为有效对象,因为标准规定应该这样做,特别是在赋值期间,尤其是如果插入了用于诊断的隐藏调试指令,这些指令假设它是有效的对象,这肯定会导致未定义行为。
我反对它被视为未定义行为的论点是:
它没有解引用任何东西——该段落说明,在引用初始化期间,解引用nullptr是未定义的。如果没有任何解引用,它并没有明确说明未定义行为。
悬空引用是一种存在于许多正常程序中的情况。它们只有在使用时才会导致未定义行为。这类似于从一个悬空引用开始。
再次强调,实际上不太有用,因为有更好的方法来利用时间,但比stack overflow更适合奇怪的问题和专家意见的地方又在哪里呢? :)

1
请注意,“通常”实现此操作(延迟构造具有静态存储期的对象)的方法是使用std::optional - Ben Voigt
我不确定你的意思 - 它是一个对象,但它还没有调用构造函数,因此那里的内存处于未初始化状态。 - mukunda
@mukunda:它的构造函数已被调用。你的类不是平凡的,因此它有一个非平凡的默认构造函数。如果该成员可以默认构造,则联合体始终将初始化为其第一个成员。 - Nicol Bolas
我添加了一些测试来查看构造函数是否在main()之前调用,但它没有被调用。尽管我不是关于联合语义的专家,但我认为如果存在初始化语法,它只会被默认构造,然后我不知道联合构造函数如何发挥作用。不过,为避免一些混淆,我将在示例中添加“char dummy”。 - mukunda
2
@NicolBolas:变量成员的构造函数不会隐式调用。 - Ben Voigt
@NicolBolas: http://eel.is/c++draft/class.init#class.base.init-9.2 - Ben Voigt
1个回答

6
你的使用方式完全正确,它符合一个活动对象是必需的规则的明确例外。在[basic.life]中:
同样地,在对象的生命周期开始之前但在分配对象所占用的存储空间之后,或者在对象的生命周期结束之后并在重用或释放对象所占用的存储空间之前,任何引用原始对象的glvalue都可以使用,但只能以有限的方式使用。
有关正在构建或销毁的对象,请参见[class.cdtor]。否则,这样的glvalue将引用已分配的存储空间([basic.stc.dynamic.allocation]),并且使用不依赖于其值的glvalue属性是定义良好的。如果程序满足以下条件,则具有未定义的行为:
- glvalue用于访问对象,或 - glvalue用于调用对象的非静态成员函数,或 - glvalue绑定到虚基类的引用[dcl.init.ref],或 - glvalue用作dynamic_cast[expr.dynamic.cast]的操作数或typeid的操作数。
如果在对象的生命周期结束之后并在重用或释放对象所占用的存储空间之前,在原始对象所占用的存储位置上创建了一个新对象,则指向原始对象的指针、引用原始对象的引用或原始对象的名称将自动引用新对象,并且一旦新对象的生命周期已经开始,就可以用来操作新对象,如果:
- 新对象的存储正好覆盖原始对象所占用的存储位置,并且 - 新对象与原始对象具有相同的类型(忽略顶层cv限定符),并且 - 原始对象的类型没有被const限定,并且,如果是类类型,则不包含任何类型为const限定或引用类型的非静态数据成员,并且 - 原始对象和新对象都不是潜在重叠的子对象[intro.object]。
因此,您的引用有效地指向已分配的存储空间,这正是您执行放置新对象并唤醒联合成员所需的内容。
由于您创建的对象的动态(运行时)类型与您持有的引用的静态类型完全匹配,因此它可以在放置新对象后(第一个或第二个)用于访问新对象。

非常好的参考,谢谢。我还研究了std::optional的实现,并发现他们在静态存储部分也使用了相同的union技巧。 - mukunda
1
@NicolBolas:变量成员在被唤醒之前不会生效。在第二次唤醒之前,析构函数会被显式调用。我没有在 OP 的示例中看到任何“重用终止”。 - Ben Voigt
1
@NicolBolas 我同意Ben的观点,这里没有任何对象的生命周期被重用终止; 特别是因为[intro.object]/2讨论了在包含对象的生命周期内创建与成员子对象相关联的存储中的对象的可能性。 - aschepler

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