尧图精选

C++纯虚函数与抽象类:用契约式编程设计接口规范

🕒 发布时间:2026/10/1 20:29:58 📁 来源:尧图网络
聊到 C 的纯虚函数和抽象类不少人的第一反应是“面试的基础题”含有纯虚函数的类叫抽象类抽象类不能实例化派生类必须重新实现所有纯虚函数然后才能变成具体类。这套定义背下来不难但我在做代码审查时发现很多人把抽象类用成了“虚函数的加强版”——基类里堆一堆带默认实现的虚函数偶尔补一两个纯虚函数撑门面派生类想改哪个改哪个不想改就默默继承默认的。这完全偏离了方向。纯虚函数和抽象类的真实用途是契约式编程Design by Contract。抽象类不是帮你复用代码的它是帮你定义规则的纯虚函数不是“等着被重写”的空函数它是双方必须遵守的合同条款。把规则写进类型系统让编译器替你把关这一点想通了你才算是真正开始用 C 来“设计”而不是在“堆代码”。这篇文章适合几类人正在刷 C 八股、准备面试的同学在维护一个多态体系越改越乱的老项目的工程师以及已经知道“抽象类不能实例化”但不太清楚它到底有什么用的进阶学习者。我会从契约式编程的视角把抽象类、纯虚函数、虚析构、重写检查这几个点串起来讲最后用一个日志系统的完整例子收尾。1. 抽象类和纯虚函数先分清“能力复用”和“规范统一”1.1 普通继承解决的是“复用”抽象类解决的是“规范”先看普通继承。假设有一个 Animal 基类里面有 eat() 的实现Dog 和 Cat 继承之后直接用不需要自己再写一遍。这种做法非常常见目标是“少写重复代码”也就是实现复用。但复用和规范是两回事。如果需求变成所有动物必须会叫而叫声又各不相同怎么办如果我在 Animal 里写一个 makeSound() 的默认实现比如输出“咕咕咕”那 Dog 继承后一旦忘记重写编译照常通过运行起来一条狗发出“咕咕咕”的声音这在业务代码里就是一颗潜伏的雷。问题不出在逻辑上而出在“忘记重写”没有被编译器拦截。而如果我把 makeSound() 声明成纯虚函数写成virtual void makeSound() 0情况立刻反转Dog 如果不实现 makeSound()它自己也会自动变成抽象类连对象都创建不了。编译器的报错会在你“忘记履约”的第一时间出现。这才是抽象类真正的定位它不提供答案它只出题它不负责实现它只负责规定“你必须有这个能力”。普通继承让你复用别人的代码抽象类逼你遵守共同的约定。1.2 纯虚函数是“法律条文”不是“教学教程”我经常用一个类比来解释虚函数和纯虚函数的区别。普通虚函数像一份教程基类把大概的实现写好了派生类觉得不合适可以重写重写不重写都行继承默认的也可以它是“可选的参考”。纯虚函数像一条法规它定义了“你必须要有这个行为”但“具体怎么做”完全交给派生类它是“必填的合同”。“契约式编程”这个思想最早由 Bertrand Meyer 在他的 Eiffel 语言中系统提出后来被各门语言以各种形式借鉴。契约的本质是调用方和使用方之间存在一份明确的共识——调用方承诺满足某些条件被调用方承诺返回某些结果。C 语言本身没有专门的关键字来表达契约C20 的 contracts 功能讨论了很久最终没能进标准但我们可以用抽象类和纯虚函数把契约“编译”进类型系统里。换句话说C 里的纯虚函数是在告诉你**我能做什么但绝不告诉你我怎么做。**这正是接口与实现分离的核心也是这整套价值观的起点。2. 契约式编程把“口头约定”变成“编译期强制”2.1 契约的三要素前置条件、后置条件、不变量真正做契约式设计时你要想的远不止“这个函数是不是虚的”这么简单。一份完整的契约通常包含三个部分前置条件Precondition函数被调用前调用方必须确保的状态。比如日志消息不能为空、文件句柄必须已经打开。后置条件Postcondition函数执行完后调用方有理由期望的结果。比如 flush() 返回后缓冲区必然为空。不变量Invariant在对象生命周期内始终成立的性质。比如某个配置值一旦初始化之后就不允许再被修改。在 C 里前置条件和后置条件可以用 assert、异常或者返回值检查来实现而“不变量”往往落在抽象类内部的非虚成员函数里由它统一检查。这里有一个容易被忽略的点**契约不是只靠纯虚函数的签名就能完成。**纯虚函数只是告诉你“有这回事”契约的完整落地需要骨架函数和派生类实现共同配合。2.2 一个典型的“契约模板方法”非虚骨架 纯虚细节下面这段代码是典型的契约式设计。抽象基类里有一个非虚的 log() 作为骨架它先检查前置条件再调用纯虚函数 write()最后执行一个可选的钩子函数。派生类只负责实现 write()完全没有机会破坏流程。class LogSink { public: virtual ~LogSink() default; // 非虚骨架函数负责流程控制和契约检查 void log(LogLevel level, const std::string message) { // 前置条件消息不能为空 assert(!message.empty()); // 核心工作交给派生类 write(level, message); // 后置收尾挂钩子 onWriteFinished(); } protected: // 纯虚函数 契约条款派生类必须实现 virtual void write(LogLevel level, const std::string message) 0; // 可选钩子默认空实现派生类按需重写 virtual void onWriteFinished() {} };这个模式业界叫模板方法模式但它的价值不止于模式本身。把契约式编程和它放在一起看骨架函数 log() 是“合同管理方”纯虚函数 write() 是“合同执行方”。派生类根本接触不到前置检查和后置检查的逻辑因此不可能绕过契约。很多刚从 Java 转过来的人会问C 里没有 interface 关键字怎么表达纯接口答案是定义一个只含纯虚函数的类它就是一个纯接口。如果你有成员变量和已实现的普通函数那它就是一个“骨架抽象类”用途从“纯契约”变成了“契约 公共实现”。两种都合法但你要清楚自己用的是哪一种混着用很容易让设计失控。2.3 为什么不直接用普通虚函数凑合这是我在 code review 时最常被问到的问题反正虚函数也能被重写为什么一定要纯虚区别在于“默认值”和“必填项”。普通虚函数带一个默认实现派生类可以不重写。你如果把所有接口都设计成普通虚函数等于告诉调用方“这一切都是可选的”。当可选的东西一多继承体系就会变得松散有人忘记重写有人重写了但行为不一致整个多态体系变成一个“谁改谁说了算”的野区。纯虚函数则明确要求你别无选择必须实现。语法层面的强制比在文档里写一百遍“请务必重写”有效得多。再加上override关键字这个强制还能进一步升级。如果你把重写签名写错了比如漏了 const或者参数类型不匹配编译器会立刻报错“没有可覆盖的函数”。没有 override 的时候你自以为在重写实际上却是在定义一个新函数把基类的纯虚函数晾在一边直到实例化报错你才反应过来。这个教训我踩过不止一次所以我的建议很绝对所有重写的地方一律加 override没有例外。3. 抽象类实操精要六个决定成败的细节3.1 析构函数必须是虚函数甚至可以是纯虚函数这是抽象类设计里最经典的一个坑。看下面这段代码class A { public: virtual void func() 0; }; class B : public A { public: void func() override {} ~B() { /* 释放资源的逻辑 */ } }; A* p new B(); delete p; // 危险A 的析构函数不是虚函数翻车点在于C 中 delete 一个基类指针时如果基类析构函数不是 virtual行为属于未定义。实际表现往往是派生类 B 的析构函数压根不会被调用资源泄漏到让你怀疑人生甚至在调试器里表现为随机的访问违例非常难查。解决办法很简单抽象基类的析构函数必须声明为 virtual。最推荐的做法是class A { public: virtual ~A() default; virtual void func() 0; };如果你希望这个类“抽象到连析构都只能由派生类负责”析构函数也可以声明成纯虚函数virtual ~A() 0;。但这里有个很多人不知道的细节**纯虚析构函数同样需要函数体。**为什么因为派生类析构时最终会隐式调用基类的析构函数哪怕它是纯虚的也需要一份实际代码来执行。你可以在类外补上A::~A() {}。如果忘了写链接阶段会报 undefined reference很多人第一次遇到都摸不着头脑。3.2 构造函数和析构函数中不要调用纯虚函数在 C 对象构造期间虚表指针有一个逐步建立的过程。执行基类构造函数时虚表对应的是“当前正在构造的类”而不是最终派生类。因此如果基类构造函数里调用了一个虚函数它调用到的可能是基类版本如果那个版本正好是纯虚函数轻则链接失败重则程序崩溃。class Base { public: Base() { init(); // 此时 init() 解析为 Base::init()如果它是纯虚的就是灾难 } virtual void init() 0; };正确做法是专门提供一个 initialize() 或者工厂函数在对象完全构造完成后由外部显式调用。这个坑非常反直觉因为括号里调用虚函数看起来“好像”没问题编译器也不一定报错但运行时行为和你预想的完全不同。其实不只是构造函数析构函数里调用虚函数也有类似问题析构期间虚表已经退化到本类不会派发到更“上层”的派生类实现。如果析构里需要清理逻辑尽量走普通的私有成员函数。3.3 纯虚函数可以有实现这很正常很多人以为 0就代表这个函数没有函数体。实际上纯虚函数完全可以拥有自己的实现派生类在必要时可以显式调用。最常见的例子就是纯虚析构函数正如上文所说。另一个实用场景是“给出默认实现但禁止静默继承”。class Logger { public: virtual ~Logger() default; virtual void log(const std::string msg) 0; }; // 纯虚函数也可以有定义提供参考实现 void Logger::log(const std::string msg) { std::cerr [Default] msg std::endl; } class SpecificLogger : public Logger { public: void log(const std::string msg) override { Logger::log(msg); // 显式选择基类的默认实现 } };这里有一个关键认知派生类依然必须实现 log()否则它自己还是抽象类、无法实例化。纯虚函数的实现只是给派生类一个“可选的参考细则”而不是绕开强制实现的通道。你完全可以把它理解为“契约的补充条款”不强制你用但如果你想参考我放在这里。3.4 接口继承和实现继承边界要清楚抽象类有两种气质很多人混在一起用导致类层次越推越乱。纯接口类不含数据成员所有方法都是纯虚函数只有契约没有实现。它最像其他语言里的 interface适合描述能力比如“可序列化”“可移动”。骨架抽象类有成员变量有非虚的公共逻辑还有纯虚的扩展点。它适合做模板方法的容器比如上文 LogSink 的例子。我的经验是一个类最好不要同时承担“纯接口”和“骨架类”两种职责。当你想描述“一份只包含约定”的能力时就用纯接口类当你发现多个具体类有大量公共逻辑时才抽出骨架抽象类。如果一上来就定义一个带五个成员变量、七个虚函数、三个纯虚函数的“万能抽象类”后面每个新场景都要被迫实现一堆用不到的方法这种设计已经离“契约清晰”越来越远了。3.5 访问级别、final给契约装上边界纯虚函数的访问级别也有讲究。接口的公有方法一般是 public因为契约必须对调用方可见。析构函数的访问级别还可以更严格如果你不希望外部通过基类指针 delete 对象可以把它声明为 protected virtual这样调用方只能通过派生类对象操作等于给契约加了一层使用边界。如果你的抽象类设计得非常稳定不希望再有人往下面派生可以加 final。比如写完一个经过充分验证的 LogSink 实现后你可以写class FileLogSink final : public LogSink { ... };。final 和 override 配合使用前者阻止继续派生后者强制检查重写签名。这两个关键字在契约式设计里非常有用它们把“人的约定”进一步变成了“编译器的约定”。4. 完整案例用契约式设计重写一个日志模块下面我把前面讲的点全部串起来做一个麻雀虽小五脏俱全的例子。假设我们要给一个旧项目重构日志模块现在的痛点很明显每个业务模块各写各的日志打印方式有的写文件有的打控制台有的偷偷用 printf无法统一切分和审计。我的第一步不是写代码而是写契约。LogSink 抽象类就是这份契约#include string #include fstream #include iostream #include cassert enum class LogLevel { Debug, Info, Warning, Error }; class LogSink { public: virtual ~LogSink() default; // 非虚模板方法统一入口负责前置检查与统一收尾 void emit(LogLevel level, const std::string msg) { // 契约1Error 级别以上的消息不允许为空 assert(level LogLevel::Error || !msg.empty()); // 契约2把真正的工作交给具体实现 write(level, msg); // 契约3实现完成后必须触达钩子供派生类补充行为 onEmitDone(level); } protected: // 纯虚函数派生类必须实现 virtual void write(LogLevel level, const std::string msg) 0; // 可选钩子默认空实现 virtual void onEmitDone(LogLevel level) {} };这个抽象类的设计意图很清楚非虚的 emit() 是契约的监督者调用方只能看到这一个入口纯虚函数 write() 是契约的执行点派生类必须实现虚函数 onEmitDone() 是可选钩子默认空实现派生类需要时再挂钩。再看两个履约方class ConsoleLogSink : public LogSink { protected: void write(LogLevel level, const std::string msg) override { std::cout [ levelAsString(level) ] msg std::endl; } void onEmitDone(LogLevel) override { std::cout.flush(); } private: std::string levelAsString(LogLevel level) const { switch (level) { case LogLevel::Debug: return Debug; case LogLevel::Info: return Info; case LogLevel::Warning: return Warning; case LogLevel::Error: return Error; } return Unknown; } }; class FileLogSink : public LogSink { public: explicit FileLogSink(const std::string path) { file_.open(path); assert(file_.is_open()); // 前置条件文件必须能打开 } ~FileLogSink() override { if (file_.is_open()) { file_.close(); } } protected: void write(LogLevel level, const std::string msg) override { file_ [ static_castint(level) ] msg std::endl; } private: std::ofstream file_; };在 FileLogSink 里析构负责文件关闭write() 里没有任何逻辑能绕过 emit() 的前置检查。使用方永远接触不到 write()因为它被 protected 挡住了实现方也永远无法跳过 emit() 的流程控制因为 write() 被 protected 挡住了。这就是双向约束也是契约式编程里“责任边界”最直观的体现。最后是使用方视角。调用方依赖的是 LogSink 这个契约而不是任何一个具体类void setupLogger(LogSink sink) { sink.emit(LogLevel::Info, logger initialized); } int main() { ConsoleLogSink consoleSink; FileLogSink fileSink(app.log); setupLogger(consoleSink); // 控制台日志 setupLogger(fileSink); // 文件日志 return 0; }注意 setupLogger 接收的是 LogSink 引用。抽象类虽然不能实例化但你可以声明它的引用或指针让它们指向具体派生类对象这正是多态的入口。未来要加一个 NetworkLogSink、一个 KafkaLogSink只要它履行了契约旧调用代码一行都不用改。这套设计的价值在维护期才会完全体现当团队从 3 人扩到 30 人每个新的日志需求都只是“新增一个派生类”而不是在既有代码里塞 if-else。这就是契约式编程带来的可扩展性也是这个案例最值得反复体会的地方。5. 常见问题与踩坑速查契约式设计听起来很美好真正落地时总会冒出一堆问题。我把这些年真实遇到的坑整理成一张速查表再补几条“靠经验才能换来的”细节。表现原因解决方案链接报错undefined reference toA::~A()纯虚析构函数只声明没定义类外补A::~A() {}运行时随机崩溃或者资源莫名泄漏基类析构不是 virtual抽象类析构函数声明为virtual ~X() default构造函数里调虚函数调到的是基类版本构造期间虚表还没指向派生类构造函数内不调虚函数改用 initialize()派生类实现纯虚函数后仍报无法实例化override 拼写或签名不匹配形成了隐藏加 override让编译器立刻暴露问题接口里有 20 个纯虚函数新实现类叫苦不迭接口职责过重拆分小接口按能力拆分参考接口隔离原则抽象类里既塞成员变量又塞纯虚函数调用方还当纯接口用接口继承和实现继承混用明确职责纯接口类不含数据骨架类才有公共实现5.1 编译期报错不是坏事那是契约在保护你我在带新人时反复强调一句话抽象类的编译期报错是它最大的优点而不是麻烦。契约式编程的本质是把“应该在运行期发现的问题”提前到编译期。当你看到一个派生类没有实现某个纯虚函数而报错时正确的反应不是“好烦又错了”而是“编译器在提醒我这份契约还没履行完”。如果你发现自己的继承体系经常在运行期出诡异的多态行为第一步要检查的是是不是有虚函数被重写时漏掉了 override是不是基类本来应该用纯虚的地方用了普通虚函数放松了约束这两个问题解决了一半以上的多态 bug 都会消失。5.2 纯虚函数和异常怎么配合契约式编程里除了 assert 失败直接终止更优雅的方式是用异常来表达违约。你可以让纯虚函数在实现中抛出异常也可以在骨架函数里 catch 并统一处理。比如前置条件不满足时如果断言在 NDEBUG 下被编译掉了那就要换成显式检查并抛异常void LogSink::emit(LogLevel level, const std::string msg) { if (level LogLevel::Error msg.empty()) { throw std::invalid_argument(Error 级别的消息不能为空); } write(level, msg); }实际生产代码里我通常保持项目开启断言同时对关键参数保留运行时校验。防御性编程和契约式编程不是一回事前者把输入都当敌人后者明确划清责任边界。两者结合才是最稳的状态。5.3 别为了抽象而抽象最后想专门给刚学习 C 的同学提个醒抽象类是好工具但别把它当装饰品。如果你只有一个具体类硬要给未来可能出现的第二种情况提前抽一层结果往往是代码里多了一个既没调用者、也没实现的空壳接口维护成本反而更高。判断该不该引入抽象类的标准很简单你至少有两个不同的具体类在行为上有“共同的能力、不同的实现”并且你希望调用方只依赖共性、不依赖具体。满足了再抽不满足就先写具体类等第二个需求出现时再重构这才是务实的做法。一个深有体会的碎碎念做过大量 C 项目的维护和重构之后我越来越确认一件事抽象类和纯虚函数的用法最能反映一个工程师是把 C 当“能跑就行”的工具还是当“可以设计”的语言。前者只记得“抽象类不能实例化”后者会主动思考“抽象类的契约应该长什么样”。这两种人写出来的代码从第一个继承层级开始就已经分道扬镳。前者写出的继承最后往往变成一团互相牵制的大泥球后者写出的继承即使过了一年半载再看也依然可以通过虚函数签名读懂当初的业务约束。纯虚函数不会帮你写出好代码但它像是一份合同的签名栏。你签下的每一份契约都逼着你思考这个接口到底承诺了什么调用方凭什么信任我这些问题的答案远比“怎么定义一个抽象类”本身重要得多。如果你正在写一个新模块或者准备重构一个混乱的继承体系我的建议是先停下来画一张“契约清单”定义清楚哪些方法是纯接口、哪些是可选钩子、哪些是公共骨架流程。画完再开始打代码你会少踩至少一半的坑。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →