虚析构函数、虚赋值运算符以及虚函数重写

虚析构函数

尽管 C++ 在你未提供析构函数时会为类提供默认析构函数,但有时你可能需要自己提供析构函数(特别是当类需要释放内存时)。如果涉及继承,你应该始终将析构函数声明为虚函数。考虑以下示例:

#include <iostream>
class Base
{
public:
    ~Base() // 注意:未声明为虚
    {
        std::cout << "Calling ~Base()\n";
    }
};

class Derived: public Base
{
private:
    int* m_array {};

public:
    Derived(int length)
      : m_array{ new int[length] }
    {
    }

    ~Derived() // 注意:未声明为虚(编译器可能会警告)
    {
        std::cout << "Calling ~Derived()\n";
        delete[] m_array;
    }
};

int main()
{
    Derived* derived { new Derived(5) };
    Base* base { derived };

    delete base;

    return 0;
}

注意:如果你编译上述示例,编译器可能会警告你析构函数未声明为虚(这是本例的意图)。你可能需要禁用将警告视为错误的编译器选项才能继续。

由于 base 是指向 Base 的指针,当删除 base 时,程序会检查 Base 的析构函数是否为虚函数。它不是,因此程序假设只需要调用 Base 的析构函数。从输出可以看出:

Calling ~Base()

然而,我们真正希望的是删除操作能够调用 Derived 的析构函数(它会进一步调用 Base 的析构函数),否则 m_array 将不会被释放。我们通过将 Base 的析构函数声明为虚函数来实现这一点:

#include <iostream>
class Base
{
public:
    virtual ~Base() // 注意:声明为虚
    {
        std::cout << "Calling ~Base()\n";
    }
};

class Derived: public Base
{
private:
    int* m_array {};

public:
    Derived(int length)
      : m_array{ new int[length] }
    {
    }

    virtual ~Derived() // 注意:声明为虚
    {
        std::cout << "Calling ~Derived()\n";
        delete[] m_array;
    }
};

int main()
{
    Derived* derived { new Derived(5) };
    Base* base { derived };

    delete base;

    return 0;
}

现在程序的输出结果为:

Calling ~Derived()
Calling ~Base()

规则: 在涉及继承的情况下,你应该将析构函数声明为虚函数。

与普通虚成员函数类似,如果基类函数是虚函数,那么所有派生类的重写版本都将被视为虚函数,无论它们是否被显式声明为虚函数。因此,没有必要仅仅为了标记为虚函数而创建一个空的派生类析构函数。

如果你希望基类有一个空的虚析构函数,可以这样定义:

virtual ~Base() = default; // 生成一个默认的虚析构函数

虚赋值运算符

可以将赋值运算符声明为虚函数。然而,与析构函数的情况(虚化总是好主意)不同,虚化赋值运算符会引发一系列复杂问题,涉及一些超出本教程范围的高级主题。因此,为了简单起见,我们建议你暂时保留非虚的赋值运算符。

忽略虚化

在极少数情况下,你可能希望忽略函数的虚化。例如,考虑以下代码:

#include <string_view>
class Base
{
public:
    virtual ~Base() = default;
    virtual std::string_view getName() const { return "Base"; }
};

class Derived: public Base
{
public:
    virtual std::string_view getName() const { return "Derived"; }
};

在某些情况下,你可能希望指向 Derived 对象的 Base 指针调用 Base::getName() 而不是 Derived::getName()。为此,可以使用作用域解析运算符:

#include <iostream>
int main()
{
    Derived derived {};
    const Base& base { derived };

    // 调用 Base::getName() 而不是虚化的 Derived::getName()
    std::cout << base.Base::getName() << '\n';

    return 0;
}

你可能不会经常使用这种方法,但了解其可行性是很有帮助的。

是否应该将所有析构函数声明为虚函数?

这是新程序员常问的一个问题。正如前面的示例所示,如果基类的析构函数未被声明为虚函数,那么程序在删除指向派生对象的基类指针时可能会导致内存泄漏。为了避免这种情况,可以将所有析构函数都声明为虚函数。但你应该这样做吗?

简单回答是“是”,这样你就可以将任何类用作基类——但这样做会带来性能开销(每个类实例都会增加一个虚表指针)。因此,你需要权衡这种开销以及你的意图。

我们建议如下:

  • 如果一个类不是被设计为基类,那么通常最好没有虚成员函数和虚析构函数。该类仍然可以通过组合的方式使用。
  • 如果一个类被设计为基类,或者具有任何虚函数,那么它应该始终有一个虚析构函数。

如果决定一个类不应被继承,那么下一个问题是是否可以强制执行这一点。

传统智慧(最初由备受尊敬的 C++ 大师 Herb Sutter 提出)建议避免非虚析构函数导致的内存泄漏问题,方法是:“基类的析构函数应该是公共的且虚的,或者是受保护的且非虚的。”具有受保护析构函数的基类不能使用基类指针进行删除,这防止了通过基类指针删除派生类对象。

不幸的是,这也阻止了基类析构函数的公共使用。这意味着:

  • 我们不应该动态分配基类对象,但我们没有常规方法来删除它们(有一些非常规的解决方法,但很糟糕)。
  • 我们甚至不能静态分配基类对象,因为当它们超出作用域时,析构函数是不可访问的。

换句话说,使用这种方法,为了使派生类安全,我们必须使基类本身几乎无法单独使用。

现在语言中引入了 final 限定符,我们的建议如下:

  • 如果你希望类被继承,确保析构函数是虚的且公共的。
  • 如果你不希望类被继承,将类标记为 final。这将阻止其他类从它继承,而不会对类本身施加其他使用限制。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"