琐碎的析构函数是否会导致混叠

Do trivial destructors cause aliasing

本文关键字:是否 析构函数      更新时间:2023-10-16

C++11 §3.8.1 声明,对于具有简单析构函数的对象,我可以通过分配给其存储来结束其生命周期。 我想知道琐碎的析构函数是否可以延长对象的寿命,并通过"摧毁对象"来引起混叠问题,而我早就结束了这个对象的生命周期。

首先,我知道的东西是安全且无别名的

void* mem = malloc(sizeof(int));
int*  asInt = (int*)mem;
*asInt = 1; // the object '1' is now alive, trivial constructor + assignment
short*  asShort = (short*)mem;
*asShort = 2; // the object '1' ends its life, because I reassigned to its storage
              // the object '2' is now alive, trivial constructor + assignment
free(mem);    // the object '2' ends its life because its storage was released

现在,对于一些不太清楚的事情:

{
    int asInt = 3; // the object '3' is now alive, trivial constructor + assignment
    short* asShort = (short*)&asInt; // just creating a pointer
    *asShort = 4; // the object '3' ends its life, because I reassigned to its storage
                  // the object '4' is now alive, trivial constructor + assignment
    // implicitly, asInt->~int() gets called here, as a trivial destructor
}   // 'the object '4' ends its life, because its storage was released

§6.7.2 声明自动存储持续时间的对象在作用域结束时销毁,表示调用析构函数。 如果有一个 int 要销毁,*asShort = 2是混叠冲突,因为我正在取消引用不相关类型的指针。 但是如果整数的生命周期在 *asShort = 2 之前结束,那么我正在调用 short 上的 int 析构函数。

我看到关于此的几个相互竞争的部分:

§3.8.8 内容

如果程序使用静态 (3.7.1)、线程 (3.7.2) 或自动 (3.7.3) 结束 T 类型的对象的生存期 存储持续时间,如果 T 具有非平凡析构函数,则程序必须确保 原始类型在发生隐式析构函数调用时占用相同的存储位置;否则 程序的行为未定义。

在我看来,他们使用非平凡析构函数调用类型 T 以产生未定义的行为这一事实似乎表明在该存储位置中定义了具有简单析构函数的不同类型,但我在规范中找不到定义它的任何地方。

如果将一个微不足道的析构函数定义为 noop,这样的定义会很容易,但规范中关于它们的描述非常少。

§6.7.3 表示允许 goto 跳入和跳出其变量具有平凡构造函数和平凡析构函数的作用域。 这似乎暗示了一种允许跳过琐碎析构函数的模式,但是规范中关于在范围末尾销毁对象的前面部分没有提到这些。

最后,是时髦的阅读:

§3.8.1 表示如果对象的构造函数是微不足道的,我可以随时启动对象的生命周期。 这似乎表明我可以做类似的事情

{
    int asInt = 3;
    short* asShort = (short*)&asInt;
    *asShort = 4; // the object '4' is now alive, trivial constructor + assignment
    // I declare that an object in the storage of &asInt of type int is
    // created with an undefined value.  Doing so reuses the space of
    // the object '4', ending its life.
    // implicitly, asInt->~int() gets called here, as a trivial destructor
}

这些读数中唯一似乎表明存在任何混叠问题的是 §6.7.2 本身。 似乎,当作为整个规范的一部分阅读时,琐碎的析构函数不应该以任何方式影响程序(尽管出于各种原因)。 有谁知道在这种情况下会发生什么?

在第二个代码片段中:

{
    int asInt = 3; // the object '3' is now alive, trivial constructor + assignment
    short* asShort = (short*)&asInt; // just creating a pointer
    *asShort = 4; 
    // Violation of strict aliasing. Undefined behavior. End of.
}

这同样适用于您的第一个代码片段。它不是"安全的",但它通常会起作用,因为 (a) 没有特别的理由实现编译器,使其不起作用,以及 (b) 在实践中编译器必须至少支持一些违反严格别名的行为,否则将无法使用编译器实现内存分配器。

我知道可以而且确实会促使编译器破坏这种代码的是,如果您事后读取asInt,DFA 可以"检测"asInt未被修改(因为它仅通过严格别名违规进行修改,即 UB),并将写入后asInt的初始化移动到*asShort。不过,根据我们对标准的任何一种解释,这都是 UB——在我的解释中,因为严格的混叠违规,在你的解释中,因为asInt在其生命周期结束后被读取。所以我们都很高兴这不起作用。

