如何删除空指针除了调用全局删除操作符外还能做什么?

6
C++标准非常明确地指出,使用deletedelete[]删除void指针是未定义的行为,正如this answer所引用的那样:

这意味着不能使用void*类型的指针来删除对象,因为没有void类型的对象。

然而,据我了解,deletedelete[]只做两件事:
  • 调用适当的析构函数
  • 调用适当的operator delete函数,通常是全局的
存在一个单参数的operator delete(以及operator delete[]),该单参数为void* ptr
因此,当编译器遇到使用void*操作数的删除表达式时,它当然可以恶意地执行一些完全不相关的操作,或者仅输出该表达式的代码。更好的做法是,它可以发出诊断消息并拒绝编译,尽管我测试过的MSVS、Clang和GCC版本都没有这样做。(后两个版本使用-Wall发出警告;使用/W3的MSVS则不会。)
但是,在删除操作的每个步骤中,只有一种明智的处理方式:
  • void*不指定析构函数,因此不会调用任何析构函数。
  • void不是类型,因此不能具有特定对应的operator delete,因此必须调用全局的operator delete(或[]版本)。由于函数的参数是void*,因此不需要进行类型转换,并且运算符函数必须正确运行。
那么,通常的编译器实现(假设它们不是恶意的,否则我们甚至无法信任它们遵守标准)在遇到这样的删除表达式时,能否依靠它们遵循上述步骤(释放内存而不调用析构函数)?如果不能,为什么?如果可以,当数据的实际类型没有析构函数时(例如它是原始类型数组,如long[64]),使用delete这种方式是否安全?
全局删除运算符void operator delete(void* ptr)(以及相应的数组版本)能否直接安全地用于void*数据(再次假设不需要调用析构函数)?

