琐碎的析构函数会导致别名

时间:2013-09-06 22:09:15

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

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之前结束,那么我在一个简短的函数上调用一个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。看起来,当作为整个规范的一部分阅读时,平凡的析构函数不应以任何方式影响程序(尽管由于各种原因)。有谁知道在这种情况下会发生什么?

2 个答案:

答案 0 :(得分: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存储的 part 结束了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的生命周期。

答案 1 :(得分:1)

我不能说我有所有的答案,因为这是我努力消化的标准的一部分,这是非平凡的(真正复杂的委婉说法)。尽管如此,由于我不同意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]中写入的值。赋值重用 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 s)在更新期间更新随着类型的变化,破坏链。现在,虽然普通析构函数在概念上可能不是无操作,但析构函数评估的唯一副作用是修改vptr s,而不是 visible ,因此遵循 as-if 规则,编译器可以有效地使普通的析构函数成为无操作(即它根本不能生成任何代码),这就是编译器实际上做了什么,也就是说,普通的析构函数将不会生成任何代码。