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 前缀,比如 IRenderer、IDatabase。这样一眼就能看出这是个接口,不是具体实现。当然也有人用 able 后缀,比如 Drawable,看团队风格。
8.4 纯虚函数在vtable中的表现
纯虚函数在虚函数表(vtable)里是怎么存的?其实和普通虚函数一样——vtable里会有一个对应的槽位。区别在于:
- 普通虚函数的槽位指向实际的函数地址
- 纯虚函数的槽位指向一个特殊函数(通常是
__cxa_pure_virtual)
如果某个派生类没有实现纯虚函数,那它的vtable里对应的槽位仍然指向那个特殊函数。一旦你调用它,程序就会触发一个纯虚函数调用错误,直接崩溃。
我曾经调试过一个bug:某个派生类忘记实现纯虚函数,但代码里通过基类指针调用了它。结果程序在运行时莫名其妙崩溃,堆栈指向 __cxa_pure_virtual。嗯,从那以后我写派生类时都会检查一遍是否实现了所有纯虚函数。
下面这张图展示了纯虚函数在vtable中的布局:
从这张图可以看得很清楚:基类Shape的vtable里,纯虚函数 area() 和 draw() 的槽位指向一个特殊函数(__cxa_pure_virtual),而派生类Circle实现了它们之后,vtable里就指向了实际的函数地址。
关键理解:纯虚函数在vtable中占一个槽位,但指向的是“错误处理函数”。派生类实现后,这个槽位会被覆盖为实际函数地址。所以从vtable的角度看,纯虚函数和普通虚函数没有本质区别——都是函数指针而已。
8.5 避坑指南
我在项目中遇到过几个和纯虚函数相关的坑,分享给你:
- 不要在构造函数或析构函数中调用纯虚函数——因为此时派生类还没构造好或已经析构,调用会触发纯虚函数错误。我曾经在基类构造函数里调了一个纯虚函数,结果程序一启动就崩,查了半天才发现。
- 纯虚析构函数需要提供实现——虽然析构函数可以声明为纯虚函数,但你必须给它一个实现,因为派生类析构时会调用基类析构函数。这个比较反直觉,我第一次遇到时也懵了。
- 接口类记得声明虚析构函数——否则通过基类指针删除派生类对象时,不会调用派生类析构函数,导致资源泄漏。这个坑我踩过不止一次。
我的建议:写接口类时,养成好习惯——把析构函数声明为 virtual ~ILogger() = default;。这样既保证了正确析构,又不用写空实现。干净利落。
纯虚函数和抽象类是C++实现多态和接口分离的核心机制。理解它们,你就能写出更灵活、更可扩展的代码。下一章我们会深入虚函数的高级话题,包括虚继承和RTTI,敬请期待。
公众号:蓝海资料掘金营,微信deep3321