☰
C++纯虚函数与抽象类:从语法到契约式编程实践
2026/10/2 10:36:52 网站建设 项目流程

很多人学C++学到纯虚函数和抽象类这一节,教材上的解释往往只有一句话:含有纯虚函数的类叫抽象类,抽象类不能实例化,派生类必须实现纯虚函数才能创建对象。这话没有错,但如果你只记住了这句话,那其实还没理解C++为什么非要设计出这么个“不能实例化的类”来。我在项目里用C++写了十几年,真正想明白纯虚函数和抽象类的价值,是在一次大重构之后——它们最重要的用处,不是让多态跑起来,而是帮你完成一件看起来很简单、实际做起来特别难的事:契约式编程。

这篇文章就围绕纯虚函数、抽象类和契约式编程展开,适合已经看完C++语法、开始写工程代码的人,也适合面试前想把这个概念讲清楚的求职者。我会先拆“为什么不能实例化”这个底层原因,再用一个可运行的存储后端例子讲清楚契约的三要素怎么落地,最后聊几个我在工程里踩过的坑,特别是析构函数和跨模块调用有关的那些隐蔽问题。内容偏实践,代码可以直接抄走改着用。

1. 重新认识纯虚函数:它不是在说“没有实现”,而是在说“必须实现”

1.1 从“抽象类不能实例化”往深处走

先说语法层面的定论:只要一个类里含有至少一个纯虚函数,这个类就是抽象类,编译器不允许你创建它的对象。所谓纯虚函数,声明方式很简单:

virtual ReturnType functionName(Parameters) = 0;

这里的= 0叫纯说明符(pure specifier),它不是把函数返回值设成0,而是告诉编译器:这个虚函数在基类里不提供有意义的实现,基类因此不允许实例化。派生类如果不能把所有继承过来的纯虚函数都实现掉,那派生类也依然是抽象类,依然不能实例化。

很多人把这理解成“抽象类是个残缺的类,所以不能造对象”,其实这个理解方向反了。抽象类不是“残缺”,而是“还没被允许独立存在”。换个比方:一份合同模板,它可以写得非常完整,包括双方义务、付款方式、违约责任,但它不是一份已经签字生效的合同,所以不能拿去履行。抽象类就是这份合同模板,纯虚函数就是合同里那些“乙方必须完成以下服务”的条款,条款写得再具体,也要等有人签字盖章才能执行。

从编译器和运行时层面看,原因更实在:每个带有虚函数的类都有一个虚函数表(vtable),对象通过虚表指针找到应该调用的函数地址。纯虚函数在基类的虚表里没有对应的实现地址,编译器不会为抽象类生成完整的虚表。一个对象连自己的行为表都填不完整,你把它创建出来,运行时一旦触发这个纯虚调用,就是未定义行为。编译器直接掐断这种可能性,是保护你。

1.2 虚函数表视角:纯虚函数的槽位到底装着什么

沿着虚函数表的思路再往深走一步,你会发现纯虚函数这个机制的本质。

普通虚函数,基类提供一个默认实现,派生类可以覆盖,也可以不覆盖直接继承。虚表里这一项从基类到派生类始终有地址。纯虚函数不同,它在基类的虚表里是一个空槽位,只有等到某个派生类真正实现了它,这个槽位才被填上函数地址。如果一路都没人实现,那这个类永远不能实例化,因为虚表永远是残缺的。

这个设计其实非常直白:基类知道“要做什么”,但不知道“怎么做”。它把这个决策权交给继承者,同时用“不能实例化”作为强制手段,逼继承者必须给出答案。你甚至可以给纯虚函数编写函数体,比如在类外写Base::foo() { ... },派生类调用时可以用Base::foo()显式调用,但派生类仍然必须实现自己的foo()。也就是说,基类可以给一份“参考答案”,但继承者不能因为有了参考答案就不交卷。

提到生活化类比,最贴切的就是USB Type-C标准。它只定义接口的外形、引脚定义、协议规范,不关心你是普通充电还是快充,也不需要知道内部芯片长什么样。任何设备只要遵守这个标准就可以互相连接。纯虚函数定义的就是“协议本身”,派生类则是“协议的各种实现方式”。

1.3 抽象类不是半成品,是设计约束

既然提到了“合同”和“协议”,就得纠正另一个常见的误解:很多人以为抽象类就是“没写完的普通类”,先把能写的写了,留几个空方法让后人补。这种想法会误导你,让你把抽象类当成“偷懒的中间层”。

抽象类的正确身份是设计约束。写一个抽象类的人,不是在说“我懒得写这些函数”,而是在说:凡是继承我的类,必须承诺做到下面这几件事。这个承诺不是口头上的,是编译器强制检查的。你想创建一个对象,就必须先实现这些纯虚函数,跑都跑不掉。

抽象类可以拥有数据成员、构造函数、普通成员函数,甚至可以有大部分默认行为,只留一个或几个需要派生类提供的“灵魂函数”。这样设计的好处是:公共逻辑、公共状态、公共接口都由基类统一管理,派生类只负责那些真正不同的部分。这就是为什么很多框架喜欢给你一个抽象基类,让你只填一小块空白——他们不是在刁难你,是在保护你,更是在用编译器的力量守住一份契约。

2. 契约式编程到底在解决什么问题

2.1 三个契约要素:前置条件、后置条件、不变量

契约式编程(Design by Contract)最早由Bertrand Meyer在Eiffel语言里系统化提出,核心思想是:软件系统中的每个模块、每个函数,都应该像一份法律合同一样,明确约定调用方和被调用方各自的权利与义务。具体拆成三个概念:

  • 前置条件(precondition):调用方调用某个函数之前,必须满足的条件。比如“传入的指针不能为空”“key不能是空字符串”“文件必须存在”。
  • 后置条件(postcondition):函数执行完之后,系统必然成立的状态。比如“返回值一定在[0, 100]之间”“数据一定已经落盘”“读写计数一定增加了”。
  • 不变量(invariant):对象从创建到销毁的整个生命周期里,始终成立的性质。比如“链表头节点永远不为空”“缓存容量永远不超过上限”“内部状态必须保持一致性”。

C++的抽象类恰好是表达这套契约的完美载体。你可以把纯虚函数理解成“合同的条款编号”,把注释写成“条款的详细内容”,而编译器负责检查“每个人都签了字”。举个例子,如果抽象类声明了一个virtual void write(key, value) = 0;,那么契约通常是“write成功之后,read(key)一定能读到value”。实现类可以自由选择底层存储是内存、文件还是数据库,但它只要敢说自己是这个抽象类的子类,就必须遵守这条语义契约。

2.2 一个可运行的抽象类契约示例

空谈理论没有说服力,直接上代码。我设计一个存储后端抽象类,这个类在工程里非常常见,各种业务系统都可能需要切换不同的存储实现。

#include <string> class StorageBackend { public: virtual ~StorageBackend() = default; // 前置条件:key 非空,value 非空 // 后置条件:write 返回后,read(key) 可以读取到 value virtual void write(const std::string& key, const std::string& value) = 0; // 前置条件:key 已经通过 write 写入,且没有被 remove // 后置条件:返回的字符串等于最近一次 write 写入的 value virtual std::string read(const std::string& key) const = 0; // 前置条件:key 已经存在 // 后置条件:调用完成之后,read(key) 抛出 KeyNotFoundException virtual void remove(const std::string& key) = 0; };

注意看,这个抽象类里除了析构函数,所有函数都是纯虚函数。它做的事情就是把契约条款列清楚:写入、读取、删除这三个操作分别有什么前提、产生什么结果。然后我给一个最简单的内存实现:

#include <map> #include <stdexcept> class KeyNotFoundException : public std::runtime_error { public: explicit KeyNotFoundException(const std::string& key) : std::runtime_error("key not found: " + key) {} }; class MemoryStorage : public StorageBackend { public: void write(const std::string& key, const std::string& value) override { if (key.empty() || value.empty()) { throw std::invalid_argument("key and value must be non-empty"); } map_[key] = value; } std::string read(const std::string& key) const override { auto it = map_.find(key); if (it == map_.end()) { throw KeyNotFoundException(key); } return it->second; } void remove(const std::string& key) override { if (map_.erase(key) == 0) { throw KeyNotFoundException(key); } } private: std::map<std::string, std::string> map_; };

这个MemoryStorage表面上看只是在实现三个接口,实际上它是在履行契约。write里检查空字符串并抛异常,是前置条件的运行时检查;read找不到key就抛KeyNotFoundException,是对“key必须存在”这个前置条件的严格执行;remove删不掉也抛异常,说明它对“key不能不存在”这件事零容忍。

现在换一个场景:你写了一个FileStorage也实现了StorageBackend,那么在业务层process(storage)函数里,你根本不需要关心传进来的是内存实现还是文件实现,对你来说它们都是同一个契约的履行者。调用方只需要写:

void process(StorageBackend& storage) { storage.write("order_1024", "paid"); auto value = storage.read("order_1024"); assert(value == "paid"); }

这就是契约式编程的价值:调用方信任契约,不信任具体类;实现方遵守契约,不干扰调用方。

2.3 C++里落地契约的常用手段

契约式编程在Eiffel里有语言级别的支持,C++至今没有完整的契约关键字(C++26正在推进[[pre:]]、[[post:]]这类的标准属性,但距离主流编译器支持还要时间)。所以在日常工程里,我们通常用下面这几种手段来落地契约:

  • assert:主要用于调试期检查前置条件和后置条件,比如进入函数时断言参数合法,退出函数时断言结果满足要求。发布版中NDEBUG会把它关掉,所以不能依赖它做业务校验。
  • 异常:用于运行期的业务规则违约检查。C++的异常机制本身就是“合同违约”的自然表达,throw std::invalid_argument相当于起诉对方。
  • static_assert:用于编译期的类型约束和常量表达式约束,检查的是编译期不变量。
  • 注释:把前置条件、后置条件、不变量明明白白写在纯虚函数声明处。这段注释不是说给编译器听的,是说给所有读代码的人听的。

如果你想让契约的检查不分散在多个实现类里,还有一个做法:在抽象类里提供protected的校验函数,让所有实现类共享。比如在StorageBackend里加一个protected void checkKey(const std::string& key) const,统一检查key是否为空、是否超长,实现类在入口处先调用它。这样契约规则只写一份,不会出现“内存版检查了,文件版忘了检查”这种问题。

3. 抽象类的真正用途:把“变”与“不变”切开

3.1 策略模式:换实现不换调用方

策略模式是抽象类最经典的应用场景之一。它的目标是:定义一族算法,让它们可以互相替换,调用方不关心具体是哪一种。

举个例子,一个数据同步模块需要支持普通明文传输和加密传输两种方式。你定义一个压缩策略接口:

class ICompression { public: virtual ~ICompression() = default; virtual std::vector<char> compress(const std::string& data) = 0; virtual std::string decompress(const std::vector<char>& blob) const = 0; };

然后是各种实现类:NoCompression、ZLibCompression、LZ4Compression。业务模块里持有的是ICompression&或者std::unique_ptr<ICompression>,具体传谁进来,由上层配置或工厂决定。新增一种压缩算法时,你只需要新增一个实现类,业务代码一行都不用改,测试代码也只需要针对新类补测试。

这就是“把变与不变切开”:压缩算法的变化是“变”,业务对压缩接口的依赖是“不变”。纯虚函数把不变的部分钉死在接口层,变化的部分留给具体类。

3.2 模板方法模式:基类把流程顺序定成契约

模板方法模式和策略模式的重点不同。策略模式强调的是“替换”,模板方法模式强调的是“固定流程,留出扩展点”。

看个实际的例子。报表导出模块,无论导出成CSV还是JSON,流程基本都是:写文件头、序列化数据、把数据写入输出流、写文件尾。如果你让每个实现类从头到尾写一遍导出流程,代码会大面积重复,而且流程顺序很容易被某个实现类打乱。用抽象类,可以这样设计:

