跳转到内容
新建笔记

C++ 抽象类与虚析构:覆盖、所有权和定义

抽象类用于表达一组具体类型共同遵守的接口。判断它能否创建对象,要看是否仍有纯虚的最终覆盖函数;判断它能否通过基类指针销毁,要看析构接口。这是两个相关但不同的问题。

本文以 C++17 为例。三个完整程序可分别保存为独立的 .cpp 编译运行;末尾的错误用例只说明预期诊断,不应混入正常程序。

纯虚声明、定义与抽象性

跳转到“纯虚声明、定义与抽象性”

virtual void handle() = 0; 中的 = 0 是纯虚说明符,不是赋值,也不表示这个函数永远不能有函数体。它要求能够实例化的派生类型具有相应的非纯最终覆盖函数。

写法或继承情况含义
virtual void handle() = 0;声明纯虚函数,当前类保持抽象
为纯虚函数提供类外定义可供限定名调用,但不取消纯虚属性
中间派生类没有覆盖纯虚函数该派生类合法,仍然抽象,不能创建其完整对象
派生类覆盖全部仍为纯虚的函数在没有其他抽象性原因时,可以实例化
更下一层继承已有非纯覆盖函数无需每一层重新写一次覆盖,也可以是具体类
普通虚函数没有在派生类中重新覆盖可以继续使用从基类继承的实现

准确地说,只要一个虚函数的最终覆盖函数仍为纯虚函数,该类就是抽象类。这包括继承而来的纯虚函数,并不要求当前类源码里再次出现 = 0。抽象类不能作为完整对象实例化,但可以作为派生对象的基类子对象;可以声明它的指针和引用。C++ 抽象类规则

抽象类也可以有构造函数、数据成员、非虚成员函数和普通虚函数。基类构造函数负责初始化基类部分;“抽象”并不等于“所有成员都纯虚”或“不能保存状态”。

完整例子:保留接口、继承默认行为、显式复用纯虚定义

跳转到“完整例子:保留接口、继承默认行为、显式复用纯虚定义”

下面保留 AbstractTemplate → Intermediate → ConcreteTemplate 的结构,同时展示状态、普通成员和普通虚函数。InheritedTemplate 不再重复覆盖 handle(),仍然可以实例化。

#include <cassert>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>
class AbstractTemplate {
public:
explicit AbstractTemplate(std::string name) : name_(std::move(name)) {}
virtual ~AbstractTemplate() = default;
const std::string& name() const { return name_; } // 普通成员函数
virtual std::string label() const { return "default label"; }
virtual std::string handle() const = 0;
private:
std::string name_;
};
std::string AbstractTemplate::handle() const {
return "prepare";
}
class Intermediate : public AbstractTemplate {
public:
using AbstractTemplate::AbstractTemplate;
};
class ConcreteTemplate : public Intermediate {
public:
using Intermediate::Intermediate;
std::string handle() const override {
return AbstractTemplate::handle() + ": concrete";
}
};
class InheritedTemplate final : public ConcreteTemplate {
public:
using ConcreteTemplate::ConcreteTemplate;
};
class CustomizedTemplate final : public ConcreteTemplate {
public:
using ConcreteTemplate::ConcreteTemplate;
std::string label() const override { return "custom label"; }
};
static_assert(std::is_abstract_v<AbstractTemplate>);
static_assert(std::is_abstract_v<Intermediate>);
static_assert(!std::is_abstract_v<ConcreteTemplate>);
static_assert(!std::is_abstract_v<InheritedTemplate>);
int main() {
std::unique_ptr<AbstractTemplate> object =
std::make_unique<InheritedTemplate>("sample");
assert(object->name() == "sample");
assert(object->handle() == "prepare: concrete");
assert(object->label() == "default label");
CustomizedTemplate customized("another");
AbstractTemplate& reference = customized;
assert(reference.handle() == "prepare: concrete");
assert(reference.label() == "custom label");
}

object->handle() 是虚调用,选择实际对象的最终覆盖函数。派生实现里的 AbstractTemplate::handle() 使用限定名,直接调用指定的基类定义。纯虚函数可以有这样的类外定义,并非 C++11 才增加的例外;不能在同一条声明上同时写 = 0 和函数体。

覆盖时使用 override,让编译器检查签名。参数、成员函数的 const 或引用限定写错时,同名函数可能没有形成覆盖;加上 override 可尽早得到诊断。普通虚函数也不要求把函数体写在类内,可以先声明,再在类外定义。C++ 虚函数覆盖规则

考虑一个接口返回或保存 Base*,实际对象却是 Derived,最终执行 delete basePointer。在本文的普通 C++17 单对象删除场景中,基类必须有可访问的虚析构函数,才能正确按动态类型销毁派生对象。否则是未定义行为,不能把某次运行“只打印了基类析构”当作语言保证。C++ 删除表达式规则

选择取决于所有权接口,而不是“类使用了资源”“类可能被继承”或“对象分配在堆上”这几个条件中的任意一个。常用设计有两种:

接口的用途析构设计所有权位置
允许通过基类接口拥有并删除派生对象public virtual ~Base()可使用默认删除器的 std::unique_ptr<Base>
基类仅供借用、调用,不允许通过它删除protected ~Base(),可以非虚保持具体类型所有者,例如 std::unique_ptr<Derived>

这对应 C++ Core Guidelines 的“公开且虚,或受保护且非虚”建议。delete 接受指针;“通过基类引用删除”不是直接的删除表达式。不要因为有一个基类引用就从中取得地址并删除:对象还可能来自栈、其他所有者或不适用的分配方式。基类析构接口建议

完整例子:纯虚析构、100 个整数资源与多态容器

跳转到“完整例子:纯虚析构、100 个整数资源与多态容器”

析构函数可以是纯虚函数,让没有其他纯虚成员的基类保持抽象。不过只要实际创建相应类或其派生类的对象,就必须为这个虚析构提供定义。派生对象销毁时,基类部分也必须被销毁。

本例把原先手工 new int[100] / delete[] 的资源交给 std::vector<int> 管理,把多态裸指针容器改为 std::vector<std::unique_ptr<Base>>。这样资源与所有者一起释放。固定大小的记录器用于检查顺序,不在析构过程中分配日志字符串。

#include <array>
#include <cassert>
#include <cstddef>
#include <memory>
#include <string_view>
#include <type_traits>
#include <utility>
#include <vector>
struct Trace {
std::array<std::string_view, 16> events{};
std::size_t size = 0;
void add(std::string_view event) noexcept {
assert(size < events.size());
events[size++] = event;
}
};
class Base {
public:
explicit Base(Trace& trace) : trace_(trace) {}
virtual ~Base() = 0;
protected:
Trace& trace_;
};
Base::~Base() { trace_.add("Base"); }
class Resource {
public:
explicit Resource(Trace& trace) : trace_(trace), values_(100, 7) {}
~Resource() { trace_.add("Resource"); }
std::size_t size() const { return values_.size(); }
private:
Trace& trace_;
std::vector<int> values_;
};
class Derived final : public Base {
public:
explicit Derived(Trace& trace) : Base(trace), resource_(trace) {}
~Derived() override { trace_.add("Derived"); }
std::size_t resourceSize() const { return resource_.size(); }
private:
Resource resource_;
};
class ImplicitDerived final : public Base {
public:
using Base::Base; // 隐式析构函数已经覆盖 Base 的虚析构函数
};
static_assert(std::is_abstract_v<Base>);
static_assert(!std::is_abstract_v<ImplicitDerived>);
void expectDerivedDestruction(const Trace& trace, std::size_t begin) {
assert(trace.events[begin] == "Derived");
assert(trace.events[begin + 1] == "Resource");
assert(trace.events[begin + 2] == "Base");
}
int main() {
Trace trace; // 记录器比所有被记录对象活得更久
{
auto concrete = std::make_unique<Derived>(trace);
assert(concrete->resourceSize() == 100);
std::unique_ptr<Base> single = std::move(concrete);
assert(single != nullptr);
}
assert(trace.size == 3);
expectDerivedDestruction(trace, 0);
{
std::vector<std::unique_ptr<Base>> objects;
objects.push_back(std::make_unique<Derived>(trace));
objects.push_back(std::make_unique<Derived>(trace));
}
assert(trace.size == 9);
expectDerivedDestruction(trace, 3);
expectDerivedDestruction(trace, 6);
{
ImplicitDerived implicit(trace);
}
assert(trace.size == 10);
assert(trace.events[9] == "Base");
}

