手动调用析构函数总是设计不良的标志吗?

102

我在思考:人们说如果你手动调用析构函数,那么你正在做一些错误的事情。但这总是正确的吗?是否存在任何反例?有没有必要手动调用析构函数的情况,或者避免手动调用析构函数很难/不可能/不切实际的情况?


在调用析构函数后,你如何释放对象而不再次调用它? - ssube
2
@peachykeen:你可以调用放置new来用新对象替换旧对象进行初始化。通常不是一个好主意,但也不是没有先例。 - D.Shawley
18
查看那些包含“总是”和“从不”这些词汇的“规则”,而这些规则并非直接来自规范。这些规则值得怀疑:在大多数情况下,教授这些规则的人想要隐藏你应该知道的东西,但他不知道如何教授。就像成年人回答孩子有关性的问题一样。 - Emilio Garavaglia
我认为在使用放置技术构造对象时,这是可以接受的(但这是相对较低级别的事情,只有在优化软件时才会使用,即使在这种程度上)。http://www.stroustrup.com/bs_faq2.html#placement-delete - Konstantin Burlachenko
12个回答

136

所有的答案都描述了具体情况,但是有一个通用的答案:

每当你需要仅仅销毁(在C ++中)对象而不释放内存时,你就显式地调用析构函数。

这通常发生在所有内存分配/释放与对象构建/销毁独立管理的情况下。在这些情况下,通过placement new在现有内存块上进行构造,并通过显式的析构函数调用进行销毁。

以下是原始示例:

{
  char buffer[sizeof(MyClass)];

  {
     MyClass* p = new(buffer)MyClass;
     p->dosomething();
     p->~MyClass();
  }
  {
     MyClass* p = new(buffer)MyClass;
     p->dosomething();
     p->~MyClass();
  }

}

另一个值得注意的例子是默认的std::allocator在被std::vector使用时:元素在push_back期间在vector中构造,但内存是以块的形式分配的,因此它预先存在于元素构造之前。因此,vector::erase必须销毁元素,但不一定要释放内存(特别是如果很快会发生新的push_back...)。

从严格的OOP意义上说,这是"不良设计"(你应该管理对象,而不是内存:事实上对象需要内存是一个"事件"),但在"低级编程"或内存不来自默认的operator new的自由存储区时,这是"良好的设计"。

如果它在代码中随机出现,那么这是一个不良设计;如果它局部出现在专门设计用于此目的的类中,则这是一个良好的设计。


110

如果对象是使用重载形式的 operator new() 构造的,则需要手动调用析构函数,除非使用 "std::nothrow" 重载:

T* t0 = new(std::nothrow) T();
delete t0; // OK: std::nothrow overload

void* buffer = malloc(sizeof(T));
T* t1 = new(buffer) T();
t1->~T(); // required: delete t1 would be wrong
free(buffer);

在IT技术中,手动释放内存并显式调用析构函数是一种低级的管理内存的方式,这种方式不仅设计不良,而且可能是错误的(是的,在赋值运算符中使用显式析构函数和复制构造函数是一种糟糕的设计,很可能是错误的)。

使用C++ 2011时,有另一个原因需要使用显式析构函数调用:当使用广义联合时,需要显式销毁当前对象并使用placement new创建新对象,以更改所表示对象的类型。此外,在销毁联合时,如果当前对象需要销毁,则需要显式调用当前对象的析构函数。


34
正确的翻译是:与其说“使用operator new的重载形式”,更准确的说法是“使用placement new”。 - Remy Lebeau
5
@RemyLebeau:我希望澄清一下,我不仅仅在谈论 operator new(std::size_t, void*)(以及数组的变化),而是所有重载版本的 operator new() - Dietmar Kühl
当您想要复制一个对象并在操作期间对其进行计算而不更改它时怎么办?temp = Class(object); temp.operation(); object.~Class(); object = Class(temp); temp.~Class(); - Jean-Luc Nacif Coelho
是的,在赋值运算符中使用显式析构函数,然后调用复制构造函数是一种糟糕的设计,很可能是错误的。为什么这么说呢?如果析构函数是微不足道的,或者接近微不足道的,那么它的开销就很小,并且增加了DRY原则的使用。如果在这种情况下与移动operator=()一起使用,甚至可能比使用swap更好。但是,具体情况具体分析。 - Adrian
1
@Adrian:调用析构函数并重新创建对象会非常容易地改变对象的类型:它将使用赋值的静态类型重新创建一个对象,但动态类型可能会不同。当类具有“虚拟”函数时,这实际上是一个问题(“虚拟”函数不会被重新创建),否则对象仅部分[重新-]构建。 - Dietmar Kühl
显示剩余2条评论

15
不是所有情况都需要,但有时调用析构函数是合法且良好的设计。
为了理解何时以及为什么需要显式调用析构函数,让我们看看“new”和“delete”的工作原理。
动态创建对象,T* t = new T;实际上:1. 分配了sizeof(T)大小的内存。2. 调用T的构造函数对分配的内存进行初始化。operator new执行两个操作:分配和初始化。
销毁对象delete t;实际上:1. 调用T的析构函数。2. 释放为该对象分配的内存。operator delete也执行两个操作:销毁和释放。
构造函数负责初始化,而析构函数负责清理工作。当你显式调用析构函数时,只会执行清理工作,但不会执行释放操作。
因此,显式调用析构函数的合法用途可能是:“我只想销毁对象,但我不想(或不能)释放内存分配(尚未)。”
一个常见的例子是,为一组特定对象预先分配内存,否则必须动态分配。创建新对象时,从预分配的池中获取内存块并进行“placement new”。完成对象后,如果有任何清理工作,您可能希望显式调用析构函数来完成。但是,您实际上不会释放内存,因为operator delete会执行该操作。相反,您将内存块返回给池以供重复使用。