class DataExporter { public: // 非虚接口,对外固定流程 void exportData(const Report& report) { writeHeader(report); auto content = serialize(report); write(content); } protected: // 派生类必须实现的步骤 virtual std::string serialize(const Report& report) const = 0; virtual void write(const std::string& content) = 0; // 可选的钩子,默认什么都不做 virtual void writeHeader(const Report& report) {} };

这里的做法叫NVI(Non-Virtual Interface,非虚接口)模式:public方法不虚,真正虚的函数被放到protected区。好处有三个:

第一,流程顺序被锁死。任何派生类都无法改变“先写头、再序列化、最后写入”的顺序,这是基类定下的契约。第二,公共逻辑可以在exportData里统一做,比如耗时统计、日志记录、异常包装,派生类完全不需要知道这些。第三,派生类只需要关注两个纯虚函数,看到的扩展面极小,出错概率自然降低。

这种设计在框架代码里极其常见,比如游戏引擎的每帧更新流程、网络库的请求处理流程,都是基类把骨架写好,你只填素材。

3.3 工厂模式:把“创建什么”推迟到运行时

光有抽象产品类还不够,很多场景下你还得把创建过程一起抽象掉。比如日志模块,按照配置可能是控制台日志、文件日志、远程日志。如果业务层直接new FileLogger(),那配置切换就变成改代码了。

这时候需要抽象工厂接口:

class ILogger { public: virtual ~ILogger() = default; virtual void log(std::string_view message, LogLevel level) = 0; }; class ILoggerFactory { public: virtual ~ILoggerFactory() = default; virtual std::unique_ptr<ILogger> createLogger(LogLevel minLevel) = 0; };

工厂接口里的createLogger返回的是std::unique_ptr<ILogger>,注意这里用的是抽象类指针。这意味着调用方拿到了一个“承诺会记录日志”的对象,但它不关心这个对象具体是怎么写的。整个创建细节都封装在工厂实现类里,业务层和日志具体实现彻底解耦。

3.4 抽象类、接口、具体基类、PImpl到底怎么选

日常开发里经常有人把“抽象类”和“接口”混着说,还有人分不清抽象类和具体基类的适用场景。C++没有Java那样的interface关键字,我们约定俗成把“所有成员函数都是纯虚函数、且没有数据成员”的抽象类称为接口类;而“部分函数有默认实现、可以带数据成员”的类,叫做抽象基类更准确。

这里直接放一张对比表,是我自己工程选型时常用的判断依据:

方案能否直接实例化可带默认实现可带数据成员典型用途主要风险
抽象类(带部分实现)否可以可以模板方法、公共逻辑复用基类膨胀、职责过重
纯接口类(全部纯虚)否不建议不建议跨模块边界、依赖倒置虚表ABI不稳定
具体基类可以可以可以单纯代码复用容易被误继承,继承关系混乱
PImpl(指针隐藏实现)可以不适用通过成员变量隐藏隐藏实现、稳定二进制接口有间接层开销

这几个方案不是互斥的。实际工程里你经常能看到“PImpl里住着一个抽象类”的组合:对外用PImpl稳定ABI,对内用抽象类承载具体算法的替换。选型时先问自己一个问题:我需要强制外部实现什么?如果只是想让一段代码被多复用,用具体基类就够了;如果需要强制所有继承者承诺某些行为,就必须上抽象类或纯接口类。

4. 工程实战中的坑与排查记录

4.1 析构函数:抽象类最容易爆的点

很多人在学习阶段写抽象类时,根本不关心析构函数,直到程序在delete那一行崩溃,或者内存泄漏到怀疑人生,才回头补课。

第一个原则:基类析构函数必须是虚函数。如果你写过:

StorageBackend* p = new MemoryStorage(); delete p; // 如果基类析构不是virtual,这里是未定义行为

基类析构不是虚函数,delete p就只会调用StorageBackend::~StorageBackend(),派生类MemoryStorage的析构不会被调用。如果派生类持有堆资源、打开的文件、数据库连接,这些资源就全部泄漏了。标准库里对这种情况的规定是未定义行为,但实测里最常见的结果就是资源泄漏,严重时就是崩溃。

第二个坑更隐蔽:纯虚析构函数。你可能为了强制所有人都走抽象类,写了这样的代码:

class StorageBackend { public: virtual ~StorageBackend() = 0; // 纯虚析构 };

语法上合法,但纯虚析构函数必须提供函数体,否则派生类析构时链接失败。为什么?因为派生类析构函数总会隐式调用基类析构函数,基类析构如果连函数体都没有,链接器找不到符号。正确的写法是在类外补上:

StorageBackend::~StorageBackend() {}

说实话,纯虚析构这个写法在实际工程里没什么收益,反而容易给自己添堵。最稳的写法就是:

virtual ~StorageBackend() = default;

既不纯虚,又保证了虚析构,让编译器给你生成默认版本,干净利落。

4.2 构造期间调用纯虚函数:一本正经的未定义行为

这个坑我亲眼见过不止一次。场景通常是这样的:新手想写一个抽象基类,希望在构造函数里调用某个纯虚函数来完成“派生类特有的初始化”,结果程序要么直接崩溃,要么在构造函数里莫名其妙跳转到一个不存在的地方。

先说结论:在基类构造函数(以及析构函数)里调用虚函数,不会发生动态绑定,只会调用当前正在构造的类自身的版本。基类构造期间,对象还处于“基类正在构造”的状态,派生类部分还没出生,虚表指针指向的仍然是基类的虚表。如果你调用的恰好是一个纯虚函数,那执行的就是一个没有函数体的虚表槽位,行为未定义。很多编译器会直接报链接错误,另一些只是运行时崩溃。

正确的做法有几个。如果初始化逻辑依赖派生类数据,可以把这些数据作为基类构造函数的参数传下来,由派生类构造时提供:

class Base { public: Base(std::string name) : name_(std::move(name)) {} private: std::string name_; };

如果必须调用纯虚函数,那就把初始化拆出来,放到一个独立的init()虚函数里,在对象构造完成之后由外部调用,或者放进派生类构造函数末尾。记住一个原则:构造函数不是调用虚函数的地方,尤其是纯虚函数。

4.3 跨模块边界导出抽象类时的access violation问题

这个坑对应热搜词里那个c#调用c++出现access violation c0000005,也适用于C++之间跨DLL/动态库的调用。很多团队喜欢把抽象类当作DLL的对外接口,导出一个纯虚类让其他人用,看起来很美,实际暗雷无数。

access violation(访问违例)出现的核心原因是:调用方和被调用方对同一个类的内存布局产生了不同的认知。抽象类作为接口导出时,类里至少有一个虚函数表指针,虚函数表里函数的排列顺序、析构函数的位置、继承体系里各基类子对象的位置,都会被内存布局影响。只要两边的编译器版本、编译选项、头文件版本稍有不同,布局就可能不一致,一调用就直接踩到非法地址。

常见诱因有这么几类:

