浅谈 C++ 中的接口、纯虚函数与虚析构函数

浅谈 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;
};

所谓纯抽象类,指的是:

  • 只包含纯虚函数,不提供任何数据成员;
  • 不能直接被实例化------只能作为基类被继承,由派生类去实现其中的纯虚函数。

它的核心是三件事:

  1. 纯虚函数声明 :virtual QString getCommandCode() = 0;
  2. 抽象类性质:只要类中包含至少一个纯虚函数,该类就自动成为抽象类,无法实例化。
  3. 虚析构函数:必须提供(哪怕函数体是空的)

什么是纯虚函数?

普通虚函数允许派生类"选择性地"重写,而纯虚函数(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);

接口类的设计规范

  1. 纯虚函数规范
    • 使用 = 0 语法声明;
    • 派生类必须实现所有纯虚函数;
    • 一个接口可以包含多个纯虚函数。
  2. 构造与析构
    • 提供默认构造函数(即使为空);
    • 必须提供虚析构函数;
    • 接口类一般不应被拷贝,因此通常不需要 拷贝构造函数和赋值运算符(可用 = delete 显式禁止)。
  3. 实现方式
    • 通常只有头文件(.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; 用于类或方法
相关推荐
YYYing.8 天前
【设计模式系列 (九) 】装饰器模式
c++·后端·设计模式·装饰器模式·c/c++
YYYing.10 天前
【设计模式系列 (八) 】组合模式
c++·后端·设计模式·组合模式·c/c++
YYYing.13 天前
【设计模式系列 (五) 】原型模式
开发语言·后端·设计模式·原型模式·c/c++
YYYing.14 天前
【设计模式系列 (四) 】建造者模式
c++·后端·设计模式·建造者模式·c/c++
YYYing.25 天前
【C++进阶系列 (七)】C++ RTTI 深度剖析:从 typeid 到 dynamic_cast 的底层之旅
开发语言·c++·c/c++·rtti
捷瑞电子工坊1 个月前
VSCode的配置和一些报错问题
vscode·ubuntu·c/c++·编译报错·开发环境配置·初学者教程·json配置
2601_962296511 个月前
C/C++:编译全过程——预处理、编译、汇编、链接(包含预处理指令:宏定义,文件包括、条件编译)
汇编·预处理·链接·c/c++·编译过程
YYYing.3 个月前
【C++大型项目之高性能服务器框架 (九) 】协议抽象与Http服务器模块
服务器·c++·http·高性能·c/c++·后端框架
YYYing.3 个月前
【C++大型项目之高性能服务器框架 (七) 】Socket与ByteArray模块
服务器·c++·后端·框架·高性能·c/c++