ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++多态实战:从虚函数表到策略模式重构

2026/10/1 3:49:18 拓冰建站 浏览量
C++多态实战:从虚函数表到策略模式重构 1. 多态到底是什么先从一句上台领奖说起多态这个词我第一次听到是在一门面向对象程序设计课上。老师在黑板上画了一堆箭头和方框讲虚函数表指针怎么指向虚函数表讲了整整一节课我下课之后唯一的感受是能编译但不知道它到底有什么用。真正让我搞懂多态是后来接手一个爬虫项目里面有一段六百多行的if-else每加一种数据源就要改一次。那天晚上我把它拆成了几个类加一个数据源只需要新写一个文件那一刻我才明白多态不是一个考试概念它是一种消除分支的手段。而在 C 里多态、封装、继承这三个词几乎永远绑在一起讲很多人把它们当成三个独立的知识点背下来其实它们是一条链上的三个环节缺一个都转不起来。我打算把这篇写成一份可以边看边敲的实操记录。不管你是刚学完类与对象的学生还是在写业务代码时被if-else反复折磨的工程师都可以按下面的顺序来先用生活类比建立直觉再看三种多态的底层实现差异然后拿 C 从头写一个能跑的例子顺带把几个必踩的坑一个一个填掉最后看 Java、Python、Go 里多态是怎么换汤不换药的并且给出一套把老代码重构成多态的具体步骤。整个过程我会把为什么这么设计讲清楚而不是只丢一段代码让你抄。提示这篇的重心是能落地。如果你时间紧张可以先跳到第 3 章把 C 的例子跑通再回头补第 2 章的原理理解会顺畅很多。1.1 一个生活类比同一句表彰优秀学生不同人做不同的事学校开表彰大会主持人念到名字请上台领奖。接下来会发生什么三好学生上台领奖状、做学习经验分享学科竞赛获奖者上台领奖杯、展示作品体育特长生上台领奖牌、绕场一周。主持人说的那句话是一样的动作指令是一样的但每个人执行的具体行为完全不同。这就是多态最朴素的样子同一个调用接口在不同对象身上产生不同的行为。再往下拆一层。主持人为什么不需要知道上台的是谁因为他手里只有一份名单名单上每个人的类型都是学生而学生这个抽象类型上定义了领奖这个动作。至于具体是领奖状还是领奖杯由对象自己决定。主持人不需要写这样的逻辑if (是竞赛获奖者) { 让他展示作品; } else if (是体育特长生) { 让他绕场一周; } else if (是三好学生) { 让他做经验分享; }如果真这么写明年学校新增一个科技创新奖主持人手里的流程稿就得改一遍。多态的价值就在这把判断是谁这件事从调用方转移到对象自己身上。新增一种学生主持人一个字都不用改只要新学生自己知道上台该干嘛就行。这个类比里其实已经藏了封装和继承每个学生的具体信息姓名、班级、奖项细节被封装在自己内部外部只看得到一个学生接口而各种学生类型共享同一个父类型学生这就是继承关系。所以行业内常说的封装继承多态不是三句口号而是一套配合封装负责把差异藏起来继承负责把共性抽出来多态负责在运行时决定用哪个差异。1.2 把生活类比翻译成代码定义换到代码世界多态的标准定义是同一操作作用于不同的对象可以有不同的解释产生不同的执行结果。注意这里的关键词是同一操作——调用方写下的代码只有一份比如s-acceptAward()但运行时实际执行的是哪个函数体取决于s指向的真实对象类型。一个严格的、教科书式的多态通常要满足三个条件我把它做成表格方便对照条件含义缺了会怎样存在继承关系派生类继承自同一个基类没有共同类型调用方无法统一处理存在方法重写派生类重新定义基类的虚方法调用时还是走基类版本行为不分化通过基类指针或引用调用用Base*/Base而不是对象本身静态绑定编译期就定死了运行时不变第三条是最容易被忽略的。很多人写完了继承也写完了重写结果调用时用的是Base b derivedObj;这种写法行为一点都不多态。原因在 3.5 节会详细讲那是对象切片一个非常隐蔽的坑。另外要澄清一个常见误解多态不等于函数重载。重载在编译期就确定了调用哪一个属于静态多态而我们平时口语里说的多态绝大多数指的是运行期多态也就是动态绑定。这两个概念经常被混着用但在面试和实际排错时区分它们能帮你省下大量时间。1.3 多态真正解决的问题把分支变成扩展我见过太多项目核心业务的复杂度其实不高绝大多数代码都消耗在判断类型然后分支上。举个例子一个订单系统要计算不同会员等级的折扣double calcDiscount(const Order order) { if (order.userType normal) return order.amount * 1.0; else if (order.userType silver) return order.amount * 0.95; else if (order.userType gold) return order.amount * 0.88; else if (order.userType diamond) return order.amount * 0.80; // 再来一个内部员工继续加分支 }这段代码的问题不在于它跑不起来而在于它每次增加一种会员类型都要修改一个已经被测试过的函数。修改就有回归风险风险就得靠测试覆盖来兜底而测试覆盖率往往跟不上业务扩张的速度。这是典型的改动扩散。用多态改写之后calcDiscount变成一句order.user().discountRate()新增会员类型只需要新增一个类文件。这就是设计原则里说的开闭原则对扩展开放对修改关闭。这也是我在实际项目中最看重多态的原因——它不是为了代码看起来高级而是为了让改动的影响范围收敛在一个文件里。注意不要为了用多态而用多态。如果分支只有两三个而且未来基本不会增加老老实实写if-else反而更清晰。多态的价值随分支数量和变更频率上升别把它当成教条。2. 三种多态形态与它们的底层实现差异理解多态光知道定义是不够的因为C 的多态和Python 的多态在底层完全是两回事。我把它分成三类编译期多态、运行期多态、以及动态类型语言里的鸭子类型。搞清楚这三类你在读别人的代码或者跨语言迁移时就不会糊涂。2.1 编译期多态函数重载、模板、宏编译期多态的特点是调用哪个函数在代码编译成机器码之前就已经确定。最常见的三种形式是函数重载、模板泛型、以及运算符重载。函数重载为什么算多态因为你写下的名字是同一个print但编译器会根据实参类型在符号表里挑一个匹配的版本。这里的选择者是编译器不是运行时。模板更进一步同一份源码会被实例化成多个具体版本每个版本都是独立编译的。template typename T T maxOf(T a, T b) { return a b ? a : b; } int a maxOf(3, 5); // 实例化为 int 版本 double b maxOf(3.2, 5.1); // 实例化为 double 版本模板的好处是零运行时开销因为调用是直接跳转甚至可能被内联展开。代价是代码体积膨胀以及编译错误信息极其难读——写过 STL 报错的朋友应该有体会一个类型不匹配能给你吐出两百行模板展开轨迹。还有一种被低估的编译期多态手法CRTP奇异递归模板模式让派生类把自己作为模板参数传给基类从而在编译期完成重写。它在高性能库比如数学库、序列化库里很常见因为它能拿到虚函数的效果却不付虚函数表的代价。缺点是代码可读性下降调试困难新手慎用除非你确实测出了虚调用是瓶颈。2.2 运行期多态虚函数与虚函数表真正的动态多态靠的是虚函数。C 标准并没有规定虚函数必须怎么实现但所有主流编译器都采用同一套方案给每个含虚函数的类生成一张虚函数表vtable表里按固定顺序存放该类各个虚函数的地址每个对象里悄悄塞一个虚表指针vptr指向自己所属类的虚函数表。调用s-acceptAward()时实际发生的事情大致是从s指向的内存里取出 vptr用 vptr 找到 vtable再按acceptAward在表里的固定偏移取出函数地址最后跳过去执行。因为s指向的真实对象不同vptr 不同取到的函数地址就不同行为自然就分化了。这套机制有几个直接后果都是实操中会碰到的对象变大。含虚函数的类每个实例会多出一个指针大小64 位下通常是 8 字节。你可以用sizeof验证空类通常是 1 字节而只加一个虚函数就变成 8 字节。调用变慢。多了一次间接寻址而且间接跳转对 CPU 分支预测器不友好。不过在绝大多数业务场景里这点开销完全可以忽略别过早优化。构造和析构期间不生效。这一点下面会单独讲是个高频坑点。2.3 一次虚调用的完整旅程把上面拆开来看会更清楚。假设有基类Student和派生类MeritStudent编译后内存里大概是这样MeritStudent 的虚函数表vtable ---------------------- | MeritStudent::~MeritStudent | - 析构 | MeritStudent::acceptAward | - 重写版本 | MeritStudent::title | ---------------------- 一个 MeritStudent 对象 ----------------------------------- | vptr --- | 上面那张表 | ----------------------------------- | name_ | 张三 | -----------------------------------当你写下Student* p new MeritStudent(张三); p-acceptAward();编译器生成的代码逻辑是从p指向的地址读出 vptr从 vptr 指向的表里取第 1 项acceptAward的槽位调用这个地址。而如果p指向的是普通Student对象第 2 步取到的就是基类版本。同一个槽位两张表两个结果——这就是动态绑定的全部秘密。vtable 的存在也解释了为什么虚函数的槽位顺序在单继承下是稳定的派生类重写只是替换了表里对应位置的内容。2.4 动态类型语言里的多态鸭子类型Python、JavaScript、Ruby 这类语言走的是另一条路。它们不看继承关系只看对象有没有这个方法也就是常说的鸭子类型如果它走起来像鸭子、叫起来像鸭子那就当鸭子用。def process(obj): obj.accept_award() class MeritStudent: def accept_award(self): print(上台领奖状做经验分享) class AthleteStudent: def accept_award(self): print(上台领奖牌绕场一周) process(MeritStudent()) process(AthleteStudent())注意这里两个类没有任何共同父类process也没有声明参数类型它照样能跑。原因在于 Python 的方法调用是在运行时按名字查属性字典的找到就调用找不到就抛AttributeError。这种机制的灵活性极高代价是错误只能在运行时暴露——这也是为什么大型 Python 项目会引入类型注解和abc抽象基类用约定把接口补回来。我用过的一个判断标准是如果你希望忘记实现某个方法这件事在编译期就被发现那就用静态类型语言的多态如果你更看重灵活拼装和小步快跑动态语言的鸭子类型会更舒服。两者没有优劣只有场景匹配。3. C 多态实操从零写一个可运行的例子理论说完了我们动手。下面这部分是我自己写教学示例时用的一版代码不长但把关键点都覆盖到了。你可以直接复制到一个.cpp文件里编译运行。3.1 环境准备与最小工程我用的环境是 g 或者 clang标准选 C17。之所以建议至少 C11是因为override、final、unique_ptr、std::move这些让代码更安全的特性都是从这个版本开始普及的。# 检查编译器版本 g --version # 编译开启常用告警这一步很重要 g -stdc17 -Wall -Wextra -Wpedantic -g main.cpp -o award_demo ./award_demo我这里特意加上-Wall -Wextra -Wpedantic。原因是在多态这块很多错误比如签名写错导致的隐藏而不是重写、缺少虚析构函数编译器其实能给你提示只是默认不打开。养成开告警的习惯等于让编译器帮你做一次静态检查。实操心得如果你用的是 IDE记得把告警级别调到最高并且把警告当成错误来处理GCC 的-Werror。多态相关的坑一半以上可以在这一步被拦住。3.2 写出虚函数版本先看基类和两个派生类#include iostream #include memory #include string #include vector class Student { public: explicit Student(std::string name) : name_(std::move(name)) {} virtual ~Student() default; // 关键虚析构 virtual void acceptAward() const { // 关键virtual std::cout name_ 上台领取荣誉证书\n; } virtual std::string title() const { return 学生; } protected: std::string name_; }; class MeritStudent : public Student { public: using Student::Student; void acceptAward() const override { // 关键override std::cout name_ 上台领取三好学生奖状做学习经验分享\n; } std::string title() const override { return 三好学生; } }; class AthleteStudent : public Student { public: using Student::Student; void acceptAward() const override { std::cout name_ 上台领取奖牌绕场一周\n; } std::string title() const override { return 体育特长生; } }; int main() { std::vectorstd::unique_ptrStudent roster; roster.push_back(std::make_uniqueMeritStudent(张三)); roster.push_back(std::make_uniqueAthleteStudent(李四)); roster.push_back(std::make_uniqueStudent(王五)); for (const auto s : roster) { std::cout [ s-title() ] ; s-acceptAward(); } return 0; }输出大概是[三好学生] 张三 上台领取三好学生奖状做学习经验分享 [体育特长生] 李四 上台领取奖牌绕场一周 [学生] 王五 上台领取荣誉证书请注意main里那段循环它只认识Student这个类型acceptAward也只写了一次。新增一种学生类型这个循环一个字都不用改。这就是 1.3 节讲的把分支变成扩展。另外title()用来打印类型名这也是多态的一个常规用法——用虚函数替代运行时的类型判断避免写dynamic_cast或者枚举判断。3.3 三个关键字virtual、override、final 的取舍virtual是开关override和final是护栏。很多老代码只写virtual不写后面两个结果签名写错的时候编译器一声不吭程序行为诡异。关键字作用建议virtual声明该函数参与动态绑定基类中所有可被重写的方法都加override断言这是重写签名不匹配直接编译报错派生类中一律加别省final禁止继续重写或禁止类被继承明确不打算扩展的地方加上 0纯虚函数基类不可实例化接口类 / 抽象基类中使用我踩过的一个真实场景基类里声明的是virtual void save() const;派生类里写成了void save();少了const。编译器没报错编译通过运行时调用的是基类版本数据没保存问题排查了两个小时。加上override之后这个错误在编译期就暴露了error: void MeritStudent::save() marked override, but does not override所以我的习惯是只要写了virtual派生类必定写override一个字都不省。这不是风格问题是止损问题。至于final它的价值在于表达设计意图。如果你设计了一个DiscountPolicy基类但明确不想让别人继承某个具体策略类加上final既能让编译器做去虚化优化把间接调用变成直接调用也让读代码的人一眼知道边界在哪。3.4 虚析构函数一个必须记住的坑这是 C 多态里最容易造成内存泄漏的地方也是一个实实在在的运行时问题。class Base { public: ~Base() { std::cout ~Base\n; } virtual void foo() {} }; class Derived : public Base { public: ~Derived() { std::cout ~Derived\n; } std::string bigData std::string(1024 * 1024, x); // 一大块内存 }; int main() { Base* p new Derived(); delete p; // 只输出了 ~Base~Derived 没被调用 }运行结果只有一行~BaseDerived的析构函数压根没执行那块字符串内存没人释放。原因是delete p时编译器看p的静态类型是Base如果析构函数不是虚函数就静态绑定到Base::~Base。修法很简单把基类的析构函数声明为virtual。class Base { public: virtual ~Base() default; virtual void foo() {} };加上之后delete p会先调用Derived::~Derived再调用Base::~Base。所以我在项目里立的规矩是只要一个类含有虚函数它的析构函数必须声明为虚函数。如果你的基类只是想做个纯接口、不允许被直接实例化那更简单直接用 default加virtual就够了。注意反过来也要小心。如果基类不打算被多态删除比如只是实现继承而非接口继承就不要随便加虚析构因为那会给所有对象加上 vptr增大体积。判断标准是这个对象会不会通过基类指针被delete。会就必须虚析构。3.5 对象切片为什么按值传参会让多态失效第二个大坑是对象切片。看下面两段代码void showByValue(Student s) { // 按值传递 s.acceptAward(); } void showByRef(const Student s) { // 按引用传递 s.acceptAward(); } int main() { MeritStudent m(张三); showByValue(m); // 输出张三 上台领取荣誉证书 - 基类版本 showByRef(m); // 输出张三 上台领取三好学生奖状 - 正确 }showByValue的参数类型是Student不是引用也不是指针。传参时发生的是拷贝构造编译器用Student的拷贝构造函数去构造参数MeritStudent里多出来的那部分包括 vptr 的指向被切掉了只剩下基类那部分。结果对象变成了一具只有基类躯壳的实体虚调用自然走基类版本。这个坑最阴险的地方在于它能编译、能运行、不报错、不崩溃只是行为不对。所以我给的建议是硬性的在多态场景下永远通过基类的指针或引用来传递对象绝不要按值传递。如果你需要所有权转移用std::unique_ptrStudent如果你只是借用用const Student。这两种写法我都用看场景选。另外提醒一下C 里还有第三种std::reference_wrapperStudent用在你需要把一堆多态对象塞进容器的场合标准容器不能存引用实际写起来是这样的std::vectorstd::reference_wrapperStudent refs; MeritStudent m(张三); refs.push_back(m); refs[0].get().acceptAward();3.6 纯虚函数与抽象基类的设计取舍当你希望基类只作为契约存在不允许被直接实例化时就用纯虚函数class AwardPolicy { public: virtual ~AwardPolicy() default; virtual double discount(double amount) const 0; // 纯虚 virtual std::string name() const 0; };一旦有纯虚函数这个类就成了抽象类AwardPolicy p;会直接编译失败。这对于接口设计是好事它强制每个派生类必须给出实现避免出现忘了重写的情况。那什么时候用普通虚函数带默认实现什么时候用纯虚我的判断标准是这样的如果所有派生类都会有一个合理且相同的默认行为用虚函数 默认实现派生类需要时再重写。如果每个派生类的行为必然不同用纯虚函数逼迫实现者想清楚。比如支付渠道的pay()不可能有默认实现就该是纯虚。如果这个抽象类里有一些公共的、不变的逻辑可以放在非虚函数里内部调用虚函数——这叫非虚接口模式。它的好处是把流程骨架固定住只留可变部分给派生类。class AwardPolicy { public: virtual ~AwardPolicy() default; double calc(double amount) const { // 非虚流程固定 return amount * rate(); // 只把可变部分虚化 } protected: virtual double rate() const 0; };这种写法在实际项目里非常实用因为它把前置校验、日志、边界处理这类不变代码收敛到基类派生类只写核心差异代码量能减掉一大半。4. 其他语言里的多态实现速览多态是面向对象的通用思想但不同语言给的工具不一样。搞懂这些差异跨语言迁移的时候会轻松很多。我在一份对照表之外挑几个最容易被踩的点展开说。4.1 Java接口 抽象类 动态绑定Java 的默认行为比 C 简单粗暴普通实例方法默认就是虚方法不需要写virtualJava 也确实没这个关键字。想禁止重写得显式加final。interface Award { void accept(); } class MeritStudent implements Award { private final String name; MeritStudent(String name) { this.name name; } Override public void accept() { System.out.println(name 上台领取三好学生奖状); } }Java 里区分interface和abstract class的常见说法是接口表达能力能做什么抽象类表达身份是什么。一个类可以实现多个接口但只能继承一个抽象类。所以在设计时如果这个抽象只描述一组行为契约用接口如果要共享状态和部分实现用抽象类。另外Java 8 之后接口可以有default方法这让接口和抽象类的边界变得模糊了一些。我的经验是如果你需要成员变量用抽象类如果只需要行为用接口规则简单不容易出错。4.2 Python鸭子类型与抽象基类Python 默认走鸭子类型不需要继承也能多态。但大型项目里我强烈建议在关键接口上用abc做一层约束理由是能在实例化阶段就发现问题from abc import ABC, abstractmethod class AwardPolicy(ABC): abstractmethod def rate(self) - float: ... class GoldPolicy(AwardPolicy): def rate(self) - float: return 0.88 # GoldPolicy() # 可以正常实例化 # AwardPolicy() # TypeError: Cant instantiate abstract class还有一个 Python 特有的工具是typing.Protocol它做的是结构化子类型相当于给鸭子类型加上静态检查。你不用改任何现有类只要它符合 Protocol 声明的方法签名类型检查器就会认。from typing import Protocol class HasAccept(Protocol): def accept_award(self) - None: ... def process(obj: HasAccept) - None: obj.accept_award()这种方式特别适合在不能修改第三方类的情况下引入类型约束我在给老项目补类型注解时用得很频繁。4.3 Go隐式接口实现Go 的接口是隐式实现的不需要显式声明implements。只要一个类型拥有接口里声明的全部方法它就自动满足这个接口。type Award interface { Accept() } type MeritStudent struct{ Name string } func (m MeritStudent) Accept() { fmt.Printf(%s 上台领取三好学生奖状\n, m.Name) } func Process(a Award) { a.Accept() }Go 的这套设计与 Java 相反Java 是我声明我要实现哪个接口Go 是接口回头看谁满足我。前者的好处是意图明确后者的好处是解耦——接口可以定义在使用方而不是实现方。写 Go 的时候我习惯在使用方定义接口并且接口尽量小一个方法最好这几乎是 Go 社区的共同风格。4.4 多语言多态实现对照语言动态绑定开关接口声明方式抽象基类常见坑C需显式virtual纯虚函数类纯虚函数漏虚析构、对象切片Java默认开启interfaceabstract class误加final静态方法不能多态Python天然支持ABC/ProtocolABC运行时才报错拼写错误难查Go接口方法interface隐式满足无值接收者与指针接收者的方法集差异C#需virtual默认非虚interfaceabstract class与 Java 相反默认不可重写提示Go 里值接收者和指针接收者的方法集是不同的。如果接口方法定义在指针接收者上只有*T能满足接口T不行。这个坑我第一次写 Go 的时候调了半小时。5. 常见问题与排查技巧实录多态相关的 bug 有一个共同特点编译能过行为不对。这类问题最难查因为它们不给你任何提示。下面整理了几类我实际遇到过的问题按现象—原因—解法组织成速查表再补充几条不成文的经验。5.1 编译期问题速查现象可能原因解法marked override but does not override签名不一致漏const、参数类型不同、参数个数不同逐字符比对签名或直接复制基类声明派生类调用基类同名重载失败名字隐藏派生类定义同名函数会隐藏基类所有重载在派生类写using Base::func;cannot instantiate abstract class有纯虚函数未实现实现全部纯虚函数或继续声明为抽象类delete指向基类的指针报未定义行为基类析构非虚基类析构改为virtual模板报错几百行看不懂类型不匹配触发深层实例化失败从最后一行往前读找第一个提到自己类型的错误其中名字隐藏这条我单独说一下因为它在实际项目里出现频率极高。假设基类有两个重载void log(int)和void log(const std::string)派生类只想重写其中一个于是写了void log(int) override;。结果是派生类对象上调用log(hello)直接编译失败因为std::string版本被隐藏了。修法就一句class Derived : public Base { public: using Base::log; // 把基类的其他重载引进来 void log(int) override; };5.2 运行期行为异常排查第一类调用到了基类版本。除了前面说的对象切片还有一种情况是漏写virtual。检查顺序我固定为三步一看是否按引用/指针传递二看基类函数是否有virtual三看签名是否完全一致。第二类构造函数里调用虚函数不生效。这个坑很隐蔽但原理很清晰对象构造过程中基类部分先构造此时 vptr 指向的是基类的虚函数表派生类部分还没构建所以调用的是基类版本。析构过程同理顺序反过来。所以我的原则很明确构造和析构函数里不要调用虚函数。如果确实需要构造完成后初始化这样的语义用两阶段初始化在外面显式调一个init()。第三类默认参数用的是基类的值。这个坑我自己踩过class Base { public: virtual void greet(const std::string who 同学) { std::cout 基类问候 who \n; } }; class Derived : public Base { public: void greet(const std::string who 老师) override { std::cout 派生类问候 who \n; } }; Base* p new Derived(); p-greet(); // 输出派生类问候 同学函数体是派生类的默认参数却是基类的。原因是默认参数在编译期就按静态类型绑定好了只有函数体是动态绑定。所以经验是虚函数里不要使用默认参数需要默认值就用重载或者显式传参。5.3 性能相关的几个观察很多资料说虚函数有性能损耗但具体多大最好自己测一下。我的经验数据是在有分支预测友好的场景下一次虚调用相比直接调用大概多几纳秒如果调用模式随机、预测失败率高差距会明显拉大。真正需要警惕的是在极热的循环里做大量虚调用。如果确实测出瓶颈有几个方向加final。编译器在能确定具体类型时可以做去虚化devirtualization把间接调用变成直接调用。改用编译期多态模板或 CRTP彻底消除间接跳转。批处理。把逐对象的虚调用改成一次处理一批降低调用密度。实操心得不要在没做性能剖析之前就动手优化虚函数。我见过太多人为了性能把清晰的多态设计改成一堆switch最后性能没提升多少代码可维护性却掉了一大截。先用性能分析工具找到真正的热点再决定动不动。5.4 设计层面的常见误用除了语法和性能多态在设计上的误用更值得警惕。我总结了三类第一类为了多态而多态。只有两个实现而且永远不会有第三个硬套一层抽象基类结果是多了一个文件、多了一层跳转读者还要两头翻。判断标准很简单如果一处抽象只被一个实现使用那它就不是抽象是绕路。第二类继承层次过深。三层以上的继承链会让理解成本急剧上升尤其是中间层只做了一点小改动的情况。我的经验是尽量控制在两层一个抽象基类一层具体实现。需要更多变化维度时用组合而不是继承。第三类基类塞了太多东西。一个抽象基类如果有十几个虚函数说明它承担了太多职责这时候应该拆成多个小接口。这一点上接口隔离原则和多态是配套的——多态不是让接口变大而是让接口变准。6. 落地实践把多态用在真实项目里前面讲的都是怎么写这一章讲怎么用。多态在真实项目里的典型落点有三个替换条件分支、搭建插件式架构、以及让代码变得可测试。我把这三个场景按难度递进讲一下最后给出一套重构老代码的步骤。6.1 场景一用策略模式替换 if-else最直接的落点。前面那个会员折扣的例子改成多态之后大概是这样class DiscountPolicy { public: virtual ~DiscountPolicy() default; virtual double rate() const 0; }; class NormalPolicy final : public DiscountPolicy { public: double rate() const override { return 1.0; } }; class GoldPolicy final : public DiscountPolicy { public: double rate() const override { return 0.88; } }; class Order { public: Order(double amount, std::shared_ptrconst DiscountPolicy policy) : amount_(amount), policy_(std::move(policy)) {} double payable() const { return amount_ * policy_-rate(); } private: double amount_; std::shared_ptrconst DiscountPolicy policy_; };关键点在于Order持有一个策略对象的智能指针而不是自己判断类型。这里的shared_ptrconst DiscountPolicy我特意用了const因为策略对象是无状态的多个订单可以共享同一个实例没有必要每个订单都新建一份。至于策略实例从哪来通常会有一个工厂或者注册表来做映射这一块就是下一个场景的内容。6.2 场景二插件式架构与注册表当实现类型很多、而且可能来自不同的模块甚至不同的动态库时硬编码的switch就不合适了。这时候用注册表 工厂的模式每种实现自己在启动时把自己注册进去。#include functional #include map #include memory #include string class DiscountPolicy { public: virtual ~DiscountPolicy() default; virtual double rate() const 0; }; class PolicyRegistry { public: using Creator std::functionstd::unique_ptrDiscountPolicy(); static PolicyRegistry instance() { static PolicyRegistry reg; return reg; } void add(const std::string key, Creator creator) { creators_[key] std::move(creator); } std::unique_ptrDiscountPolicy create(const std::string key) const { auto it creators_.find(key); if (it creators_.end()) return nullptr; return it-second(); } private: std::mapstd::string, Creator creators_; }; class GoldPolicy : public DiscountPolicy { public: double rate() const override { return 0.88; } static constexpr const char* kKey gold; }; // 在某个统一的注册函数里完成注册 void registerBuiltinPolicies() { PolicyRegistry::instance().add( GoldPolicy::kKey, [] { return std::make_uniqueGoldPolicy(); }); }这套写法的价值在于新增策略不需要改动任何已有代码只要写一个新文件在里面完成注册即可。工厂里那个if (it creators_.end()) return nullptr;也要小心处理返回空指针意味着调用方必须判空否则会崩溃。如果想要更安全的语义可以让它在找不到时抛异常或者返回一个默认策略。我个人偏向抛异常因为配置了不存在的策略名属于部署错误早发现比默默降级好。有一点需要提醒这种依赖静态初始化时自动注册的写法在不同翻译单元之间的初始化顺序是不确定的容易出现注册还没发生就被查询。所以我在示例里没有用全局静态对象自动注册而是提供了一个显式的初始化函数由main在启动阶段调用一次。这样顺序完全可控也更好调试。6.3 场景三让代码可测试多态还有一个经常被忽略的价值它让单元测试变得可行。举一个我经历过的例子。有个模块需要在函数执行完后发送通知原本直接调用了具体的通知类void handleOrder(const Order order) { // ... 业务逻辑 WeChatNotifier notifier; notifier.send(order.userId(), 订单已受理); }这段代码没法测试因为一跑就会真的发消息。改成依赖抽象之后class Notifier { public: virtual ~Notifier() default; virtual void send(const std::string userId, const std::string msg) 0; }; void handleOrder(const Order order, Notifier notifier) { // ... 业务逻辑 notifier.send(order.userId(), 订单已受理); }测试里传一个假的实现进去就行class FakeNotifier : public Notifier { public: std::vectorstd::pairstd::string, std::string sent; void send(const std::string userId, const std::string msg) override { sent.emplace_back(userId, msg); } };这样断言sent.size() 1就可以了。测试跑得快、无副作用、可重复。这个改造通常只需要把具体类型换成抽象引用改动很小收益很大是我在做存量代码治理时最先动手的地方。6.4 一套可复用的重构步骤最后给一套我从实践中总结出来的重构流程适用于把一大段分支逻辑改造成多态。这套流程我用了很多次基本没出过问题先补测试。在动手之前把现有分支逻辑的输入输出整理成表格写成测试用例。这一步不能省因为重构过程中你需要一个行为不变的验证标准。抽出共同接口。观察所有分支找出它们共有的那一个动作把它抽成一个抽象方法。注意只抽共性别急着把所有差异都塞进去。创建具体实现类。一个分支对应一个类先把原逻辑原样搬进去一字不改。这一步的目标是搬移不是优化。改造调用点。把原来的if-else换成一次虚调用。此时要注意参数传递方式用引用或指针别按值传。处理对象创建。决定谁来创建具体对象如果类型在编译期已知直接在调用方构造如果类型来自配置或运行时输入引入工厂或注册表。回归测试 清理。跑一遍全部测试确认行为一致。然后清理掉已经没人用的枚举、常量、辅助函数。注意第 3 步一字不改地搬移很关键。很多人一边搬一边顺手优化逻辑结果测试失败之后不知道是重构引入的问题还是优化引入的问题排查成本翻倍。分开做一次只改一件事。7. 关于多态我个人踩坑之后的三点体会第一点关于什么时候不该用多态。这个问题我最早是反着理解的觉得设计得越抽象越好。后来在一个日均请求量不大的内部系统里我为三种数据源设计了一套完整的抽象层结果半年后这三个数据源一个都没变过。那套抽象的维护成本却一直在加字段要改接口、改接口要改三个实现、测试要维护三份。所以现在的判断标准是先看变更频率再看实现数量。如果一个区域半年没动过抽象就是负债。第二点关于override这个关键字。我把它当成一种保险只要写派生类的方法就必加。它的成本是一个单词收益是把一类能编译但行为错的问题变成编译不过。这一类问题在多人协作、代码频繁合并的项目里尤其致命因为你不知道哪天别人的一次改动就把你的重写变成了隐藏。多打两个字省两个小时的排查这笔账怎么算都划算。第三点关于多态的边界。多态能让调用方不知道具体类型这是优点但也会让调用链变长、调试时不容易看出实际执行的是哪个函数。我的习惯是在关键接口上保证每个实现类的方法名和语义高度一致方便断点定位同时在日志里打上具体实现的标识。别让抽象把可观测性也一起藏起来——这是我在生产环境排障时最深刻的教训之一。最后分享一个小技巧如果你想快速判断一段代码是不是真多态就看调用方有没有出现具体类型名。如果调用方还在写GoldPolicy或者if (type gold)那它只是把分支挪了个地方并没有真正解耦。真正的多态调用方眼里只有一个抽象类型其余的事情全部交给对象自己。