3
当然,为什么不呢?语言规范并没有强制要求(这就是“未定义行为”的意思),所以你可以猜测你的实现可能会发生什么。有什么坏处呢? - Pete Becker
1
标准规定这是未定义行为(UB)。符合标准的代码不会出现UB。优化器可以利用这一点来删除包含UB的代码路径。请参见示例:https://en.cppreference.com/w/cpp/language/ub 和 http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html。 - Richard Critten
3
这个问题似乎可以概括为“我能相信这个编译器理解我的意思吗?”。我不确定有谁能真正帮得上忙。 - M.M
1
就我所知,我曾被迫使用MSVC工作了几年,使用这种反模式。 在Windows CE设备上,我一直在与堆栈损坏问题作斗争,而桌面客户端似乎运行良好。 当然,这并不能告诉你太多,因为我们的CE操作系统是由疯狂的人定制的,代码的总体质量也仅仅是马马虎虎。最后我不得不嵌入较大结构的校验和:( 糟糕至极。 - zzxyz
1
@RichardCritten:编写一个优化器,可以处理使用方言编写的代码,这些方言通过在标准定义的行为之外增加通常用于嵌入式和系统编程领域的行为。只要真正努力去做,这并不是特别困难的。 - supercat
显示剩余12条评论
5个回答

3
一个 void* 是指向未知类型对象的指针。如果你不知道某个东西的类型,那么你不可能知道如何销毁它。因此,我认为,没有“真正只有一种明智的方式来处理这样的删除操作”。唯一明智的处理方法是不去处理它。因为你根本无法正确地处理它。
因此,正如你链接到的原始答案所说:删除 void* 是未定义行为 ([expr.delete] §2)。该答案中提到的脚注 至今基本保持不变。我真的有点惊讶,为什么这只是被指定为未定义行为而不是使其非法,因为我无法想象任何情况下这不能在编译时被检测出来。
请注意,从C++14开始,new表达式不一定意味着调用分配函数。同样,delete表达式也不一定意味着调用释放函数。编译器可能调用分配函数来为使用new表达式创建的对象获取存储空间。在某些情况下,编译器允许省略这样的调用并使用其他方式分配的存储空间。例如,使编译器有时可以将使用new创建的多个对象打包到一个分配中。

在使用 void* 时调用全局释放函数代替使用 delete 表达式是否安全?只有当该存储空间是由相应的全局分配函数分配的时才安全。一般来说,除非您自己调用了分配函数,否则您无法确定这一点。如果您的指针来自于 new 表达式,通常您不知道该指针是否是释放函数的有效参数,因为它可能根本就没有指向通过调用分配函数获得的存储空间。请注意,知道哪个分配函数必须被 new 表达式使用基本上等同于知道您的 void* 指向的任何内容的动态类型。如果您知道这一点,那么您也可以使用 static_cast<> 转换为实际类型并进行 delete

在不显式调用析构函数的情况下释放具有平凡析构函数的对象的存储空间是否安全?根据 [basic.life] §1.4,我认为是安全的。请注意,如果该对象是数组,则仍然可能需要先调用任何数组元素的析构函数,除非它们也是平凡的。

你能依赖常见的编译器实现来产生你认为合理的行为吗?不行。拥有确切的规范定义,明确你可以依赖什么,这正是制定标准的全部意义所在。假设你有一个符合标准的实现,你可以依赖标准给你的保证。你还可以依赖特定编译器文档可能给出的任何额外保证,只要你使用那个特定版本的编译器编译你的代码。除此之外,一切都无法保证……

2
“Calling no destructor would be just as good as calling any random destructor”这种说法有些牵强附会。我很难找到任何情况下,我宁愿调用随机析构函数而不是不调用析构函数。 - zneak
1
其次,在考虑是否安全调用全局释放函数时,有一个有用的提示需要记住,即尽管你不知道,编译器也不知道。我还没有仔细思考过,但我的直觉是这个决定是不可计算的,并且知道它的优化好处微不足道,因此编译器极不可能关心。当然,重要的是不要搞砸,但大多数环境都受到足够的控制,没有那么多可供选择的释放函数。 - zneak
3
编译器的new运算符可以很容易地在分配时包含对象的析构函数信息,而无论指针类型如何,delete都可以利用这些信息。我不会感到惊讶,如果一些编译器实际上已经这样做了。如果一些编译器支持某种有用的构造方式,但其他编译器却不支持,标准通常允许编译器自行决定是否支持该行为,希望基于对其客户受益的考虑。 - supercat
@KyleStrand 称其为“神奇”确实是不必要的。我有点失态了。我已经删除了那一部分... - Michael Kenzel
虽然我对假设分配的内存来自默认分配器会有多大问题有些不同意。据我所知,在程序中拥有多个分配器是相当小众的。 - Kyle Strand
显示剩余9条评论

1
如果您想调用释放函数,只需调用释放函数即可。
这很好:
void* p = ::operator new(size);

::operator delete(p);  // only requires that p was returned by ::operator new()

这不是:

void* p = new long(42);

delete p;  // forbidden: static and dynamic type of *p do not match, and static type is not polymorphic

但请注意,这也不安全:

void* p = new long[42];

::operator delete(p); // p was not obtained from allocator ::operator new()

为什么最后一行代码不安全?此外,我真的很想要一个具体的解释或示例,说明在实践中如何实际触发不良行为 - Kyle Strand
1
@KyleStrand - 如果您决定不费心存储分配大小,基于单元格的分配很容易触发不良行为。(您可以根据对象大小查找单元格大小,这样就知道要去哪个分配表)。这肯定有点牵强,但我曾经看到过一些非常奇怪的内存分配例程导致的行为。(当然,商业编译器中没有这种情况)。 - zzxyz
1
@KyleStrand:Array new 可以在分配的开头放置元数据(标准语:补充信息),位于内容之前。然后,operator delete[](void*) 调用需要分配的地址,并传递内容的地址将失败,因为它们是不同的。 - Ben Voigt
即使不涉及数组,也有可能出现问题,因为标准允许new long(42)调用两个分配器中的任何一个,带或不带额外的对齐参数,并且必须匹配解除分配器。 - Ben Voigt
1
这里是标准中重要的引用:“执行delete表达式时,在删除对象时,应调用所选的释放函数,并将地址设置为最终派生对象的地址;在删除数组时,则需要对对象地址进行适当调整以考虑分配开销(8.3.4),然后将其作为第一个参数。” - Ben Voigt

1
虽然标准允许实现使用传递给delete的类型来决定如何清理相关对象,但它并不要求实现这样做。标准还允许另一种(可能更优越的)方法,即在返回地址之前的空间中存储内存分配的new清理信息,并将delete实现为类似以下内容的调用:
typedef void(*__cleanup_function)(void*);
void __delete(void*p)
{
  *(((__cleanup_function*)p)[-1])(p);
}

在大多数情况下,以这种方式实现new/delete的成本相对较小,并且该方法会提供一些语义上的好处。唯一的显著缺点是,它要求记录其new/delete实现内部工作原理的实现并且无法支持类型不可知的delete的实现必须打破依赖于其记录的内部工作原理的任何代码。
请注意,如果将void*传递给delete是一个约束违规,那么即使实现很容易做到这一点,而且一些为它们编写的代码依赖于这种能力,也会禁止实现提供类型不可知的delete。当然,代码依赖于这种能力将使其仅限于可以提供它的实现,但允许实现选择支持这些功能比将其视为约束违规更有用。
就个人而言,我希望标准可以为实现者提供两个具体选择:
  1. 允许将void*传递给delete,并使用传递给new的任何类型来删除对象,并定义指示支持此构造的宏。

  2. 如果将void*传递给delete,则发出诊断,并定义指示不支持此构造的宏。

支持类型不可知的delete的程序员可以决定是否通过使用它来获得收益来证明其可移植性限制,实现者可以决定是否支持更广泛的程序的好处足以证明支持该特性的小成本。


我认为在一般情况下,在分配中存储清理信息对于符合规范的实现是不可能的。[expr.new] §11§12 在处理新表达式所创建的分配大小时非常具体,可惜了。数组基本上是唯一的例外,编译器允许请求额外的存储空间来保持已创建对象所需要的大小。 - Michael Kenzel
如果没有安装用户提供的分配函数,那么据我所知,实现可以自由地按其所见方式进行分配(尽管在调用此类函数时也可以这样做,但是优质实现通常应首选调用用户提供的函数而不是请求自己的堆分配)。然而,似乎标准的意图是禁止之前曾经是一种有用方法的做法。 - supercat
请注意,关于new分配的大小的保证已经存在于C++03标准中,因此这并不是一个新的添加。我不知道是否有任何实现实际上会像“以前”那样做任何事情。如果有的话,我会认为它们只能违反标准来这样做。实现始终可以简单地保留自己的数据结构,例如,一个映射来跟踪所有活动分配的清理信息。当然,这将引入相当大的开销... - Michael Kenzel
许多C++的实现在第一个发布的标准之前就存在了。通过使用由new存储的信息来实现所有类型的delete并不困难,而且标准之前的实现还做了许多有趣的事情,但这些实现已经逐渐被淘汰了。 - supercat

0

void没有大小,因此编译器无法知道要释放多少内存。

编译器应该如何处理以下内容?

struct s
{
    int arr[100];
};

void* p1 = new int;
void* p2 = new s;
delete p1;
delete p2;

正如我在问题中所指出的,释放内存的函数(operator delete)接受 void* 参数,因此大小数据是在运行时存储在内存中而不是从类型系统推断出来的。 - Kyle Strand
@KyleStrand,那么为什么标准要求同时使用deletedelete[]?如果有运行时信息记录,那么区别就是多余的。 - Mark Ransom
我想我可以想象一种依赖于类型信息来删除单个项的实现方式,但我不确定它如何处理指向基类的指针,这个指针可以合法地用于删除派生类的实例。 - Kyle Strand

0
void* 指定没有析构函数,因此不会调用任何析构函数。
这很可能是不允许的原因之一。在不调用类的析构函数的情况下释放支持类实例的内存是一个非常糟糕的想法。
例如,假设该类包含一个具有数十万个元素的 std::map。这代表了大量的内存。执行您提出的操作将泄漏所有这些内存。

我的问题明确指出,我只对即使在正确的 delete 表达式中也不会涉及任何析构函数的情况感兴趣。这意味着没有非 POD 类。 - Kyle Strand
请注意,您是正确的;这确实是标准中该脚注(以及GCC和Clang警告)的陈述理由。 - Kyle Strand

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接