13

不应该显式地调用它,因为这样会调用两次。一次是手动调用,另一次是在声明对象的作用域结束时。

例如:

{
  Class c;
  c.~Class();
}

如果您确实需要执行相同的操作,应该有一个单独的方法。

有一种特定情况,您可能希望使用放置new调用动态分配对象的析构函数,但这听起来不像是您永远需要的。


9

6

任何时候,如果您需要将分配与初始化分开, 您将需要使用放置new和显式调用析构函数。 今天,由于我们有标准容器,很少需要这样做, 但是如果您必须实现一些新的容器类型,就需要它。


3

我发现另一个需要手动调用析构函数的例子。假设你已经实现了一个类似于variant的类,它可以保存多种类型的数据:

struct Variant {
    union {
        std::string str;
        int num;
        bool b;
    };
    enum Type { Str, Int, Bool } type;
};

如果Variant实例持有一个std::string,并且现在你要将不同类型分配给联合体,则必须首先销毁std::string编译器不会自动执行此操作

3

有时它们是必要的:

在我的代码中,我在分配器中使用显式析构函数调用,我有一个简单的分配器实现,它使用放置 new 返回内存块到 stl 容器。在销毁时,我有以下代码:

  void destroy (pointer p) {
    // destroy objects by calling their destructor
    p->~T();
  }

在构造函数中:

  void construct (pointer p, const T& value) {
    // initialize memory with placement new
    #undef new
    ::new((PVOID)p) T(value);
  }

在allocate()中还会进行分配操作,在deallocate()中进行内存释放,使用特定于平台的alloc和dealloc机制。例如,在Windows上使用LocalAlloc函数来绕过Doug Lea malloc并直接使用该分配器。


2

这个怎么办呢?
如果在构造函数中抛出异常,则析构函数不会被调用,因此我需要手动调用析构函数来销毁在构造函数中创建的句柄,以防止内存泄漏。

class MyClass {
  HANDLE h1,h2;
  public:
  MyClass() {
    // handles have to be created first
    h1=SomeAPIToCreateA();
    h2=SomeAPIToCreateB();        
    try {
      ...
      if(error) {
        throw MyException();
      }
    }
    catch(...) {
      this->~MyClass();
      throw;
    }
  }
  ~MyClass() {
    SomeAPIToDestroyA(h1);
    SomeAPIToDestroyB(h2);
  }
};

2
这似乎是有问题的:当您的构造函数抛出异常时,您不知道(或可能不知道)哪些对象已经被构造,哪些没有。因此,您不知道要调用哪些子对象的析构函数,例如。或者要释放哪些由构造函数分配的资源。 - Violet Giraffe
@VioletGiraffe 如果子对象是在堆栈上构建的,即不使用“new”,它们将自动销毁。否则,在析构函数中销毁它们之前,您可以检查它们是否为NULL。资源也是同样的情况。 - CITBL
2
你在这里编写ctor的方式是错误的,正如你自己提供的原因一样:如果资源分配失败,则清理会出现问题。'ctor'不应调用this->~dtor()dtor应该在已构造的对象上调用,在这种情况下,对象尚未构造。无论发生什么情况,ctor都应处理清理工作。在ctor代码内部,你应该使用像std::unique_ptr这样的工具来处理自动清理,以防发生异常。将类中的HANDLE h1,h2字段更改为支持自动清理也可能是一个好主意。 - quetzalcoatl
这意味着,ctor应该像这样:MyClass(){ cleanupGuard1<HANDLE> tmp_h1(&SomeAPIToDestroyA) = SomeAPIToCreateA(); cleanupGuard2<HANDLE> tmp_h2(&SomeAPIToDestroyB) = SomeAPIToCreateB(); if(error) { throw MyException(); } this->h1 = tmp_h1.release(); this->h2 = tmp_h2.release(); } 就是这样。没有冒险的手动清理,也没有在部分构造对象中存储句柄直到一切安全的风险,这是一个额外的好处。如果你将类中的 HANDLE h1,h2 更改为 cleanupGuard<HANDLE> h1; 等,则甚至可能根本不需要 dtor - quetzalcoatl
cleanupGuard1cleanupGuard2的实现取决于相关的xxxToCreate返回什么以及相关的xxxxToDestroy需要哪些参数。如果它们很简单,你甚至不需要编写任何代码,因为通常情况下std::unique_ptr<x,deleter()>(或类似的)可以在两种情况下为您完成工作。 - quetzalcoatl

1
我发现有3种情况需要这样做:
  • 在使用内存映射io或共享内存创建的内存中分配/释放对象时
  • 在使用C++实现给定的C接口时(令人遗憾的是,这种情况今天仍然存在(因为我没有足够的影响力来改变它))
  • 在实现分配器类时

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