  • 头文件更新不同步。DLL编译时接口里是3个虚函数,调用方手里的头文件还是2个,虚表顺序直接错位。
  • 跨模块传递标准库类型。比如抽象类接口里有std::string参数,两个模块用了不同的运行时库设置,内部结构不一致,栈直接崩掉。
  • 调用约定不一致。C++默认的成员函数调用约定是__thiscall,如果DLL导出的函数被C#那边按错误约定声明,参数栈的平衡就乱了。
  • RTTI和异常设置的差异。两边线程模型、异常处理模型不一致时,跨越边界抛异常也容易出问题。

规避方案我按推荐顺序排列:第一选择是不要直接跨模块导出抽象类,改用C风格接口加句柄(handle),把所有C++类型藏在内层。第二选择是使用不透明指针(PImpl),保持头文件里只有一个struct Impl*,对外暴露的还是普通函数。第三选择才是导出纯虚接口,但前提是你能保证所有使用方用同一套编译器、同一套标准库、头文件永久同步。前两条在实际工程里远比第三条稳。

4.4 常见问题速查表

最后把我在代码评审和答疑时最常遇到的抽象类问题整理成一张速查表,方便你遇到报错时直接定位:

问题现象原因定位解决建议
“cannot instantiate abstract class”类里至少有一个纯虚函数没实现逐个对照基类纯虚声明补上 override
派生类重写失败但编译器不报错参数类型、const属性、返回值不匹配,重写变成了隐藏给所有重写函数加override关键字
纯虚析构函数链接失败纯虚析构函数没有函数体类外补充Base::~Base() {}
delete基类指针后派生类资源没释放基类析构函数不是 virtual抽象类一律写virtual ~Base() = default;
构造函数里调用纯虚函数后崩溃构造期间虚调用不会动态绑定参数传入或改由外部完成初始化
跨DLL调用抽象类接口崩溃虚表布局或STL类型跨边界不一致用C接口+句柄,或用PImpl封装

补充一个特别实用的小习惯:从C++11开始,凡是重写基类虚函数,一律写上override。它让编译器帮你检查签名匹配,一旦你把参数类型写错、const忘加、返回值不一致,编译器立刻报错,而不是让这个类悄悄变成一个没有正确重写的抽象类。很多“为什么我的类不能实例化”的困惑,加一圈override之后就真相大白了。

5. 我自己是怎么把这些原则用进代码里的

讲一个真实经历。前几年我重构一个数据同步模块,模块要支持多种数据源和多种目标端。刚开始的方案是写一个大类,里面有几十个函数,再用一堆if分支处理不同场景,代码膨胀得没法看,加一个新数据源要改核心类,测试回归成本特别高。

后来我重新设计。第一步,把所有同步器必须做的事列成一份契约:连接、准备数据、推送、收尾,四件事必须有,且顺序不能变,错误必须通过异常传递,不能静默吞掉。第二步,写抽象基类,把四步变成protected纯虚函数,对外只留一个非虚的run()流程函数。第三步,把连接参数校验、超时控制、重试机制这些公共逻辑全塞进基类的run()里。

之后添加新的同步器,比如加一个SFTP目标端,我只需要继承基类、实现那四个纯虚函数,再注册一个工厂分支。业务层完全没动,测试代码只需要针对新类写用例,老用例一条不改就全过。

那次重构之后我形成了一种固定的思考方式:每次要写抽象类,先不急着定义虚函数,而是先问自己,这个类想让继承者承诺什么?有哪些操作是无论如何都不能让调用方看到的?把这些边界想清楚,再动手写代码。我发现一旦用“契约”而不是“多态”来指导设计,抽象类的每个函数都变得有分量,代码的扩展性和稳定性也明显提升。这个思路推荐你也在自己的项目里试一次,试过之后你对纯虚函数的理解,就不只是停留在“不能用”这三个字上了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询