但是我不同意你的解释。如果您认为分配给asInt的部分存储会结束asInt的生命周期,那么这与自动对象的生存期是其范围的声明直接矛盾。好的,所以我们可能会接受这是一般规则的例外。但这意味着以下内容无效:

{
    int asInt = 0;
    unsigned char *asChar = (unsigned char*)&asInt;
    *asChar = 0; // I've assigned the storage, so I've ended the lifetime, right?
    std::cout << asInt; // using an object after end of lifetime, undefined behavior!
}

除了允许unsigned char作为别名类型(以及定义 all-bits-0 表示整数类型的"0")的全部意义在于使这样的代码工作。所以我非常不愿意对标准的任何部分进行解释,这意味着这是行不通的。

Ben在下面的评论中给出了另一种解释,即*asShort分配根本不会结束asInt的生命周期。

我不能说我有所有的答案,因为这是我努力消化的标准的一部分,而且它不是平凡的(非常复杂的委婉说法)。不过,由于我不同意史蒂夫·杰索普的回答,这是我的看法。

void f() {
   alignas(alignof(int)) char buffer[sizeof(int)];
   int *ip = new (buffer) int(1);                 // 1
   std::cout << *ip << 'n';                      // 2
   short *sp = new (buffer) short(2);             // 3
   std::cout << *sp << 'n';                      // 4
}

该函数的行为由标准明确定义和保证。严格的混叠规则完全没有问题。这些规则确定何时可以安全地读取写入变量的值。在上面的代码中,读入 [2] 通过相同类型的对象提取 [1] 中写入的值。赋值重char的内存并终止其生存期,因此类型 int 的对象将变为先前由 char 占用的空间。严格的混叠规则对此没有问题,因为读取是使用相同类型的指针进行的。在[3]中,short被写入先前被int占用的内存上,重用存储。int消失了,short开始了它的生命周期。同样,[4] 中的读取是通过用于存储值的相同类型的指针进行的,并且根据别名规则完全没问题。

此时的关键是别名规则的第一句话: 3.10/10 如果程序尝试通过以下类型之一以外的 glvalue 访问对象的存储值,则行为是未定义的:

关于对象的生存期,

特别是当对象的生存期结束时,您提供的报价不完整。析构函数不运行是完全可以的,只要程序不依赖于正在运行的析构函数。这在某种程度上很重要,但我认为说清楚很重要。虽然没有明确说明,但事实是平凡析构函数是无操作(这可以从什么是平凡析构函数的定义中得出)。[见下面的编辑]。3.8/8 中的引用意味着,如果你有一个带有简单析构函数的对象,例如任何具有静态存储的基本类型,你可以重用内存,如上所示,这不会导致未定义的行为(本身)。前提是,由于类型的析构函数是微不足道的,因此它是一个无操作,并且当前位于该位置的内容对于程序并不重要。(此时,如果存储在该位置的内容微不足道,或者程序不依赖于其析构函数的运行,则程序将被很好地定义;如果程序行为取决于覆盖类型的析构函数来运行,那么,运气不好:UB)

<小时 />

简单析构函数

标准 (C++11) 在 12.4/5 中将析构函数定义为微不足道的:

如果析构函数不是用户提供的,并且

如果:

— 析构函数不是虚拟的,

— 其类的所有直接基类都有简单的析构函数,并且

— 对于其类中所有属于类类型(或其数组)的非静态数据成员,每个此类都有一个简单的析构函数。

需求可以重写为:析构函数是隐式定义的而不是虚拟的,没有一个子对象具有非平凡析构函数。第一个要求意味着析构函数调用不需要动态调度,这使得启动销毁链不需要vptr的值。

隐式定义的析构函数根本不会对任何非类类型(基本类型、枚举)执行任何操作,但会调用类成员和基的析构函数。这意味着析构函数不会触及存储在完整对象中的任何数据,因为毕竟一切都由基本类型的成员组成。从这个描述来看,似乎 trival 析构函数是一个无操作,因为没有触及任何数据。但事实并非如此。

我记错的细节是,要求不是根本没有虚函数,而是析构函数不是虚拟的。因此,类型可以具有虚函数,也可以具有普通析构函数。这意味着,至少在概念上,析构函数不是无操作的,因为完整对象中存在的vptr(或vptr)在破坏链期间随着类型的变化而更新。现在,虽然一个普通析构函数在概念上可能不是无操作,但评估析构函数的唯一副作用是修改vptr,这是不可见的,因此遵循 as-if 规则,编译器可以有效地使平凡析构函数成为无操作(即它根本无法生成任何代码), 这就是编译器实际所做的,也就是说,一个简单的析构函数不会有任何生成的代码。

相关文章: