我们何时需要定义析构函数?

46

我读到过当我们有指针成员和定义一个基类时,需要定义析构函数,但我不确定是否完全理解。其中一个让我不确定的是是否定义默认构造函数是无用的,因为我们总是默认获得一个默认构造函数。另外,我不确定我们是否需要定义默认构造函数来实现RAII原则(我们只需要在构造函数中放置资源分配而不定义任何析构函数吗?)。

class A
{

public:
    ~Account()
    {
        delete [] brandname;
        delete b;

        //do we need to define it?

    };

    something(){} =0; //virtual function (reason #1: base class)

private:
    char *brandname; //c-style string, which is a pointer member (reason #2: has a pointer member)
    B* b; //instance of class B, which is a pointer member (reason #2)
    vector<B*> vec; //what about this?



}

class B: public A
{
    public something()
    {
    cout << "nothing" << endl;
    }

    //in all other cases we don't need to define the destructor, nor declare it?
}

9
虽然回答可能相关,但问题并不相同。不是重复的。我认为这是一个好问题,想要自己听到答案。 - nonsensickle
4
您的第二个句子有点混淆。我认为您在写构造函数时想表达析构函数的意思? - Filipe Gonçalves
5个回答

42

三五法则和零法则

处理资源的传统方法是使用三五法则(现在因移动语义而称为五法则),但最近另一种规则正在取代它: 零法则

简单来说,资源管理应该留给其他特定类别。标准库提供了一组很好的工具,例如:std::vectorstd::stringstd::unique_ptrstd::shared_ptr,有效地消除了自定义析构函数、移动/复制构造函数、移动/复制赋值运算符和默认构造函数的需求。

如何将其应用于您的代码

在您的代码中有许多不同的资源,这是一个很好的例子。

字符串

如果您注意到brandname实际上是一个“动态字符串”,标准库不仅使您免于使用C风格字符串,还会使用std::string自动管理字符串的内存。

动态分配的B

第二个资源似乎是一个动态分配的B。如果您动态分配资源的原因不仅仅是“我想要一个可选成员”,则应该使用std::unique_ptr,它会自动处理资源(在适当时释放)。另一方面,如果您希望它成为可选成员,则可以使用std::optional代替。

B的集合

最后一个资源只是B数组。使用std::vector轻松管理。标准库允许您从各种不同的容器中选择,以满足不同的需求;只介绍其中的一些:std::dequestd::liststd::array

结论

将所有建议综合起来,您将得到:

class A {
private:
    std::string brandname;
    std::unique_ptr<B> b;
    std::vector<B> vec;
public:
    virtual void something(){} = 0;
};

既安全又易读。


36
好的,但这几乎没有回答问题。问题:「何时应定义析构函数?」回答:「使用vector。」什么意思? - Ed S.
8
@EdS.,这个答案的含义是:永远不要使用vector。 :) - Shoe
6
我不认为这是一个很好的回答。理解从来都不是坏事,而且你不可能真的相信只有标准库实现者会需要定义自己的析构函数。 - Ed S.
4
我认为答案在于正确理解“零规则”和“三规则”,因此你的答案和@Claudiordgz的回答很好地互补。在我看来,其他的只是哲学问题。两个答案都得到了加一的支持。 - nonsensickle
3
@Jeffrey,零规则太棒了,非常感谢你。我以前从未听说过它。 - Claudiordgz
显示剩余4条评论

13

正如@nonsensickle指出的那样,这个问题太宽泛了...所以我要尝试用我所知道的一切来解决它...

重新定义析构函数的第一个原因是在 C ++编程中的三法则 中,在其中的条款6,Scott Meyers的Effective C ++有所涉及但并不全面。三法则规定,如果你重新定义了析构函数、复制构造函数或复制赋值操作,则意味着你应该重写它们三个。原因是如果你为其中一个重写了自己的版本,那么编译器默认的版本对于其他版本将不再有效。

另一个例子是由Scott Meyers在Effective C ++中指出的。

当您尝试通过基类指针删除派生类对象并且基类具有非虚拟析构函数时,结果是未定义的。

然后他继续说:

如果一个类不包含任何虚拟函数,通常表明它不适合用作基类。当一个类不打算用作基类时,将析构函数声明为虚拟的通常是一个坏主意。

