ARTICLE DETAIL

资讯详情

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

C++纯虚函数与抽象类:把契约式编程焊进架构

C++纯虚函数与抽象类:把契约式编程焊进架构 今年我给团队做C内训时出了一道基础题“纯虚函数和虚函数的最大区别是什么”有人回答“纯虚函数没有函数体”有人回答“含有纯虚函数的类不能实例化”。都对但都停在语法层。真正要命的是下一问“那你会不会在自己的项目里主动、刻意地使用纯虚函数和抽象类”整个屋子安静了。这就是我想写这篇《你真的了解C吗》No.022的原因。大多数人学纯虚函数只记住了“0”这个符号和“不能实例化”这个结论却从未理解它到底是干什么用的。今天我不讲语法书上的老话直接从工程视角拆开纯虚函数和抽象类的真正用途是契约式编程。它把“你答应我做什么”从口头约定变成编译器强制执行的合同这是我在多个嵌入式、服务端项目里反复验证过的核心价值。1. 别把纯虚函数只当语法它在类型系统里刻了一张“承诺书”1.1 “0”到底在干什么先回到最基础的问题。一个函数声明长这样class Decoder { public: virtual void decode(const uint8_t* data, size_t len) 0; };语法上0 告诉你两件事第一这个函数是虚函数会进入虚函数表允许派生类覆盖第二当前这一个类不打算给你实现体。但第而句话还有一个更深层的含义——当前这个类不再具备“完整对象”的资格它从“类”降级成了“约定”。编译器为什么不允许抽象类实例化很多初学者觉得这是限制但其实这是保护。如果允许你构造一个 Decoder 对象然后调用 decode()那请问这个对象拿什么去解码它没有行为只会崩溃或者返回一些没意义的东西。编译器拦下的是“用一个半成品当成品用”的愚蠢行为。我记得有一次跟同事争论能不能把所有虚函数都加上默认实现然后让基类可实例化这样不就更方便了吗我当时反问他“如果基类自己能实例化那你如何保证派生类一定改了行为你如何保证别人不偷偷 new 一个 Decoder 假装自己完成了实现”他想了想承认的确无法保证。这就是契约的第一层意义抽象类把“你是没有资格独立存在的”这件事直接写进类型系统。后续任何人看到这个类第一反应就是——我要找个派生类而不是去 new 基类。1.2 面试常问的“为什么不能实例化”背后藏着设计意图光是“编译器不让 new”这句话应付不了真正的面试更应付不了真正的代码评审。你要能说清楚抽象类不能实例化是因为它代表的是“未完成的能力承诺”。它就像一份只有乙方签名、没有甲方盖章的合同你当然不能拿这份合同去银行兑钱。class Shape { public: virtual double area() const 0; virtual ~Shape() default; };这里的 Shape 承诺了“所有形状都能量出面积”但用什么公式、怎么量取决于派生类是圆、矩形还是三角形。基类本身连自己是什么形状都不知道怎么可能算得出面积它要是能实例化才见鬼了。从这里提炼出一个经验当你设计一个基类时如果发现某个方法“在所有派生类里都会覆盖且基类里的实现永远没有机会被调用”那这个方法就应该声明为纯虚函数。反过来如果某个方法在基类里提供了公共逻辑派生类只负责扩展那它应该是有实现体的虚函数或者直接是非虚函数。这个判断标准比死记“纯虚函数没有函数体”好用得多。2. 契约式编程的骨架前置条件、后置条件与不变量如何落进C2.1 契约式编程比“函数调用”更严肃的“合同关系”契约式编程Design by Contract是 Bertrand Meyer 在《面向对象软件构造》里提出的一套思想。它的核心是把软件组件之间的关系类比成商业契约调用方客户和使用方服务提供者之间提前约定好各自的责任。契约有三个关键词前置条件Precondition调用方必须满足的条件。比如“传入的指针不能为空”“金额必须大于零”。后置条件Postcondition调用完成后服务提供者必须保证的结果。比如“返回的结构体已初始化”“文件已关闭”。类不变量Class Invariant对象在整个生命周期中始终成立的状态。比如“队列长度不可能为负”“缓冲区的写位置永远小于上限”。这个概念好理解难的是怎么在 C 里落地。C20 一度有计划把 contract 特性推进标准后来又为了重新设计被移出了 C20 和 C23 的范围。也就是说今天你在标准 C 里写代码编译器不会因为“后置条件没满足”而报错。那我们靠什么实现契约答案就是纯虚函数和抽象类配合 assert、异常和文档约定。2.2 纯虚函数如何表达“契约条款”我们刚才说的 Decoder 接口就是一个典型的契约。我来写一个稍微完整一点的例子class Decoder { public: virtual ~Decoder() default; // 契约data 不能为 nullptrlen 表示有效字节数 // 实现方必须完整解析 len 字节不得越界读取 virtual std::vectoruint8_t decode(const uint8_t* data, size_t len) 0; // 契约reset 后内部解析状态回到初始 // 实现方必须释放所有临时缓存 virtual void reset() 0; };注意这里面的注释。我见过很多团队在写抽象类时完全不在接口上写契约——函数名一看大概能懂但细节约定全在派生类里各自解释。这会导致什么后果实现的人在 A 派生类里认为“len 是包括头部校验的总长度”使用的人在另一个模块里认为“len 是不含校验的有效载荷长度”两边都觉得自己没错最后对接全靠扯皮。正确做法是抽象类里的每个纯虚函数都要写清楚前置条件、后置条件和性能上的承诺。这就是契约条款。纯虚函数本身的“0”是编译器层面的强制你必须实现它、你必须覆盖它的签名。而注释、断言、单元测试则把语义层面的承诺固化下来。2.3 覆盖契约时的“不可弱化不可强化”原则有一件事我想重点提醒契约跟着虚函数一起被继承派生类在覆盖时不是想怎么改就怎么改。Liskov 替换原则在这里有一句人人皆知的话派生类对象应该能替换基类对象并且行为一致。落到契约式编程上就是前置条件只能放宽不能收紧。基类说“data 可为空”派生类如果要求“data 不能为空”就会让调用方在使用基类接口时突然崩溃。后置条件只能增强不能减弱。基类保证“永远返回非空结果”派生类如果可能返回空容器等于撕毁合同。我做过一个解码器项目重构阶段有人把某个派生类的 decode() 前置条件改成了“len 必须是 64 的倍数”理由是性能优化。结果调用方按任意长度传入数据线上偶发内存越界。排查了两天最后用契约思想回看才发现是前置条件被偷偷收紧了。从那以后我们接口注释里专门加一条约定覆盖纯虚函数时不允许收紧基类声明的输入范围不允许削弱基类声明的输出保证。3. 一个真实可跑的契约式设计案例从传感器驱动到策略引擎3.1 场景需要稳定壳子但谁也不知道下一块芯片是什么光说不练假把式。我拿一个实际做过的项目来讲。当时我们在做一个多通道振动采集系统硬件上会接不同类型的传感器有的是 I2C 数字温度传感器有的是 SPI 加速度计有的干脆是 ADC 直连的模拟信号。上层业务关心的只有三件事通道是否在线、当前采样值是多少、校准偏移量是多少。至于底层是 I2C 还是 SPI业务层完全不关心。这种场景就是抽象类的教科书用法。我先定义一个 Sensor 抽象类class Sensor { public: virtual ~Sensor() default; // 契约启动前调用 open()底层句柄合法失败返回 false 并置位 last_error virtual bool open() 0; // 契约返回最近一次采样值单位由 software config 决定 // 若通道离线行为未定义不——保证抛 SensorException virtual double read_value() 0; // 契约calibrate() 最多耗时 100ms期间不产生数据上报 virtual bool calibrate(double reference) 0; // 契约关闭后所有资源释放open 可再次被调用 virtual void close() 0; std::string_view sensor_name() const { return name_; } protected: std::string name_; };这里用了纯虚函数把底层差异“挖”出来。有人说基类直接把这些方法都写空实现不也行吗写空实现的话调用方每次 open() 都要担心“这个传感器是不是没实现 open其实是假的”一旦有人 new Sensor() 自己整个体系的“必须由外部提供硬件能力”的契约就崩了。纯虚函数在这里不是在找麻烦而是在替你把关。3.2 派生类实现把硬件的那些脏活琐活全部关起来我写一个 I2C 温度传感器的派生类片段class I2CTempSensor final : public Sensor { public: I2CTempSensor(int bus_id, uint8_t device_addr) : bus_id_(bus_id), device_addr_(device_addr) {} bool open() override { if (bus_id_ 0 || device_addr_ 0) { last_error_ invalid i2c parameters; return false; } handle_ i2c_open(bus_id_, device_addr_); if (handle_ nullptr) { last_error_ i2c_open failed; return false; } return true; } double read_value() override { if (handle_ nullptr) { throw SensorException(read before open); } uint16_t raw i2c_read_reg(handle_, TEMP_REG); return raw * 0.0625 - 25.0; // 硬件的温度换算公式 } void close() override { if (handle_ ! nullptr) { i2c_close(handle_); handle_ nullptr; } } private: int bus_id_; uint8_t device_addr_; void* handle_ nullptr; std::string last_error_; };这个例子要说明的是上层模块完全不认识 I2CTempSensor它只认 Sensor。后续加入新传感器比如 SPIAccelSensor只需要继承 Sensor 并实现四个纯虚函数。整个上层代码一行不用改。这就是抽象类的第二个价值隔离变化。3.3 基类带实现的“骨架方法”有些公共流程不该被重复写刚才的 Sensor 全部是纯虚函数这是纯接口风格。但实际工程里更常见的是“抽象类中有少量共享实现”。比如所有传感器都有“上电自检”流程先 open、然后读取设备 ID、校验版本号、最后校准。这个流程对于 I2C 和 SPI 传感器是相同的区别只在第二步怎么读 ID 和怎么校验。我倾向于这样设计class Sensor { public: virtual ~Sensor() default; // 非虚骨架方法把公共流程固定下来 bool self_check() { if (!open()) return false; if (!read_device_id()) set_error(device id check failed); if (!calibrate(default_ref())) return false; close(); return true; } virtual bool open() 0; virtual bool read_device_id() 0; virtual bool calibrate(double ref) 0; virtual void close() 0; };注意到这里 self_check() 不是虚函数它是“骨架”它调用了四个纯虚函数。这种模式叫模板方法Template Method而且和纯虚函数是绝配公共算法流程放在基类不可覆盖差异步骤交给派生类强制实现。这样每个派生类不需要重新实现 self_check()也避免了有人把流程顺序写乱。我对这种做法有特殊好感因为它把契约和复用一次都解决了。4. 抽象类和接口看着像其实契约的力度完全不同4.1 纯抽象类与含实现的抽象类两个东西很多 Java 或 C# 转过来的同事会问“C 里没有 interface 关键字那接口怎么表达”一种常见做法是用纯抽象类——即所有成员函数都是纯虚函数没有数据成员。这其实更接近“接口”的概念。但 C 的抽象类还可以带实现。一个类只要有一个纯虚函数它就是抽象类但其他成员函数完全可以是带函数体的甚至可以有数据成员。这就产生了两种风格维度接口风格纯抽象类抽象类含部分实现数据成员通常没有可以有如 name_虚函数全部是纯虚函数部分纯虚部分有实现复用能力不提供实现提供公共流程或默认行为语义角色“你是什么”的能力清单“你是这一类东西的半成品”修改代价加一个虚函数所有实现全要改只改公共实现派生类影响小把这表记住很多设计问题的答案就自然浮出来了。4.2 “你在干吗”和“这东西是什么”是两个层次的抽象我在设计一套策略引擎时有一套“算法策略”接口class Strategy { public: virtual double execute(const MarketData data) 0; virtual ~Strategy() default; };所有策略只需要实现 execute()。这种接口的契约是“行为”至于策略对象内部有没有状态、怎么初始化接口不管。它的好处是极度稳定因为策略就是一组消息的变换没有生命周期不需要共享代码。这时候用接口风格最干净加上 final 和 override 约束几乎不可能被破坏。而另一个场景构建一个“文件解析器”层次时我选择了含实现的抽象类。因为所有文件解析器都需要做打开文件、读取魔数、校验表头、循环读取记录块、处理异常。这些流程里表头和记录块的处理必然因格式而异但循环体和异常处理的骨架完全一致。如果都写纯接口那每个派生类都要重新写一遍“打开文件、读魔数、循环”的代码等于把公共逻辑复制到每个类里后期想调整流程就要改七八处。抽象类把公共流程放基类既能强制不同文件的解析差异又能保住公共流程的一致性。4.3 我的选型经验什么时候该选哪个我常用的判断标准很简单如果你需要定义“我能干什么”且不希望任何实现细节泄漏到接口声明里用纯抽象类。如果你需要定义“我们是一类东西只是细节不同”并且有公共流程要复用用含实现的抽象类。如果同一个接口下要做 mock测试替身接口越小越好纯抽象类是绝对首选。如果这类对象的生命周期很复杂涉及打开、关闭、状态机迁移我会倾向用抽象类而非纯接口因为生命周期管理代码可以集中。有一回我在系统里为了图方便把本应含实现的抽象类写成了纯接口结果每个派生类都要自己处理“连接超时重试”的逻辑。后来三个传感器驱动线程都会在断连时出现重试风暴排查下来才发现重复了十几次几乎一样的重试代码。最后不得不重构把重试逻辑提到抽象类各派生类的代码直接删掉了一半。那次之后我彻底明白纯接口适合“能力契约”抽象类适合“流程契约”两者都有用但别用错地方。5. 与纯虚函数相伴的三个高危区析构、构造与签名弱化5.1 基类析构函数必须是虚函数一不留神就是未定义行为这一节要聊的是坑是我在实际项目里亲眼见过的问题。先看这段代码class BaseDecoder { public: virtual void decode(const uint8_t*, size_t) 0; ~BaseDecoder() {} // 没有 virtual }; class JpegDecoder : public BaseDecoder { public: JpegDecoder() { cache_ new CacheBuffer(4 * 1024 * 1024); } ~JpegDecoder() { delete cache_; } private: CacheBuffer* cache_; }; BaseDecoder* d new JpegDecoder(); // ... delete d; // 危险问题在哪BaseDecoder 的析构函数不是虚拟的当通过基类指针 delete 派生类对象时只会调用基类析构函数JpegDecoder 自己的 cache_ 永远不会释放。C 标准把这种情形判为未定义行为实际表现常常是内存泄漏、资源句柄泄漏严重点直接崩。我记得调这个问题时的完整排查链路项目运行几小时后内存持续上涨曲线像一条缓慢坡道。一开始怀疑缓存没清加了一堆日志没找到异常分支。后来用内存工具看JpegDecoder 的对象总数为零但 CacheBuffer 的数量却在涨——对象明明删了内部缓冲没释放。接着在 BaseDecoder 上打断点发现 delete d 时根本没进 JpegDecoder 的析构代码。最后检查类定义一眼就看到缺了 virtual ~BaseDecoder()。修复后的变化立竿见影内存曲线回归平线。教训只有一句话只要这个类会被当基类使用不管有没有纯虚函数析构函数都要声明为 virtual。纯虚析构函数虽然少见但如果在抽象类里写 virtual ~Foo() 0 这种形式也别忘了在 cpp 文件里提供它的定义——这是因为析构链里无论派生类怎么析构基类析构函数最终都要被调用只有声明没有定义会直接链接错误。5.2 构造函数里调虚函数“多态”在构造期间是失效的C 的虚函数在构造函数执行期间并不会表现出多态。也就是说如果在基类构造函数里调用一个虚函数实际执行的是基类自己的版本派生类还没构造完成它的覆盖版本不会被调用。class Base { public: Base() { init(); } virtual void init() 0; };这样的代码在构造 Base 时如果 init() 是纯虚函数调用它会导致未定义行为在很多编译器上直接崩溃。我的建议是不要在构造函数里调用虚函数尤其不要调用纯虚函数。如果确实需要在初始化阶段触发“虚行为”就用上一节提到的模板方法模式把初始化流程拆成纯虚步骤让派生类在构造完毕后再显式调用 init()或者用工厂方法初始化。5.3 签名被悄悄改掉隐藏而非覆盖第三个坑是签名弱化。看这段代码class Driver { public: virtual bool read(int fd, uint8_t* buf, int len) 0; }; class UsbDriver : public Driver { public: bool read(int fd, uint8_t* buf, int len) {/* ... */} // ok签名一致覆盖成功 }; class PcieDriver : public Driver { public: bool read(uint8_t* buf, int len) {/* ... */} // 糟糕漏了第一个参数这不是覆盖是隐藏 };PcieDriver 的 read() 不是覆盖 Driver 的纯虚函数而是隐藏了基类版本。编译器不会报错但如果你通过 Driver* 调用 read()根本不会到达 PcieDriver 的实现因为 PcieDriver 仍然“抽象”——没有实现基类要求的签名于是我们多半会看到“无法实例化抽象类”编译错误或者行为莫名的错乱。从 C11 开始我要求团队里的覆盖函数一律写 override 关键字。这样如果签名不匹配编译器会立即告诉你“这个函数没有覆盖任何基类虚函数”。这是一行字符、一个编译期防御能省掉的排查时间不可估量。同时对“不想再被覆盖”的函数加 final这样继承关系里的契约边界就非常清楚了。6. 把契约焊进架构依赖反转、测试替身与长期演化6.1 抽象类让“高层依赖底层”的问题变成“大家都依赖接口”依赖倒置原则Dependency Inversion Principle说高层模块不应该依赖低层模块两者都应该依赖抽象。这个原则在有纯虚函数和抽象类之前是不容易实施的。就拿前面传感器例子说事。业务告警模块要读取温度如果直接依赖 I2CTempSensor就会带来一个连锁反应告警模块要了解 I2C 总线、设备地址、寄存器编号、温度换算公式。所有硬件细节全暴露在高层的“业务逻辑”里换一个 SPI 传感器告警模块就得改。而引入 Sensor 抽象类之后告警模块只依赖 Sensor 接口。这个开关极其重要今天我接的是 I2C 温度传感器明天换成 SPI 温度传感器后天加上一个模拟量传感器告警模块一行代码都不用动。我在上一个项目里就是因为把驱动层和业务层用抽象类隔离才在硬件方案临时调整时保住了研发进度。6.2 纯虚接口是单元测试的天然替身我在第 4 节已经提过 mock这里展开讲一下价值排序。纯虚接口的另一个大用途是写测试替身class MockSensor : public Sensor { public: MOCK_METHOD(bool, open, (), (override)); MOCK_METHOD(double, read_value, (), (override)); MOCK_METHOD(bool, calibrate, (double), (override)); MOCK_METHOD(void, close, (), (override)); };如果 Sensor 是一个含实现的抽象类mock 仍然可行但如果里面有大量公共实现代码、数据成员和构造参数写 mock 的成本会显著上升。因此我有一条经验越靠近模块边界的接口越应该保持“纯”纯虚函数加零数据成员会让整个测试体系轻快很多。我们在采集系统里用纯接口模拟了十几种传感器行为——高温、断线、噪声、漂移全部在测试环境里复现。这些测试代码加起来比传感器驱动代码还多几倍但它换回了一个稳定到几乎不会在集成阶段出意外的上层系统。6.3 长期演化接口版本化、契约注释和编译器强制最后说一说契约式编程在长期项目里真正有效的推进方式。第一个建议接口要谨慎加函。抽象类每增加一个纯虚函数所有派生类都要跟着实现。我见过一个团队在抽象类里加了一个辅助虚函数后十几个实现类全部编译失败排查了大半天。如果想平滑演进可以考虑把扩展点设计成“默认实现 可选覆盖”的虚函数或者拆一个新的抽象接口出去用组合代替继承演进。接口不是不能变而是要意识到它变动的成本比普通类高得多。第二个建议给纯虚函数的契约写好注释。我见过的契约注释模板是四段式调用方要求、实现方保证、失败时的行为、性能承诺。例如// 契约说明 // 前置data 非空len 0 // 后置返回解析后的完整记录不得为空 // 异常数据损坏时抛出 ParseException对象状态不受影响 // 性能单次调用不超过 O(len) virtual std::vectorRecord parse(const uint8_t* data, size_t len) 0; // 这套注释并不是写给编译器看的它更像是战术手册。有了它后续任何人在实现派生类或者使用接口时都不需要翻遍所有实现类去猜行为。第三个建议用 override/final 把契约焊死在编译器层面。编译期能阻止的就不要拖到运行期。class UsbDriver final : public Driver { bool read(int fd, uint8_t* buf, int len) override; };override 保证“确实覆盖了基类的虚函数”final 保证“再往下子类不能再改写”。这两组关键字是 C11 给的强大工具可惜很多老代码库根本不用。我在评审时看到只写 virtual 不写 override 的都会让人补上看到能用 final 却没用、导致第三层派生类把行为改得面目全非的基本都得重构。说回我自己的体会。早年我在一个不小规模的项目里写抽象类一开始为了简单给所有虚函数都写了默认实现想着“派生类愿意覆盖就覆盖不愿意也别报错”。结果半年之后百分之六十的虚函数从来没被任何派生类覆盖过而整个继承体系看起来什么都能做实际上做什么都靠猜。后来痛定思痛把所有“必须有外部实现”的方法改成纯虚函数编译器的报错立刻帮我揪出了十多个该实现却漏实现的赝品。那一次重构让我彻底相信纯虚函数和抽象类不是语法负担它们是整个项目里最便宜的保险丝——一旦有人撕毁契约编译器会在上线之前就报警。最后分享一个习惯写抽象类时我总会先问自己一句“这个类到底想表达什么契约”。答不上来时就不要急着开写。等接口名、方法签名、契约注释都能自然说清楚再敲第一行代码。这个过程听着麻烦但比后面改一堆派生类要省得多。下一期 No.023我会接着聊聊虚函数覆盖、隐藏和那些容易让人栽跟头的重载规则感兴趣的话可以继续关注。
返回列表