8. 纯虚函数与抽象类:接口设计的基石

纯虚函数和抽象类,说白了就是C++里用来定义“接口”的机制。我刚开始学的时候,觉得这东西不就是个没实现的函数嘛,有啥特别的?后来在项目中吃过亏才明白——它其实是设计模式的核心支撑。

8.1 纯虚函数的定义

纯虚函数,就是在基类中声明但不实现的虚函数。语法很简单:在函数声明后面加上 = 0

class Shape {
public:
    virtual double area() const = 0;  // 纯虚函数
    virtual void draw() const = 0;    // 纯虚函数
};

注意这个 = 0,它不是赋值,而是一个特殊标记。告诉编译器:“这个函数我不打算在基类里实现,派生类必须自己搞定。”

核心要点:

  • 纯虚函数只有声明,没有定义(但C++允许给纯虚函数提供定义,不过很少用)
  • 派生类必须实现所有纯虚函数,否则派生类也会变成抽象类
  • 纯虚函数让基类成为“接口”,而不是“实现”

8.2 抽象类的概念

包含至少一个纯虚函数的类,就是抽象类。抽象类不能实例化——你不能写 Shape s;,编译器会报错。

为什么会这样?你想想看,一个类里有没实现的函数,你创建它的对象有啥意义?调用那个函数时程序就崩了。所以C++干脆从语法层面禁止你这么做。

// 错误:不能实例化抽象类
// Shape s;  // 编译错误

// 正确:用派生类
class Circle : public Shape {
public:
    double area() const override { return 3.14159 * r * r; }
    void draw() const override { /* 画圆 */ }
private:
    double r = 1.0;
};

Circle c;  // 没问题

注意:抽象类可以有普通成员函数和成员变量。我见过有人以为抽象类里只能有纯虚函数,其实不是。你可以在抽象类里放一些公共的辅助函数,只要保证至少有一个纯虚函数就行。

8.3 接口设计模式

纯虚函数最经典的应用,就是实现“接口设计模式”。在C++里,我们通常用只包含纯虚函数的类来模拟其他语言(比如Java)的interface。

// 定义一个接口
class ILogger {
public:
    virtual ~ILogger() = default;
    virtual void log(const std::string& msg) = 0;
    virtual void flush() = 0;
};

// 实现1:控制台日志
class ConsoleLogger : public ILogger {
public:
    void log(const std::string& msg) override {
        std::cout << "[LOG] " << msg << std::endl;
    }
    void flush() override {
        std::cout << std::flush;
    }
};

// 实现2:文件日志
class FileLogger : public ILogger {
public:
    void log(const std::string& msg) override {
        file_ << msg << std::endl;
    }
    void flush() override {
        file_ << std::flush;
    }
private:
    std::ofstream file_{"log.txt"};
};

这种设计的好处是啥?调用方只需要依赖 ILogger 接口,不需要知道具体是哪种日志实现。我在项目中经常用这种模式来做插件系统——定义好接口,别人写插件时只要实现接口就行,完全解耦。

个人习惯:接口类命名我习惯加 I 前缀,比如 IRendererIDatabase。这样一眼就能看出这是个接口,不是具体实现。当然也有人用 able 后缀,比如 Drawable,看团队风格。

8.4 纯虚函数在vtable中的表现

纯虚函数在虚函数表(vtable)里是怎么存的?其实和普通虚函数一样——vtable里会有一个对应的槽位。区别在于:

  • 普通虚函数的槽位指向实际的函数地址
  • 纯虚函数的槽位指向一个特殊函数(通常是 __cxa_pure_virtual

如果某个派生类没有实现纯虚函数,那它的vtable里对应的槽位仍然指向那个特殊函数。一旦你调用它,程序就会触发一个纯虚函数调用错误,直接崩溃。

我曾经调试过一个bug:某个派生类忘记实现纯虚函数,但代码里通过基类指针调用了它。结果程序在运行时莫名其妙崩溃,堆栈指向 __cxa_pure_virtual。嗯,从那以后我写派生类时都会检查一遍是否实现了所有纯虚函数。

下面这张图展示了纯虚函数在vtable中的布局:

纯虚函数在vtable中的布局 Shape 类对象 vptr → vtable Shape 的 vtable area() → __cxa_pure_virtual draw() → __cxa_pure_virtual ~Shape() → 实际析构函数地址 其他虚函数... → 实际函数地址 Circle 类对象 vptr → vtable Circle 的 vtable area() → Circle::area() draw() → Circle::draw() ~Circle() → 实际析构函数地址

从这张图可以看得很清楚:基类Shape的vtable里,纯虚函数 area()draw() 的槽位指向一个特殊函数(__cxa_pure_virtual),而派生类Circle实现了它们之后,vtable里就指向了实际的函数地址。

关键理解:纯虚函数在vtable中占一个槽位,但指向的是“错误处理函数”。派生类实现后,这个槽位会被覆盖为实际函数地址。所以从vtable的角度看,纯虚函数和普通虚函数没有本质区别——都是函数指针而已。

8.5 避坑指南

我在项目中遇到过几个和纯虚函数相关的坑,分享给你:

  • 不要在构造函数或析构函数中调用纯虚函数——因为此时派生类还没构造好或已经析构,调用会触发纯虚函数错误。我曾经在基类构造函数里调了一个纯虚函数,结果程序一启动就崩,查了半天才发现。
  • 纯虚析构函数需要提供实现——虽然析构函数可以声明为纯虚函数,但你必须给它一个实现,因为派生类析构时会调用基类析构函数。这个比较反直觉,我第一次遇到时也懵了。
  • 接口类记得声明虚析构函数——否则通过基类指针删除派生类对象时,不会调用派生类析构函数,导致资源泄漏。这个坑我踩过不止一次。

我的建议:写接口类时,养成好习惯——把析构函数声明为 virtual ~ILogger() = default;。这样既保证了正确析构,又不用写空实现。干净利落。

纯虚函数和抽象类是C++实现多态和接口分离的核心机制。理解它们,你就能写出更灵活、更可扩展的代码。下一章我们会深入虚函数的高级话题,包括虚继承和RTTI,敬请期待。


公众号:蓝海资料掘金营,微信deep3321