6

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 具有非平凡的析构函数 39,则程序必须确保在隐式析构函数调用发生时原始类型的对象占据相同的存储位置;否则程序的行为是不确定的。

在我看来,他们用非平凡析构函数调用类型 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 本身。看起来,当作为整个规范的一部分阅读时,微不足道的析构函数不应该以任何方式影响程序(尽管出于各种原因)。有谁知道在这种情况下会发生什么?

4

2 回答 2

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.

于 2013-09-06T22:32:08.123 回答
1

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

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]中写入的值。该赋值重用chars 的内存并终止了它们的生命周期,因此类型的对象int 超出了 s 先前占用的空间char。严格的别名规则对此没有问题,因为读取是使用相同类型的指针。在 [3] 中,ashort被写入之前由 占用的内存int,从而重用存储。这int消失了,short开始了它的生命。同样,[4] 中的读取是通过用于存储值的相同类型的指针进行的,并且通过别名规则非常好。

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

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


微不足道的析构函数

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

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

— 析构函数不是虚拟的,

— 其类的所有直接基类都有微不足道的析构函数,并且

— 对于其类的所有属于类类型(或其数组)的非静态数据成员,每个这样的类都有一个微不足道的析构函数。

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

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

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

于 2013-09-07T03:46:38.617 回答