尧图精选

抽象类与接口的实战对比:C++和Python中的设计模式应用

🕒 发布时间:2026/10/2 4:01:40 📁 来源:尧图网络
1. 抽象类的本质一种“半成品约定”写了这么多年代码我越来越觉得抽象类这个看上去“不产生任何实际功能”的概念其实是面向对象设计里最容易被低估的一个。它不直接干活但它决定了谁能干活、怎么干活。一个系统过了快速原型阶段往中大型演进的路上抽象类往往是第一个跳出来替你兜住架构底线的工具。什么是抽象类拆开看就是两个关键词约定、半成品。说它是“约定”是因为它定义了一批子类必须实现的方法相当于“你们要做什么”的清单说它是“半成品”是因为它自己并不完整里面可以有一部分已经实现好的通用逻辑同时也保留一部分只有子类才可能给出答案的“空白点”。打个比方抽象类像一份总部发下来的岗位职责模板里面已经写好了“每天要开晨会”“每周要提交周报”但真正的“怎么完成业绩”留给你自己填。普通类是可以直接拿来用的成品接口是一张纯合同书而抽象类正好站在两者之间。这个定义直接决定了它在实战里的位置凡是你面对“一大类对象行为模式相同、但细节各自有差异”的场景抽象类就是首选。比如各种消息通知渠道——邮件、短信、站内信流程都是“组装内容—发送—记录结果”但发送这一步每家渠道实现完全不同。这时候抽象类把公共流程写好把发送逻辑声明为抽象方法让每个渠道各自去填。很多人问我为什么要折腾这么一层直接把方法写在各个类里不香吗答案藏在“维护”两个字里。没有抽象类约束时你写邮件渠道就自由定义send_email()短信渠道写send_sms()站内信写push_message()。等到业务要增加公共逻辑——比如所有渠道发送前统一做风控检查你得打开三个文件改三遍而有了抽象类只需在父类或外部统一切入点改一次牵一发动全身的问题就变成了牵一发而全动。抽象类本质上是在给“共同规则”安了一个固定的、唯一的家。再深挖一层抽象类的价值还在于“编译期/运行期的规范校验”。在C里如果你继承抽象类却没有实现全部纯虚函数编译器会直接拒绝类就实例化不了错误在写代码阶段就被拦下。Python虽然运行时才检查但通过abc模块也能在实例化瞬间暴露出你忘了实现的方法。这两道关卡的意义在于团队合作时抽象类就是一份可执行的接口文档比任何注释都靠得住。2. C 里的抽象类纯虚函数与多态的基石2.1 纯虚函数的定义语法C 的抽象类靠纯虚函数撑起来。所谓纯虚函数就是“只声明实现约定、不提供具体实现”的成员函数末尾加上 0就标记为纯虚函数。一个类里只要存在哪怕一个纯虚函数这个类就成为抽象类不能创建对象只能当基类被继承。#include iostream #include string class MessageSender { public: // 抽象类通常要声明虚析构函数避免子类释放不完整 virtual ~MessageSender() default; // 非抽象方法公共流程已经在父类写好 void send(const std::string content) { if (!preCheck(content)) { std::cout pre-check failed, abort. std::endl; return; } doSend(content); postLog(content); } protected: // 纯虚函数子类必须实现 virtual void doSend(const std::string content) 0; // 统一的风控检查也可以被重写覆盖 virtual bool preCheck(const std::string content) { return !content.empty(); } void postLog(const std::string content) { std::cout already sent: content std::endl; } };注意send()是普通成员函数里面调用了纯虚函数doSend()。C 允许在非抽象方法中调用纯虚函数这是模板方法模式的核心用法。父类把整个发送流程串起来子类只需要填doSend()这一个坑。2.2 子类继承与“补完”子类接管一个抽象类必须全部实现其纯虚函数否则子类依然是抽象类、依然不能实例化。下面是一个具体渠道的写法class EmailSender : public MessageSender { protected: void doSend(const std::string content) override { std::cout [Email] sending... content std::endl; } }; class SmsSender : public MessageSender { protected: void doSend(const std::string content) override { std::cout [SMS] sending... content std::endl; } };override关键字是我强烈建议强制使用的。它的作用是告诉编译器“我在重写父类的虚函数”万一父类方法签名改了或者你拼写错了编译器会立刻报错而不是让你在运行时才发觉调用到了错误版本。这是现代 C 里成本最低但收益极高的一道防线。使用时的形态也很关键int main() { EmailSender email; SmsSender sms; email.send(hello email); sms.send(hello sms); return 0; }也可以借助基类指针实现真正的多态#include memory #include vector int main() { std::vectorstd::unique_ptrMessageSender senders; senders.emplace_back(std::make_uniqueEmailSender()); senders.emplace_back(std::make_uniqueSmsSender()); for (auto sender : senders) { sender-send(notification); } return 0; }这里有个很多人踩过的坑std::unique_ptrMessageSender在析构时需要通过基类指针删除派生类对象。如果MessageSender的析构函数不是虚函数删掉派生类时派生部分不会被正确释放行为未定义实际表现可能是内存泄漏也可能是傻眼崩溃。所以规则很简单——凡是被继承的基类析构函数一律声明为virtual。2.3 抽象类的误用与绕过手段抽象类在 C 里还有一个容易被人忽略的细节抽象类不能直接实例化但可以有构造函数、成员变量、普通成员函数甚至可以在构造函数里调用已经实现好的普通成员函数。不过要小心构造函数中不要调用纯虚函数或未完成的虚函数。因为创建派生类对象时基类构造函数先于派生类构造函数运行此时派生类部分还没有初始化虚函数分派并不会进入派生类的版本结果很可能让你怀疑人生。反过来也常有人问我“有没有办法实例化一个抽象类”。技术上可以用指针或引用的形式指向派生类对象但你不能写出MessageSender sender;这种代码编译器直接拒绝。这个“拒绝”正是你要的防线别试图用什么灰色手段绕过它。如果确实需要一个能实例化的公共基类那说明这个类本身不该设计成抽象类应该把抽象方法抽到更深的层次去。3. Python 里的抽象类abc 模块与鸭子类型的调和3.1 为什么 Python 要有抽象类Python 是动态语言天然没有编译期类型检查所以很多人早期写代码觉得抽象类没必要“反正调用方法时只要对象有这个方法就行管它什么继承不继承”。这就是所谓鸭子类型——走起来像鸭子、叫起来像鸭子那它就是鸭子。鸭子类型让代码很灵活但灵活性一旦过了头大型项目就会出问题。同事继承你的类时可能忘记实现某个方法而错误要到运行那一刻才暴露或者方法名拼错成了do_sendd类依然可以实例化调用时抛AttributeError让人摸不着头脑。抽象类就是 Python 里把这种松散约定重新拉回纪律轨道的工具。从 Python 3.4 起abc模块提供了ABC类和abstractmethod装饰器。用起来非常顺手如下from abc import ABC, abstractmethod class MessageSender(ABC): # 公共流程已经在父类里写好了 def send(self, content: str) - None: if not self.pre_check(content): print(pre-check failed, abort.) return self.do_send(content) self.post_log(content) abstractmethod def do_send(self, content: str) - None: 子类必须实现的具体发送逻辑 def pre_check(self, content: str) - bool: return bool(content) def post_log(self, content: str) - None: print(falready sent: {content})继承它来实现子类class EmailSender(MessageSender): def do_send(self, content: str) - None: print(f[Email] sending... {content}) class SmsSender(MessageSender): def do_send(self, content: str) - None: print(f[SMS] sending... {content})这个例子几乎就是 C 那一段的翻版但有一个关键差异Python 版本必须在“有人去实例化EmailSender”时才检查do_send是否实现。如果你忘了实现方法定义类本身不会出错但只要执行EmailSender()解释器立刻抛TypeError: Cant instantiate abstract class EmailSender with abstract method do_send。3.2 抽象类 vs 鸭子类型的共存策略Python 的抽象类不是来消灭鸭子类型的它是来给鸭子类型划边界。我的习惯是判断一个对象的类型用isinstance而不是上来就拿到对象调方法当希望对象必须符合某种结构时用抽象类做“注册门槛”。abc模块还给了一种特殊能力——抽象类的注册机制。比如你写了个第三方库里的类它本身不继承你的抽象类但实际方法和行为完全满足你的要求。你可以用MessageSender.register把它注册进来之后isinstance(那个对象, MessageSender)就返回True。这不是传统意义上的继承却能让鸭子类型和抽象类结合起来class WeChatSender: def do_send(self, content: str) - None: print(f[WeChat] sending... {content}) MessageSender.register(WeChatSender) wc WeChatSender() print(isinstance(wc, MessageSender)) # True注意注册进来的类不会获得抽象类里的任何方法实现也不会被abstractmethod检查约束。它只影响isinstance和issubclass的判断结果。这个特性适合处理“外部库的类不能改造它但希望纳入自己的类型体系”的场景。3.3 Python 和 C 在抽象类上的差异对比两国语言形态不同抽象类的具体玩法差异很大。我把核心差异列成一个表大家日常对比着看对比维度CPython定义方式virtual func() 0abstractmethod装饰方法抽象类声明包含纯虚函数的类继承ABC的类未实现方法的检查时机编译期报错运行期实例化时报错构造函数/析构函数有且析构需virtual有__init__机制无析构概念多重继承支持但复杂菱形问题支持配合Mixin更常见接口 vs 抽象类用纯抽象类表达接口用抽象类或Protocol表达类型检查静态强类型运行时isinstance这个表格不只是在罗列语法区别更重要的是提醒我们在 C 里编译器是你的守门员错误成本最低在 Python 里守门员换成了解释器但只要你勤于用抽象类做约束项目规模上去之后维护成本并不会比静态语言差多少。所谓“动态一时爽重构火葬场”用抽象类就是给动态代码上保险。4. 从抽象类到接口两种约定契约的分工4.1 抽象类和接口的本质区别抽象类和接口的对比几乎是面试和技术社区永远的热点。我直接给结论抽象类侧重“复用 约定”接口侧重“纯约定”。抽象类可以把公共逻辑直接写进父类子类只用填坑接口则只规定“有什么行为”不管行为怎么实现。打个比方更能说明白抽象类是一张半成品的预制菜厨房已经帮你洗好切好配好料你要做的是开火按步骤炒接口是一份菜单只标明菜品名称和价格区间返回类型后厨具体类怎么做完全自由。如果你是做大型系统菜单式的职责划分比半成品更适合模块解耦。在 C 里接口一般用“所有方法都是纯虚函数、没有任何成员变量和已实现方法”的抽象类来模拟。而在 Java、C# 这类语言里接口是一个独立关键字依赖接口而不是具体类就成了最基础的设计习惯。Python 里则从 3.8 开始引入Protocol用于结构化子类型检查定义方法签名而不要求继承关系这其实更接近“接口的鸭子类型版本”。4.2 选抽象类还是接口四个判断标准很多人纠结一个场景我已经有了抽象类还要不要抽一个接口我认为可以先看四条准则是否有可复用的公共逻辑公共逻辑占比越高越应该放在抽象类里否则方法到处重复你会难受死。是否强调“是什么”is-a关系Cat继承Animal抽象类自然表达“猫是一种动物”此时用抽象类。是否需要跨继承树统一契约比如Dog继承AnimalRobotDog继承Robot两者毫无亲缘关系但都要能walk()。此时应当抽接口Walker让两边各自实现避免强行造一个虚假的共同基类。是否关心行为组合接口适合做多行为组合比如Flyable加Swimmable加Runnable一个类可以实现多个接口抽象类只能继承一个C 虽支持多继承但现实里没人愿意踩菱形继承的地雷。4.3 组合优于继承的现代设计思路近些年写业务系统我越来越倾向于“接口 组合”而不是“深继承链”。抽象类里的公共逻辑也可以抽成独立的策略类或者辅助函数让领域类不依赖继承通道来获得公共行为。比如pre_check、post_log完全可以是一个外部工具函数的集合或者一个NotificationLogger对象。但这不是说抽象类该退出历史舞台。抽象类最适合的场景是“模板方法模式”——父类控制流程子类提供细节流程本身相对稳定细节可能频繁变化。像是数据导入器、批处理任务、支付渠道接入都是在固定骨架里换细节抽象类用起来非常顺手。反过来如果你的需求是“一个类能从多处借来能力”组合和接口才是正解。5. 实战构造一个“订单计费引擎”的抽象类骨架5.1 业务场景与设计思路聊天聊了这么久下面写个完整的小实战做一个订单计费引擎支持多种促销规则每种规则计算折扣的方式不同但整体流程相同——校验规则是否适用、计算优惠金额、生成结果记录。我选这个场景是因为它是“抽象类模板方法”的典型计费流程的骨架是不变的变化点在“怎么计算折扣”天然适合抽象类来约束。同时这个场景也特别容易扩展后续加“满减”“限时折扣”“会员折扣”规则时只需要新增子类。设计准备两层顶层抽象类DiscountRule定义整套计算流程不同子类实现各自的计算逻辑。为了让 C 和 Python 的对比感更强我两边都用同一套业务逻辑来写。5.2 C 实现订单计费引擎先定义抽象基类#include iostream #include string struct OrderContext { double amount; std::string userLevel; }; struct DiscountResult { double discountAmount; std::string ruleName; }; class DiscountRule { public: virtual ~DiscountRule() default; // 模板方法定义优惠计算的完整流程 DiscountResult apply(const OrderContext order) { if (!isApplicable(order)) { return {0.0, none}; } double rawDiscount calculate(order); double finalDiscount clampDiscount(rawDiscount, order.amount); return {finalDiscount, ruleName()}; } protected: // 以下的纯虚函数都要子类去实现 virtual bool isApplicable(const OrderContext order) const 0; virtual double calculate(const OrderContext order) const 0; virtual std::string ruleName() const 0; double clampDiscount(double discount, double amount) const { if (discount 0) return 0.0; if (discount amount) return amount; return discount; } };然后实现两类规则class FullReductionRule : public DiscountRule { protected: bool isApplicable(const OrderContext order) const override { return order.amount 300.0; } double calculate(const OrderContext order) const override { // 满300减50 return 50.0; } std::string ruleName() const override { return full-reduction-300-50; } }; class MemberDiscountRule : public DiscountRule { protected: bool isApplicable(const OrderContext order) const override { return order.userLevel gold || order.userLevel diamond; } double calculate(const OrderContext order) const override { double ratio (order.userLevel gold) ? 0.9 : 0.85; return order.amount * (1.0 - ratio); } std::string ruleName() const override { return member-discount; } };使用起来#include vector #include memory int main() { std::vectorstd::unique_ptrDiscountRule rules; rules.emplace_back(std::make_uniqueFullReductionRule()); rules.emplace_back(std::make_uniqueMemberDiscountRule()); OrderContext order{680.0, gold}; for (const auto rule : rules) { DiscountResult result rule-apply(order); if (result.discountAmount 0) { std::cout [ result.ruleName ] discount: result.discountAmount std::endl; } } return 0; }输出会打印出两条优惠结果。这里有几个关键点值得展开isApplicable是纯虚函数迫使子类必须判断规则是否适用calculate也是纯虚函数让每个规则自己算而apply作为普通成员函数已经写好了“先检查→再计算→最后封顶”的流程子类完全不用碰。万一以后要加“优惠金额超过订单金额则按订单金额算”的兜底逻辑只需要改父类的clampDiscount所有规则自动生效。5.3 Python 实现订单计费引擎from abc import ABC, abstractmethod class DiscountRule(ABC): def apply(self, order): if not self.is_applicable(order): return {rule_name: none, discount: 0.0} raw_discount self.calculate(order) final_discount self._clamp_discount(raw_discount, order[amount]) return {rule_name: self.rule_name(), discount: final_discount} abstractmethod def is_applicable(self, order): ... abstractmethod def calculate(self, order): ... abstractmethod def rule_name(self): ... def _clamp_discount(self, discount, amount): if discount 0: return 0.0 if discount amount: return amount return discount class FullReductionRule(DiscountRule): def is_applicable(self, order): return order[amount] 300.0 def calculate(self, order): return 50.0 def rule_name(self): return full-reduction-300-50 class MemberDiscountRule(DiscountRule): def is_applicable(self, order): return order[user_level] in (gold, diamond) def calculate(self, order): ratio 0.9 if order[user_level] gold else 0.85 return order[amount] * (1.0 - ratio) def rule_name(self): return member-discount order {amount: 680.0, user_level: gold} rules [FullReductionRule(), MemberDiscountRule()] for rule in rules: result rule.apply(order) if result[discount] 0: print(f[{result[rule_name]}] discount: {result[discount]:.2f})Python 版多了样东西——子类只实现了三个抽象方法_clamp_discount这类工具函数放在父类里子类可以用也可以在被继承时自然获得。但要注意abstractmethod装饰的方法同样可以有实现体Python 允许你在抽象方法里写默认逻辑然后子类用super()去调它。不过业务上不太推荐容易让约定变得模糊。5.4 两个版本的设计对比与扩展方向C 和 Python 的计费引擎在结构上完全对齐这恰好说明抽象类的设计思想跨越语言界限。C 版本的优势是编译期就能拦截“忘实现”的问题Python 版本的优点是修改规则后不用重新编译适合业务快速试错。我个人建议后续扩展可以先做这几件小事给规则加优先级排序把规则判定做成组合模式引入策略注册表根据优惠码自动匹配规则类。这些升级都不需要改动现有抽象类的结构加新类或者调整调用聚合的地方即可。6. 设计不规范会踩的坑抽象类使用的经验教训速查我积累了不少抽象类设计上的失败案例这里挑几个高频的整理成速查表。这些坑不解决代码后期必然恶化。问题现象解决方案抽象类里塞了太多公共业务逻辑子类被迫接受一堆用不上或影响正确性的父行为公共逻辑应尽量低层、无业务倾向过于个性化的逻辑放到各自子类或用组合抽象类层次过深类继承链达到三层以上改动底层牵一发动全身优先组合或接口限制继承深度忘记在 C 中声明虚析构出现诡异的内存泄漏或崩溃所有基类都声明virtual ~Base() default;Python 中忘写abstractmethod抽象类变得可以被实例化失去约束检查类是否继承ABC且每个拟抽象方法都加上装饰器子类大面积重写非抽象方法模板方法形同虚设内容不一致非抽象方法应声明为final或谨慎设计为“可扩展点”而非随意重写抽象方法命名不统一调用方需要靠散落的文档记住哪个子类用了哪个方法名强制只在父类声明的抽象方法里提供统一入口在基类构造函数里调用虚函数派生类状态未初始化运行时数据错乱遵守“构造期间不要分派虚函数”的规则依赖延迟初始化再说说排查经验。碰到 Python 里莫名其妙的TypeError: Cant instantiate abstract class ... with abstract method时别慌那消息会直接列出你没实现的方法名优先去补对应子类实现即可。C 编译器报的错会指向纯虚函数位置写明“invalid new-expression of abstract class type”检查哪个 0还没被覆盖。除此之外还有一个容易被忽略的维护性问题抽象类里方法的访问权限。C 中我习惯把纯虚函数放在protected区公开暴露的是apply()这种模板方法不让外部乱七八糟地直接调用计算逻辑。Python 中用下划线暗示受保护更多靠团队约定。权限设计得清楚抽象类的“半成品”边界才会稳定别人在使用它时才不会踩进实现细节里。7. 从抽象类到框架思维我的一点个人体会聊到这里我想说点顺手经验之外的思考。这几年从业务系统到开源项目我越来越觉得抽象类不只是语法层面的东西它是一种框架思维的训练。练习抽象类设计本质上是在练习能不能把变化的部分和稳定的部分切开让变化的部分各自独立生长让稳定的部分固若金汤。这是所有好的库和框架的内核也是 AI 编程时代写提示词时最值钱的思维——你给 AI 描述一个功能需求时如果脑子里能先勾勒出“哪些是稳定流程哪些是开放接口”生成出来的代码会规整好几个量级。先说个实操心得设计抽象类的第一步永远是把“流程动词”列出来。不要从类名开始而从方法序列开始。比如计费校验→计算→封顶→返回。把动词定下来再分析动词里哪些是通用的、哪些是各实例不同的最后才落到类结构上。这个习惯帮我少走了很多弯路。再说个团队协作经验抽象类写完后给出一个“最小实现示例”比写十页文档都有用。我在团队里定了一条规矩所有抽象类提交时都要附一个内置在注释里的几十行示例实现。新人接手时不需要看文档猜“这个方法该返回什么”直接看注释示例就能跑通。最后给你一个具体建议从今天起可以挑自己项目里最容易变的两个业务场景试着用抽象类把流程骨架抽出来。第一次写很可能觉得别扭多练两三次后你会慢慢形成一种“先找骨架再填细节”的条件反射。抽象类的价值不会直观地体现在某一次运行的输出里但会在你三个月后加需求、半年后带新人、一年后重构系统时悄悄地替你节省大把时间。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →