浅谈 C++ 中的接口、纯虚函数与虚析构函数
很多从 Java / C# 转过来写 C++ 的都会有一个疑问:C++ 没有
interface关键字,它的"接口"是怎么实现的?为什么反复强调"基类一定要写虚析构函数"?
C++ 里的"接口"到底是什么
在C++中,接口是通过纯虚类(Pure Abstract Class)实现的,这是一种只包含纯虚函数而没有数据成员的特殊抽象类。
cpp
class CommandAccept {
public:
CommandAccept() {} // 基类需要构造函数体
virtual ~CommandAccept() {} // 虚析构函数,必须要有函数体!
virtual QString getCommandCode() = 0; // 纯虚函数
virtual Response process(Session* session, Request request) = 0;
};
所谓纯抽象类,指的是:
- 只包含纯虚函数,不提供任何数据成员;
- 不能直接被实例化------只能作为基类被继承,由派生类去实现其中的纯虚函数。
它的核心是三件事:
- 纯虚函数声明 :
virtual QString getCommandCode() = 0; - 抽象类性质:只要类中包含至少一个纯虚函数,该类就自动成为抽象类,无法实例化。
- 虚析构函数:必须提供(哪怕函数体是空的)
什么是纯虚函数?
普通虚函数允许派生类"选择性地"重写,而纯虚函数(pure virtual function)在基类中只有声明、没有强制实现 ,并在声明末尾加上 = 0。它相当于一份契约:凡继承该类的派生类,都必须给出这个函数的具体实现,否则派生类自己也会变成抽象类、同样无法实例化。
cpp
class Shape {
public:
// = 0 告诉编译器:这是纯虚函数,基类不提供实现,派生类必须重写它。
virtual double area() const = 0;
};
Java 接口有 default 默认的实现,在C++中也可以
virtual void f(); 是一个有实现的普通虚函数 (基类可以给出默认行为);而 virtual void f() = 0; 才是纯虚函数 。= 0 是一个特殊的语法标记,含义是"此函数在本类中是抽象的、待实现的",并不是"返回值等于 0"。
只要含纯虚函数的类就不能被实例化
一旦类里出现纯虚函数,它就升级为抽象类(abstract class),编译器会禁止你创建它的对象:
cpp
Shape s; // 编译错误:Shape 是抽象类,无法实例化
Shape* p = new Shape(); // 同样编译错误
Shape* q = new Circle(); // 正确:Circle 实现了 area(),是具体类
抽象类的唯一用途是被继承,作为多态的"上层抽象"存在。
一个抽象类示例
下面是一份基于 Qt 的典型接口定义,它把"接口三件套"集中体现了出来:
cpp
class CommandAccept {
public:
CommandAccept() {} // 基类需要一个构造函数体
virtual ~CommandAccept() {} // 虚析构函数,必须要有函数体!
virtual QString getCommandCode() = 0; // 纯虚函数
virtual Response process(Session* session, Request request) = 0; // 纯虚函数
};
对应的派生类必须把两个纯虚函数都实现出来,否则它自己也是抽象类:
cpp
class CommandAcceptRecord : public CommandAccept {
public:
QString getCommandCode() override { // override 明确表达"我在重写"
return "RECORD";
}
Response process(Session* session, Request request) override {
// ......真正的业务逻辑......
return Response::ok();
}
};
调用方则永远只面向接口编程,完全不关心背后是哪个具体实现:
cpp
// 面向接口,而非面向实现:这正是多态的价值所在
CommandAccept* handler = new CommandAcceptRecord();
Response resp = handler->process(session, request);
接口类的设计规范
- 纯虚函数规范
- 使用
= 0语法声明; - 派生类必须实现所有纯虚函数;
- 一个接口可以包含多个纯虚函数。
- 使用
- 构造与析构
- 提供默认构造函数(即使为空);
- 必须提供虚析构函数;
- 接口类一般不应被拷贝,因此通常不需要 拷贝构造函数和赋值运算符(可用
= delete显式禁止)。
- 实现方式
- 通常只有头文件(
.h),没有实现文件(.cpp); - 纯虚函数不应在基类中提供实现(少数特殊情况除外)。
- 通常只有头文件(
必须要有虚析构函数!
cpp
CommandAccept* crecord = new CommandAcceptRecord();
delete crecord; // 如果基类析构函数非虚,将不会调用派生类析构函数!
内存泄漏的根因:析构函数默认不是多态的
在 C++ 里,普通的成员函数(包括析构函数)是通过"静态绑定"决定调用哪一个的 。对于 delete crecord; 这行代码,编译器只看指针 crecord 的静态类型 (这里是基类 CommandAccept*),而不会去看它实际指向的对象类型。
- 如果基类析构函数不是
virtual:编译器只会调用CommandAccept::~CommandAccept(),派生类CommandAcceptRecord的析构函数根本不会被触发 ,派生类里new出来的资源、打开的文件、注册的回调......全部泄漏。在标准里,通过基类指针delete一个非虚析构的对象本身就是未定义行为(undefined behavior)。 - 如果基类析构函数是
virtual:delete会经由对象的**虚函数表(vtable)**找到实际类型的析构函数,先执行派生类析构、再自动向上执行基类析构------这才是正确的"自下而上"完整销毁顺序。
一个直观的对比示例
cpp
#include <iostream>
using namespace std;
struct BaseBad {
BaseBad() { cout << "BaseBad 构造\n"; }
~BaseBad() { cout << "BaseBad 析构\n"; } // 非虚!
};
struct DerivedBad : BaseBad {
int* data;
DerivedBad() : data(new int[1024]) { cout << "DerivedBad 构造\n"; }
~DerivedBad() { delete[] data; cout << "DerivedBad 析构\n"; }
};
struct BaseGood {
BaseGood() { cout << "BaseGood 构造\n"; }
virtual ~BaseGood() { cout << "BaseGood 析构\n"; } // 虚析构
};
struct DerivedGood : BaseGood {
int* data;
DerivedGood() : data(new int[1024]) { cout << "DerivedGood 构造\n"; }
~DerivedGood() { delete[] data; cout << "DerivedGood 析构\n"; }
};
int main() {
cout << "--- 非虚析构 ---\n";
BaseBad* b = new DerivedBad();
delete b; // 只打印 BaseBad 析构,DerivedBad 的资源泄漏
cout << "--- 虚析构 ---\n";
BaseGood* g = new DerivedGood();
delete g; // 先 DerivedGood 析构,再 BaseGood 析构,资源正确释放
}
运行结果:
--- 非虚析构 ---
BaseBad 构造
DerivedBad 构造
BaseBad 析构 <-- 派生类析构缺失,data 泄漏
--- 虚析构 ---
BaseGood 构造
DerivedGood 构造
DerivedGood 析构 <-- 派生类先析构,资源被释放
BaseGood 析构
可以清楚看到:只要可能用基类指针管理派生类对象,基类析构就必须是 virtual。
默认实现 =default
如果不需要在析构里做任何清理,C++11 起可以用 = default 明确表示"我就是要编译器生成的默认析构",语义比空的 {} 更清晰:
cpp
virtual ~CommandAccept() = default; // C++11 风格,明确使用默认实现
"不是基类"的类要不要写虚析构?
判断准则是 Rule of Three / Rule of Five:
- 一个类是否会被当作多态基类 (即存在派生类、且可能通过基类指针
delete)?- 会 → 析构函数必须
virtual。 - 不会 (不打算被继承,或不通过基类指针销毁)→ 不必加
virtual。因为每个含虚函数的类都会让对象多带一个 vptr 指针,带来体积与间接调用开销,不该无脑给所有类都加虚析构。
- 会 → 析构函数必须
C++11中的 override 与 final
C++11 引入了 override 和 final 能让你在编译期就抓到多态写错的 bug。
override
不用 override 时,如果你把参数类型或 const 写错,函数会被当成"派生类里新加的一个函数",悄悄躲过了多态,非常类似Java的@Override注解:
cpp
class Shape {
public:
virtual double area() const = 0;
};
class Circle : public Shape {
public:
double r;
// double area() const override { ... } // 正确:签名与基类一致,校验通过
double area() /* 漏写了 const */ override { return 3.14 * r * r; } // 编译报错!
// 没有 override 时,上面这行会"成功编译",但 area() 永远不会被多态调用,
// Circle 仍然是抽象类 ------ 这种 bug 极难排查。
};
加上 override 后,一旦签名和基类对不上,编译器立刻报错,而不是留下一个静默的逻辑漏洞。
final
final 既可用于虚函数(禁止重写),也可用于类(禁止继承),并且有助于编译器做去虚化(devirtualization)优化。类似Java的final修饰符:
cpp
class FinalShape : public Shape {
public:
double area() const final { return 0.0; } // 该函数不允许再被子类重写
};
// 若把类标记为 final:class Sealed final : public Shape { ... };
// 那么 class XXX : public Sealed { ... }; // 此处会编译报错,禁止继承
最后总结一下
- 接口 在 C++ 中由纯抽象类实现:只含纯虚函数、不能实例化,是多态的抽象上层。
- 纯虚函数 用
= 0声明,是基类向派生类提出的强制契约;含纯虚函数的类即为抽象类。 - 虚析构函数是面向接口编程的硬性要求 :凡可能通过基类指针销毁派生对象,基类析构必须
virtual(推荐= default),否则派生类析构被跳过、资源泄漏,且属于未定义行为。 override/final是 C++11 提供的编译期安全网,能及早暴露"假重写"等问题,接口实现中应养成默认写override的习惯。
| 概念 | 关键点 | 语法示例 | 注意事项 |
|---|---|---|---|
| 接口类 | 纯虚函数集合 | class I { virtual void f() = 0; }; |
不能被实例化 |
| 纯虚函数 | 强制派生类实现 | virtual void f() = 0; |
可提供默认实现(罕见) |
| 虚析构函数 | 确保正确析构 | virtual ~Base() = default; |
必须提供,即使为空 |
| override | 明确重写意图 | void f() override; |
C++11引入,提高安全性 |
| final | 禁止进一步重写 | virtual void f() final; |
用于类或方法 |