他对于虚析构函数的结论是:

归根结底,毫无根据地声明所有析构函数都是虚构的,就像从未声明它们一样错误。事实上,许多人总结情况如下:如果一个类包含至少一个虚拟函数,则在该类中声明虚拟析构函数。

如果不是三法则的情况,那么也许你的对象内有一个指针成员,并且你在对象内分配了内存,那么你需要在析构函数中管理该内存,这是他书中的第6条建议。

一定要查看@Jefffrey关于零法则的答案


1
虽然我认为您的回答很有见地,但我认为问题略微比那个宽泛。他想知道何时应该重载默认构造函数/析构函数,我没有在问题中看到 virtual 的提及。这不是一个答案,但可以作为实际答案的补充,因此请将其标记为如此。在那之前,将其视为无用回答。 - nonsensickle
你认为编辑后更像是一个实际的答案吗? - Claudiordgz
是的,这是一个很大的改进,因此+1。 - nonsensickle
谢谢,我正在努力想另一个原因,但现在真的想不出来了。 - Claudiordgz
我认为你和@Jeffriey已经尽可能地回答了这个问题。 - nonsensickle

6

需要定义析构函数的原因有两个:

  1. When your object gets destructed, you need to perform some action other than destructing all class members.

    The vast majority of these actions once was freeing memory, with the RAII principle, these actions have moved into the destructors of the RAII containers, which the compiler takes care of calling. But these actions can be anything, like closing a file, or writing some data to a log, or ... . If you strictly follow the RAII principle, you will write RAII containers for all these other actions, so that only RAII containers have destructors defined.

  2. When you need to destruct objects through a base class pointer.

    When you need to do this, you must define the destructor to be virtual within the base class. Otherwise, your derived destructors won't get called, independent of whether they are defined or not, and whether they are virtual or not. Here is an example:

    #include <iostream>
    
    class Foo {
        public:
            ~Foo() {
                std::cerr << "Foo::~Foo()\n";
            };
    };
    
    class Bar : public Foo {
        public:
            ~Bar() {
                std::cerr << "Bar::~Bar()\n";
            };
    };
    
    int main() {
        Foo* bar = new Bar();
        delete bar;
    }
    

    This program only prints Foo::~Foo(), the destructor of Bar is not called. There is no warning or error message. Only partially destructed objects, with all the consequences. So make sure you spot this condition yourself when it arises (or make a point to add virtual ~Foo() = default; to each and every nonderived class you define.

如果这两个条件都不满足,就不需要定义析构函数,使用默认的构造函数即可。
现在看看你提供的示例代码:
当你的成员是一个指针时(无论是指针还是引用),编译器都不知道……
  • ……是否有其他指针指向这个对象。

  • ……该指针是指向一个对象还是数组。

因此,编译器无法推断出指针指向的内容是否需要销毁以及如何销毁。所以默认的析构函数永远不会销毁指针后面的任何东西。
这既适用于 brandname 也适用于 b。因此,你需要一个析构函数,因为你需要自己进行内存释放。或者,你可以为它们使用 RAII 容器(例如 std::string 和智能指针)。
这种推理不适用于 vec,因为这个变量直接包含了 std::vector<> 对象。因此,编译器知道必须销毁 vec,进而销毁其所有元素(毕竟它是一个 RAII 容器)。

3
如果您动态分配内存,并且希望只有在对象本身“终止”时才释放此内存,则需要具有析构函数。
对象可以通过两种方式“终止”:
1. 如果它是静态分配的,则被编译器隐式“终止”。 2. 如果它是动态分配的,则需要显式地调用`delete`来“终止”。
当使用一个基类类型的指针进行显示“终止”时,析构函数必须是`virtual`的。

2

我们知道,如果没有提供析构函数,编译器会自动生成一个。

这意味着除了简单的清理(如原始类型)之外,任何其他需要清理的内容都需要一个析构函数。

在许多情况下,在构造过程中进行动态分配或资源获取会有一个清理阶段。例如,动态分配的内存可能需要被删除。

如果该类表示硬件元素,则该元素可能需要关闭或放置到安全状态。

容器可能需要删除它们所有的元素。

总之,如果该类获取资源或需要特殊的清理(比如按照确定的顺序),那么就应该有一个析构函数。


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