ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++纯虚函数与抽象类:从语法到契约式编程实践

C++纯虚函数与抽象类:从语法到契约式编程实践 很多人学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::mapstd::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至今没有完整的契约关键字C26正在推进[[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::vectorchar compress(const std::string data) 0; virtual std::string decompress(const std::vectorchar blob) const 0; };然后是各种实现类NoCompression、ZLibCompression、LZ4Compression。业务模块里持有的是ICompression或者std::unique_ptrICompression具体传谁进来由上层配置或工厂决定。新增一种压缩算法时你只需要新增一个实现类业务代码一行都不用改测试代码也只需要针对新类补测试。这就是“把变与不变切开”压缩算法的变化是“变”业务对压缩接口的依赖是“不变”。纯虚函数把不变的部分钉死在接口层变化的部分留给具体类。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) {} };这里的做法叫NVINon-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_ptrILogger createLogger(LogLevel minLevel) 0; };工厂接口里的createLogger返回的是std::unique_ptrILogger注意这里用的是抽象类指针。这意味着调用方拿到了一个“承诺会记录日志”的对象但它不关心这个对象具体是怎么写的。整个创建细节都封装在工厂实现类里业务层和日志具体实现彻底解耦。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封装补充一个特别实用的小习惯从C11开始凡是重写基类虚函数一律写上override。它让编译器帮你检查签名匹配一旦你把参数类型写错、const忘加、返回值不一致编译器立刻报错而不是让这个类悄悄变成一个没有正确重写的抽象类。很多“为什么我的类不能实例化”的困惑加一圈override之后就真相大白了。5. 我自己是怎么把这些原则用进代码里的讲一个真实经历。前几年我重构一个数据同步模块模块要支持多种数据源和多种目标端。刚开始的方案是写一个大类里面有几十个函数再用一堆if分支处理不同场景代码膨胀得没法看加一个新数据源要改核心类测试回归成本特别高。后来我重新设计。第一步把所有同步器必须做的事列成一份契约连接、准备数据、推送、收尾四件事必须有且顺序不能变错误必须通过异常传递不能静默吞掉。第二步写抽象基类把四步变成protected纯虚函数对外只留一个非虚的run()流程函数。第三步把连接参数校验、超时控制、重试机制这些公共逻辑全塞进基类的run()里。之后添加新的同步器比如加一个SFTP目标端我只需要继承基类、实现那四个纯虚函数再注册一个工厂分支。业务层完全没动测试代码只需要针对新类写用例老用例一条不改就全过。那次重构之后我形成了一种固定的思考方式每次要写抽象类先不急着定义虚函数而是先问自己这个类想让继承者承诺什么有哪些操作是无论如何都不能让调用方看到的把这些边界想清楚再动手写代码。我发现一旦用“契约”而不是“多态”来指导设计抽象类的每个函数都变得有分量代码的扩展性和稳定性也明显提升。这个思路推荐你也在自己的项目里试一次试过之后你对纯虚函数的理解就不只是停留在“不能用”这三个字上了。
返回列表