【问题标题】:Do trivial destructors cause aliasing微不足道的析构函数会导致混叠吗
【发布时间】:2013-09-11 02:48:50
【问题描述】:

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

【问题讨论】:

  • 这个问题是作为别名相关讨论的一部分出现的:stackoverflow.com/questions/18624449/…
  • 很抱歉,这个问题和别名有什么关系?
  • @KerrekSB:显然是asIntasShort 的别名。
  • 根据编译器对琐碎析构函数所做的事情,我可能会同时将同一位置引用为 short 和 int,而不是 int,然后是短。
  • @BenVoigt:我看不到连接。别名是通过不匹配类型的左值“访问对象的存储值”。这不是在这里发生的。一个对象的生命周期结束,另一个对象的生命周期开始。我同意这是标准中令人尴尬的部分,但我希望看到对标准中感知到的问题或矛盾的更清晰的描述。

标签: c++ memory c++11 destructor


【解决方案1】:

在您的第二个代码 sn-p 中:

{
    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.
}

这同样适用于您的第一个代码 sn-p。它不是“安全的”,但它通常会起作用,因为(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 在下面的 cmets 中给出了另一种解释,即 *asShort 赋值根本不会结束 asInt 的生命周期。

【讨论】:

  • 你能扩展(b)吗?我想不出一个场景,但它可能只是在我脑海中闪过。
  • @GManNickG:还没有检查过 C++11,但 C++03 实际上并没有说您可以使用 int 类型的左值访问 char 对象或数组,它只是说相反。也许我错了,但我认为这会威胁到基础。
  • 对象生命周期规则是美丽的法律术语,不幸的是相互矛盾。整个“具有简单初始化的对象在获得存储(适当的大小和对齐)后立即开始其生命周期”尤其成问题,因为它表示所有具有简单初始化的类型始终在整个内存空间中被垃圾邮件。
  • @BenVoigt:嗯,我认为重写标准有点超出我的工资等级,尤其是当我一直在喝酒的时候。在理想世界中,所有这些代码都是合法的:也许诀窍是说可平凡破坏的对象可以由违反严格别名的类型写入,然后保持不确定的值。因为这就是编译器优化时实际发生的情况——如果我通过int* 覆盖浮点数并最终在int 写入后重新排序浮点初始化,我从未真正看到那些鼻恶魔,我只是得到意想不到的值查看那段记忆时。
  • @CortAmmon:关于微不足道的析构函数,您的实际问题的答案可能是它们不做任何事情,特别是它们在名义上执行时不需要对象存在.这与您对 3.8.8 的引用一样明确:它说非平凡可破坏类型 do 需要一个对象,因此可以理解平凡的类型不需要。标准语有时很复杂,但我们可以假设它不是故意误导 :-)
【解决方案2】:

我不能说我有所有的答案,因为这是我努力消化的标准的一部分,而且它并非微不足道(非常复杂的委婉说法)。不过,由于我不同意 Steve Jessop 的回答,所以这是我的看法。

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 类型的对象成为 之前由chars 占用的空间.严格的别名规则对此没有问题,因为读取是使用相同类型的指针。在 [3] 中,short 被写入先前由 int 占用的内存,重用存储。 int 消失了,short 开始了它的生命周期。同样,[4] 中的读取是通过用于存储值的相同类型的指针进行的,并且通过别名规则完全可以。

此时的关键是别名规则的第一句:3.10/10 如果一个程序试图访问一个对象的存储值,它不是以下类型的行为未定义:

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


简单的析构函数

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

如果析构函数不是用户提供的并且满足以下条件,则它是微不足道的:

——析构函数不是虚拟的,

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

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

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

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

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

【讨论】:

  • 这个答案很有道理,但你是否知道他们定义了一个微不足道的析构函数?我一直在搜索规范,然后干了。
  • @CortAmmon:我正在阅读什么是微不足道的析构函数的定义,它与我记忆中的不同(也与 C++03 进行了交叉检查)。上面关于普通析构函数是空操作的声明是 false,将使用此信息更新答案。
  • @CortAmmon:用琐碎的析构函数信息更新了答案。
  • 我认为您(实际上)是在说我对标准的误读非常简单。我认为写一个对象访问它的存储值(“写”是“访问”的一种形式,另一种形式是“读”)。你认为没有。你可能是正确的,但我懒得去寻找任何可能澄清的标准。特别是,如果访问值和访问包含该值的存储之间存在区别,那么您是对的,3.10/10 仅指前者,而 write 是后者。
  • 另外:在您的回答中,您仔细使用placement new 以确保您明确地重新使用内存来创建具有新生命周期的新对象。典型的代码不会那样做,它会做提问者的代码所做的事情,因此了解您的论点是否完全适用于提问者的代码而不是仅适用于您自己的代码会很有用。
猜你喜欢
  • 1970-01-01
  • 2020-07-18
  • 2012-04-09
  • 2014-05-13
  • 1970-01-01
  • 2017-05-09
  • 1970-01-01
  • 2015-12-20
  • 2017-12-29
相关资源
最近更新 更多