每个 Derived 先执行派生析构函数体,再销毁派生成员,最后进入基类析构。例子检查每个对象内部的顺序;两个容器元素的记录相同,没有依赖 vector 以某种指定顺序销毁元素。更一般地,同一类的非静态数据成员按构造完成的逆序销毁,之后销毁基类子对象。C++ 析构顺序

ImplicitDerived 没有手写析构函数仍然是具体类。编译器隐式声明的派生析构函数可以覆盖基类的纯虚析构,无需为了“实现纯虚析构”添加一个空函数体。

std::unique_ptr<Base> 的默认删除器最终仍然通过 Base* 删除对象;它能自动管理所有权,不能修复非虚基类析构造成的错误。虚析构也不使 Base* 指向派生对象数组后的 delete[] 合法;本文的多态容器保存的是分别分配的单个对象。

声明、默认定义与链接错误

跳转到“声明、默认定义与链接错误”
析构写法是否已经提供定义
virtual ~Base();只有声明;不是“自动生成一个默认定义”
virtual ~Base() = default;显式默认化定义;基类其他条件合法时可使用
virtual ~Base() {}有函数体的定义;成员和基类仍会正常析构
virtual ~Base() = 0;纯虚声明;实际创建派生对象仍须提供定义
Base::~Base() = default;类外默认化定义,可用于此前已声明的纯虚析构

如果不需要记录器,上一例的纯虚析构定义就可以写为 Base::~Base() = default;。把这种类外定义放进会被多个翻译单元包含的头文件时,应写为 inline Base::~Base() = default;,或把非 inline 定义放进单独的 .cpp 文件。

普通纯虚函数在不显式调用基类版本时,通常只需声明;纯虚析构会参与派生对象的销毁,不能套用“纯虚函数都不需要定义”。缺少被需要的定义往往在链接阶段暴露,与“抽象类不能实例化”的编译阶段错误不同。

完整例子:借用接口与具体类型所有者

跳转到“完整例子:借用接口与具体类型所有者”

如果基类只是借用接口,可用受保护的非虚析构阻止外部通过它删除。下面的 BorrowedInterface& 只负责调用;真正的所有者保留 ConcreteOwner 类型,并通过公开的具体析构销毁对象。

#include <cassert>
#include <memory>
#include <type_traits>
class BorrowedInterface {
public:
virtual int value() const = 0;
protected:
~BorrowedInterface() = default;
};
class ConcreteOwner final : public BorrowedInterface {
public:
~ConcreteOwner() = default;
int value() const override { return 42; }
};
static_assert(!std::has_virtual_destructor_v<BorrowedInterface>);
static_assert(!std::is_destructible_v<BorrowedInterface>);
static_assert(std::is_destructible_v<ConcreteOwner>);
int inspect(const BorrowedInterface& object) {
return object.value();
}
int main() {
auto owner = std::make_unique<ConcreteOwner>();
assert(inspect(*owner) == 42);
}

这里派生析构不能标记 override,因为基类析构不是虚函数。借用引用也不会延长对象寿命,不能在所有者销毁后继续使用。

下列结果已用独立小程序检查。编译失败与链接失败分开验证,没有运行任何未定义行为用例。

故意写错的内容预期阶段与原因
创建含未实现纯虚函数的基类或中间类对象编译失败:类型仍然抽象
基类为 handle() const,派生写 handle() override编译失败:签名不形成覆盖
在类内把 = 0 与函数体写在同一声明上编译失败:纯虚说明符不能这样与定义组合
外部通过 BorrowedInterface* 执行 delete编译失败:析构函数受保护
声明普通虚析构但省略所需定义,创建派生对象源文件可编译,链接失败:缺失所需定义
声明纯虚析构但省略其定义,创建派生对象源文件可编译,链接失败:缺失基类析构定义

最后还要约束调用时机:构造和析构期间的虚调用不会进入尚未构造或已经销毁的更派生部分。从抽象基类构造或析构过程中直接或间接虚调用其纯虚函数会产生未定义行为;给纯虚函数补一个类外定义也不能使这种虚调用合法。需要共享初始化步骤时,使用行为明确的非虚辅助函数。构造与析构期间的成员调用、纯虚调用边界

相关主题:继承和多态、指针与引用。本篇合并五篇历史笔记,并保留纯虚声明、抽象类成员、分层继承、纯虚析构、单对象和多态容器资源释放的核心问题。