类是否应该能够抵御双重破坏

Should classes be resilient to double destroy?

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

我遇到一种情况,即并发访问容器的分配器需要同时调用放置 new 和析构函数。

template< class U > void destroy(U* p){
p->~U();
}

事实上,我最终可以反复调用破坏。 这让我想到这样的事情是否应该可以。

std::vector<int>* p = (std::vector<int>*)malloc(sizeof(std::vector<int>));
::new (*p) std::vector<int>(30);
(*p)[10] = 5;
p->~std::vector<int>();
p->~std::vector<int>();
free(p);

我认为只要销毁std::vector将数据指针设置为 null 或 size 设置为零,并且再次调用时没有双重释放,这将起作用。

那么,是否应该将类设置为意外(或良性)双重销毁应等同于单次销毁?

换句话说,销毁是否应该是一种幂等操作?

(请注意,为简单起见,此示例中的析构函数不是虚拟的)

我发现这个问题类似于如何允许稍后销毁移动类的问题。


一些答案将运行时成本用于反对支持双重销毁的理由。 有成本,但它们类似于移动物体的成本。 换句话说,如果移动便宜,那么允许双重破坏是便宜的(如果DD首先不是UB,因为其他原因,例如标准)。

具体来说:

class dummyvector{
int* ptr;
public:
dummyvector() : ptr(new int[10]){}
dummyvector(dummyvector const& other) = delete; // for simplicity
dummyvector(dummyvector&& other) : ptr(other.ptr){
other.ptr = nullptr; // this makes the moved object unusable (e.g. set) but still destructable 
}
dummyvector& operator=(dummyvector const&) = delete; // for simplicity
void set(int val){for(int i = 0; i != 10; ++i) ptr[i]=val;}
int sum(){int ret = 0; for(int i = 0; i != 10; ++i) ret += ptr[i]; return ret;}
~dummyvector(){
delete[] ptr;
ptr = nullptr; // this is the only extra cost for allowing double free (if it was not UB)
// ^^ this line commented would be fine in general but not for double free (not even in principle)
}
};

鉴于销毁意味着对象的生命周期结束,处理双重销毁是付出代价(使内存处于可重新破坏状态的额外工作),以获得任何格式良好的代码都不应该遇到的好处。

说双重销毁是可以的,相当于说释放后使用是好的;当然,在特定情况下允许它的分配器可能会保持错误的代码工作,但这并不意味着如果它阻止分配器在非病理情况下高效和正确地工作,那么它就是值得在分配器中保留的功能。

如果你曾经处于双重销毁的可能性,那么你可能处于一个可以销毁后使用的位置,现在你必须对你的类的每个操作进行检查和"处理"(无论这意味着什么)在销毁实例上被调用的可能性。您已经损害了正常操作以允许滥用,这是一条可怕的开始之路。

简短版本:不要防止双重破坏;当你被双重销毁的那一刻,有问题的代码无论如何都进入了未定义的行为领域,处理它不是你的工作。编写一开始就不会做可怕事情的代码。

好吧,实际上不可能做到这一点。或者至少,并非没有明显的痛苦。

请参阅,在类的析构函数完成执行后,其所有子对象都不再存在。因此,当析构函数的第二次调用尝试访问其任何子对象时,您将调用 UB。

因此,如果要实现双重销毁安全,则需要在类之外存储状态,以便能够判断特定实例是否已被销毁。由于你可以有一个类的任意数量的实例,处理这个问题的唯一方法是让构造函数分配一些内存,并使用析构函数可以检查的内容注册this指针。

对于该对象的每个双重破坏安全子对象的每个实例,都必须发生这种情况。这是大量的开销,所有这些都是为了阻止不应该发生的事情。


正如Raymond Chen所指出的,仅仅对任何非平凡可破坏的类型调用双重破坏的行为都是未定义的行为

[basic.life]/1 告诉我们,具有非平凡析构函数的对象在调用析构函数时结束其生存期。[basic.life]/6告诉我们:

在对象的生存期

开始之前,但在分配了对象将占用的存储之后,或者在对象的生存期结束后,在重新使用或释放对象占用的存储之前,可以使用表示对象将位于或曾经所在的存储位置地址的任何指针,但只能以有限的方式使用。[...]允许通过此类指针进行间接寻址,但生成的左值只能以有限的方式使用,如下所述。在以下情况下,程序具有未定义的行为:

指针用于访问非静态数据成员或调用对象的非静态成员函数,或

析构函数是"对象的非静态成员函数"。所以事实上,不可能让C++型双重破坏安全。

不,您不应该试图防止滥用代码。C++的力量使这样的滥用成为可能,但你必须相信(并记录)预期的用途是被遵守的。

我们是要在所有桥梁周围竖起 12 英尺高的围栏来阻止和保护跳线者(以高昂的成本),还是应该只使用高效和正常的护栏并相信每个人都会遵守预期和合理的用例?