什么时候需要定义析构函数?

When do we need to define destructors?

本文关键字:析构函数 定义 什么时候      更新时间:2023-10-16

我读到当我们有指针成员和定义基类时需要定义析构函数,但我不确定我是否完全理解。我不确定的一件事是定义默认构造函数是否无用,因为默认情况下总是给我们一个默认构造函数。此外,我不确定我们是否需要定义默认构造函数来实现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?
}

三法则与零法则

处理资源的好方法是"三法则"(现在是"五法则"),但最近另一个规则正在接管:"零法则"。

这个想法是,资源管理应该留给其他特定的类,但您应该真正阅读这篇文章。

在这方面,标准库提供了一组很好的工具,如:std::vector, std::string, std::unique_ptrstd::shared_ptr,有效地消除了自定义析构函数,移动/复制构造函数,移动/复制赋值和默认构造函数的需要。

如何将其应用到您的代码

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

字符串

如果您注意到brandname实际上是一个"动态字符串",那么标准库不仅可以将您从c风格的字符串中拯救出来,还可以使用std::string自动管理字符串的内存。

动态分配的B

第二个资源似乎是一个动态分配的B。如果你动态分配的原因不是"我想要一个可选成员"你一定要使用std::unique_ptr,它会自动处理资源(在适当的时候解除分配)。另一方面,如果您希望它是可选成员,则可以使用std::optional

b

集合

最后一个资源只是一个B s数组。这很容易与std::vector管理。标准库允许您根据不同的需求从各种不同的容器中进行选择;仅举其中一些:std::deque, std::liststd::array

结论

把所有的建议加起来,你会得到:

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

既安全又可读。

正如@nonsensickle所指出的,这个问题太宽泛了……所以我要尽我所能来解决这个问题…

重新定义析构函数的第一个原因是在三规则中,这是Scott Meyers Effective c++中第6项的一部分,但不是全部。三原则表示,如果您重新定义析构函数、复制构造函数或复制赋值操作,则意味着您应该重写所有这三个操作。原因是,如果您必须为其中一个版本重写自己的版本,那么编译器的默认值将不再对其余版本有效。 另一个例子是Scott Meyers在Effective c++ 中指出的

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

然后他继续

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

他对virtual的析构函数的结论是

底线是,毫无理由地将所有析构函数声明为虚和从不将它们声明为虚一样错误。实际上,许多人这样总结这种情况:当且仅当类包含至少一个虚函数时,在该类中声明虚析构函数。

如果不符合规则三的情况,那么也许你的对象中有一个指针成员,也许你在对象中为它分配了内存,那么你需要在析构函数中管理这个内存,这是他的书中的第6项

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

有两种情况需要定义析构函数:

  1. 当你的对象被析构时,你需要执行一些动作,而不是析构所有的类成员。

    这些操作中的绝大多数曾经是释放内存,根据RAII原则,这些操作已经转移到RAII容器的析构函数中,由编译器负责调用。但是这些操作可以是任何操作,例如关闭文件,或将一些数据写入日志或... .如果您严格遵循RAII原则,您将为所有这些其他操作编写RAII容器,因此只有RAII容器定义了析构函数。

  2. 当你需要通过基类指针析构对象时。

    当需要这样做时,必须在基类中将析构函数定义为virtual。否则,派生析构函数将不会被调用,不管它们是否定义,也不管它们是否为virtual。下面是一个例子:
    #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;
    }
    

    这个程序只输出Foo::~Foo(),没有调用Bar的析构函数。没有警告或错误消息。只有部分销毁的对象,以及所有的后果。因此,当出现这种情况时,请确保您自己发现它(或者注意将virtual ~Foo() = default;添加到您定义的每个非派生类中)。

如果这两个条件都不满足,则不需要定义析构函数,默认构造函数就足够了。


现在进入示例代码:
当你的成员是指向某物的指针时(无论是指针还是引用),编译器不知道…

  • …是否有指向该对象的其他指针

因此,编译器无法推断是否或如何析构指针所指向的对象。因此,默认析构函数永远不会析构指针后面的任何内容。

这对brandnameb都适用。因此,您需要一个析构函数,因为您需要自己执行解分配。或者,您可以为它们使用RAII容器(std::string和智能指针变体)。

这个推理不适用于vec,因为这个变量在对象中直接包含了一个std::vector<> 。因此,编译器知道vec必须被析构,这反过来又会析构它的所有元素(毕竟它是一个RAII容器)。

如果你动态分配内存,并且你希望这些内存只在对象本身"终止"时才被释放,那么你需要有一个析构函数。

对象可以通过两种方式"终止":

  1. 如果它是静态分配的,那么它被隐式地"终止"(由编译器)。
  2. 如果它是动态分配的,那么它被显式地"终止"(通过调用delete)。

当使用基类类型的指针显式"终止"时,析构函数必须为virtual

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

这意味着除了简单的清理之外的任何东西,比如基本类型,都需要析构函数。

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

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

容器可能需要删除其所有元素。

总而言之,如果类获取资源或需要专门的清理(比如以确定的顺序),就应该有析构函数。