前阵子帮同事做代码评审,看到他写了一个“动物园管理系统”的模块:一个方法接收动物类型参数,然后用if-else判断传入的是什么动物,分别调用不同的叫声方法。每次要新增一种动物,就得改这个方法加一个分支,改完还得小心别漏掉其他判断逻辑。我问他想过用多态吗,他愣了几秒说“多态我考试的时候背过,但真到自己写代码就用不上了。”这个对话我遇到过太多次了。多态这个概念几乎每个开发者都听过,但能用好它、能在合适的场景里主动用它来简化设计的,反而不多。这篇文章我就从实际项目经验出发,把多态从头到尾拆开讲清楚:它解决什么问题、底层原理是什么、最容易踩的坑有哪些、以及怎么在日常开发里判断“这里该用多态了”。
1. 从一次代码评审说起:多态到底能省多少事
先说那套让我印象深刻的“动物园”代码。它的逻辑大致长这样:
public void makeSound(String animalType, Animal animal) { if ("dog".equals(animalType)) { animal.bark(); } else if ("cat".equals(animalType)) { animal.meow(); } else if ("duck".equals(animalType)) { animal.quack(); } }这段代码最大的问题不在于它用了if-else,而在于它把“这个动物属于哪种类型”的判断权,交到了使用方手里。调用makeSound方法的人必须先知道动物的具体类型,才能决定调用哪个方法。今天只有狗、猫、鸭三种动物,写三个分支还不算难;明天新增一个“牛”,你要在方法里加一个else if;后天新增十种动物,这个方法就会膨胀得没法看。
而且这种写法还有个隐患:判断条件可能与真实类型不一致。比如你传了个animalType = "dog",但传入的animal对象实际是Cat的实例,调用animal.bark()就会直接报错——类型混乱的问题在这种代码里特别容易滋生。
用多态改造之后,情况完全不同:
public void makeSound(Animal animal) { animal.makeSound(); }代码量从十几行变成一行。新增动物时,你只需要让新类继承Animal并重写makeSound()方法,原来的makeSound(Animal)方法一个字都不用改。这就是多态最核心的价值——它把“做什么”和“具体怎么做”拆开了:调用方只关心“这是一只动物,让它发出叫声”,至于这只动物是狗、是猫、还是未来某天新增的麒麟,调用方一概不管。
这也恰好呼应了软件设计里的开闭原则:对扩展开放,对修改关闭。多态是这个原则最典型的实现手段之一。所以如果有人问我多态到底解决了什么问题,我的答案很直接:它让代码从“面向具体类型编程”变成“面向抽象类型编程”,让新增行为不再靠修改旧代码来实现。
可能有读者会问:那switch-case或者策略模式不也能做到吗?能,但策略模式只是把分支逻辑搬到了另一个类里,如果没有多态配合,本质上还是在做“判断类型再分发”这件事。多态是更底层、更本质的解法——它把类型判断这件事直接交给了运行时的对象本身。
2. 多态的三个必要条件:继承、重写与父类引用
明白了多态解决什么问题,接下来就得弄清它能生效的条件。很多初学者背过“继承、重写、父类引用指向子类对象”这个三件套,但既不理解为什么是这三条,也不理解三条之间怎么配合。我换个角度解释。
2.1 没有继承/实现,就没有“共同类型”
多态的前提是存在一个更抽象的类型,能让多个具体类型归到同一类下。在 Java 里这个抽象类型可以是父类,也可以是接口。
public abstract class Animal { public abstract void makeSound(); } public class Dog extends Animal { @Override public void makeSound() { System.out.println("汪汪汪"); } } public class Cat extends Animal { @Override public void makeSound() { System.out.println("喵喵喵"); } }这里Dog和Cat没有血缘关系,但通过继承Animal,它们获得了“都是 Animal”这层身份。多态要生效,第一步就是让这些具体类型拥有一个公共的抽象祖先。如果你有两个类互相之间一点关系都没有,那它们实际上是无法统一处理的,强行用Object类型接收再instanceof判断,就回到了开头说的if-else老路。
2.2 重写是“多态行为的入口”
继承只解决了类型归属问题,还不足以产生多态。关键的一步是子类重写父类的方法。
public void makeSound(Animal animal) { animal.makeSound(); }这段代码能对Dog输出“汪汪汪”、对Cat输出“喵喵喵”,就是因为Dog和Cat各自重写了makeSound()的实现。如果Dog没有重写这个方法,调用时会执行父类Animal里定义的那个默认实现,那所有动物发出的声音都一样,多态就失去意义了。
重写有个容易被忽略的规则:方法签名必须一致,返回值类型可以相同或者是父类返回值类型的子类型(协变返回)。访问修饰符不能比父类更严格——父类是
public,子类就不能改成protected。这些规则的本质目的,是保证子类重写的方法在任何使用父类引用的地方都能无缝替换。如果子类把访问权限收紧了,那父类引用在外部调用这个方法的合法性就会出问题。
2.3 父类引用指向子类对象:让编译器松手
多态的触发条件,是用父类类型的变量来持有子类类型的对象:
Animal animal = new Dog(); animal.makeSound(); // 输出“汪汪汪”注意关键点:animal这行代码的编译期类型是Animal,运行期类型是Dog。调用makeSound()时,编译器看到的是Animal类型,确认这个方法存在,就放行了;真正执行的时候,虚拟机会去看animal实际指向的对象是谁,结果发现是Dog,于是调用Dog重写后的方法。
这也是很多教程里常说的“向上转型”。它看起来只是简单地换个引用类型,但意义重大——正是因为它允许用父类变量装载不同子类对象,才让后面的动态绑定有了用武之地。你可以把ArrayList对象赋值给List变量,然后把List传入一个只认识接口的方法,方法内部不管具体是ArrayList还是LinkedList,都能正确操作。这就是面向接口编程的根基。
2.4 三个条件缺一个会怎么样
我让这三件事做过一次“分别剔除”的测试,结果很有参考价值:
| 缺失条件 | 后果 | 典型表现 |
|---|---|---|
| 没有继承/实现 | 没有共同类型,无法统一处理不同对象 | 只能用Object接参 +instanceof判断 |
| 没有重写 | 子类没有自己的行为版本 | 所有对象执行同一个父类默认实现 |
| 没有父类引用指向子类对象 | 多态代码写不出来 | 每个子类都要单独写一段调用逻辑 |
这三个条件是一个闭环,理解它们之后再去看网上讲多态的代码示例,基本一眼就能判断示例写得好不好。
3. 编译期绑定和运行期绑定:重载不是重写的替身
写过多年代码之后我发现,程序员最容易混淆的不是多态的三个条件,而是方法重载(overload)和方法重写(override)。更麻烦的是,这两个概念都跟“多态”沾边,导致很多人把重载也算作多态的一种。我之前也这么理解,直到有一次在项目里栽了跟头,才彻底弄明白两者的本质区别。
3.1 重载:编译期就决定好的事
先看一段代码:
public class Printer { public void print(Animal animal) { System.out.println("打印动物"); } public void print(Dog dog) { System.out.println("打印狗"); } }调用printer.print(animal)时,到底走哪个print方法,在编译期就确定了。编译器根据animal这个变量的声明类型来决定调用签名匹配的方法。举个例子:
Animal a = new Dog(); Printer p = new Printer(); p.print(a); // 编译期看到 a 是 Animal 类型 → 调用 print(Animal)虽然a实际指向的是Dog对象,但这里方法调用选择了print(Animal)。原因在于重载解析发生在编译期,编译器按变量的静态类型去找最匹配的方法签名,它根本不在乎运行时的真实对象是什么。
这种在编译期就锁定调用目标的方式,叫静态绑定(或早期绑定)。重载本质上是一次编译期的行为,跟运行时的对象类型无关。
3.2 重写:运行期才拍板的事
重写则完全不同。看这段:
Animal a = new Dog(); a.makeSound(); // 输出“汪汪汪”Java 编译器编译这行代码时,只检查Animal类有没有makeSound()方法。有,编译通过。但真正执行的时候就交给虚拟机来处理:虚拟机依据a引用指向的对象类型(Dog)来找方法实现。这种在运行期才确定调用哪个方法实现的机制,叫动态绑定(或晚期绑定)。
听到这里应该能明白了:重载对应静态绑定,重写对应动态绑定。只有“重写”才是运行时多态的体现,重载并不是。你完全可以把重载当作一个语法便利——同一个方法名下挂多个版本,省得命名
printAnimal、printDog。它不是多态,至少不是通常面向对象意义上那种“一个接口,多种实现”的多态。
3.3 一个分不清重载和重写的经典场景
为了加深印象,再看一个常见的面试题变体:
public void invoke(Animal a) { System.out.println("处理动物"); } public void invoke(Dog d) { System.out.println("处理狗狗"); } // 调用时 Animal a = new Dog(); invoke(a);输出结果是什么?答案是“处理动物”。因为两个invoke方法构成重载关系,编译器按a的声明类型Animal选择匹配invoke(Animal),运行时不不做二次分发。这一点很多经验不足的开发者会答错:他们下意识觉得运行时a是Dog,应该调用invoke(Dog)。这就是没搞懂“重载静态绑定、重写动态绑定”导致的。
| 对比维度 | 方法重载 overload | 方法重写 override |
|---|---|---|
| 方法签名 | 参数列表必须不同 | 参数列表必须完全一致 |
| 返回值 | 可随意 | 必须相同或是协变返回类型 |
| 修饰符约束 | 无特殊限制 | 不能比父类更严格 |
| 绑定时机 | 编译期静态绑定 | 运行期动态绑定 |
| 与多态关系 | 无关 | 运行时多态的核心机制 |
4. 动态绑定背后:方法表、字段隐藏与静态方法的特殊规则
很多人对多态的理解停在“子类重写父类方法,调用时自动找子类实现”这个层面,够用,但遇到一些边角情况还是会懵。比如:为什么访问属性时没有多态?为什么静态方法也没有多态?这些问题的答案,藏在动态绑定的底层机制里。
4.1 虚方法表:虚拟机怎么找到那个“真正的实现”
以 HotSpot 虚拟机为代表的主流 JVM 实现,在类加载完成后会给每个类生成一份方法表(method table),里面记录了这个类所有虚方法(非私有、非静态、非 final 的实例方法)的方法入口地址。子类继承父类时,方法表会先复制父类的方法入口,然后对子类重写的那些方法,把入口替换成子类自己的实现。
调用一个虚方法时,虚拟机会这样处理:先拿到当前对象所属的类,从它的方法表里按方法索引定位到方法入口,然后直接调用。整个查找过程很快,不是每个方法调用都临时去遍历继承链,这也是为什么多态虽然多了一次间接寻址,性能开销却非常小。
我们用Animal a = new Dog(); a.makeSound();来走一遍这个过程:
a引用的实际对象是Dog实例,对象的类标记指向Dog类。- 虚拟机读取
Dog类的方法表,找到makeSound方法对应的入口地址。 - 发现这个方法被
Dog重写过,方法表里存的是Dog版本的实现入口,于是执行Dog的输出逻辑。
如果Dog没有重写makeSound,那它的方法表里这个位置保存的还是Animal版本的入口,调用时会执行Animal的逻辑。这个机制完美解释了“为什么多态只发生在重写的方法上”。
4.2 字段访问没有多态:隐藏,而不是重写
方法可以被重写,但字段不行。看这段经典代码:
public class Parent { public String name = "父类"; public String getName() { return name; } } public class Child extends Parent { public String name = "子类"; @Override public String getName() { return name; } } Parent p = new Child(); System.out.println(p.name); // 输出“父类” System.out.println(p.getName()); // 输出“子类”同一个对象,p.name和p.getName()的结果居然不一样。原因就在于字段访问是编译期静态绑定的:p的声明类型是Parent,访问p.name时编译器直接定位到Parent类的name字段,不会根据运行时类型做二次选择。而getName()是虚方法,走动态绑定,到了运行期发现对象是Child,调用的是子类重写后的方法,返回子类的name。
Java 里这叫字段隐藏(hiding):子类声明了一个与父类同名的字段,并不会覆盖父类字段,只是把它“藏住”了。在实际项目里,这种同名变量造成的代码阅读障碍远大于它带来的便利,我的建议是:要么用@Override或者注释明确意图,要么干脆换个字段名,别指望靠字段隐藏来实现“多态式的数据区分”。
4.3 静态方法跟多态绝缘
静态方法的调用规则和字段类似:看引用变量的声明类型,不看运行类型。
public class Parent { public static void hello() { System.out.println("父类静态方法"); } } public class Child extends Parent { public static void hello() { System.out.println("子类静态方法"); } } Parent p = new Child(); p.hello(); // 输出“父类静态方法”有人管这叫“静态方法的隐藏”,道理和字段隐藏类似。因为静态方法属于类本身,不参与实例的动态绑定。所以如果你看到有人在子类里写了一个和父类同名的静态方法,并且指望调用时产生多态效果,那是行不通的。日常开发里我也建议避免这么写,很容易让读代码的人误以为这里发生了重写。
4.4 这条规则带来的实际启发
明白了动态绑定的机制后,有几个可以直接上手的经验:
- 能用多态让代码变简单就别手动做类型判断,因为类型判断本质上是自己模拟了一遍分发逻辑,比 JVM 的方法表查找慢不说,还容易漏分支。
- 不要在父类构造器里调用可重写方法,这点在第 6 节会详细展开。
- 访问字段时,保持通过 getter/setter 而不是直接访问,这样哪怕有字段隐藏问题,也能通过方法走动态绑定来保证数据来源一致。
5. 用好多态的实际场景:开闭原则、策略模式与面向接口编程
聊完了理论,接下来回答一个更实际的问题:日常项目里到底怎么判断“该用多态了”?我的经验是,当你发现自己在写一大堆类型判断、或者准备复制粘贴类似代码时,就该警惕了。下面三个场景是我在项目里用多态用到最多的地方。
5.1 场景一:报表导出,把“多格式”收敛成“多实现”
有一段时间我在做一个数据报表模块,需求是同一张报表要输出 Excel、PDF、CSV 三种格式。最早的实现是写一个ReportExporter类,里面放了三个方法,分别处理三种格式,调用方用一个format参数switch进去。
后来需求加了 JSON 格式,我又得回去改那个类;再后来要加图片格式,我彻底受不了了。重构的时候我把导出过程抽成一个接口:
public interface ReportExporter { byte[] export(ReportData data); } public class ExcelExporter implements ReportExporter { @Override public byte[] export(ReportData data) { // Excel 导出逻辑 return null; } } public class PdfExporter implements ReportExporter { @Override public byte[] export(ReportData data) { // PDF 导出逻辑 return null; } }调用方拿到什么ReportExporter实现就调用什么逻辑,新增格式时只要再加一个实现类,原来的代码一行不动。这就是我在第一节说的开闭原则的真实落地。
这个场景里有个关键设计:调用方不应自己去
new具体的导出类,而应该从一个统一的工厂或依赖注入容器里拿到对应的实现。否则调用方还是得写if (format.equals("excel")) { return new ExcelExporter(); },那类型判断的麻烦只是从导出类转移到了工厂类,本质上没有消失。
5.2 场景二:支付渠道对接,把“平台的差异”藏在实现后面
另一个典型场景是第三方支付渠道对接。不同渠道的请求参数、签名算法、回调验签逻辑差别都很大,但业务方只关心“发起支付、查询订单、处理回调”这几件事。
我的做法是定义一个PaymentChannel接口,不同渠道各写一个实现类,业务层只依赖接口:
public interface PaymentChannel { PaymentResult pay(Order order); PaymentQueryResult query(String orderId); boolean verifyCallback(Notification notification); }上线新渠道时,新写一个实现类接入就行,不影响任何现有业务代码。这种“面向接口编程”是价值非常大的一层抽象——支付渠道的数量可能会越来越多,但核心业务代码的复杂度保持稳定。
5.3 场景三:策略模式是“多态的打包版”
很多教材把策略模式当成单独的设计模式讲,其实它本质上就是多态的一层装饰。就拿前面的报表导出举例:
Map<String, ReportExporter> exporterMap = new HashMap<>(); exporterMap.put("excel", new ExcelExporter()); exporterMap.put("pdf", new PdfExporter()); public byte[] export(String format, ReportData data) { ReportExporter exporter = exporterMap.get(format); if (exporter == null) { throw new IllegalArgumentException("不支持的格式: " + format); } return exporter.export(data); }这里依然有一个“根据格式找实现”的过程,但和if-else + 方法内分发有本质区别:分支判断只剩一次,而且新增格式时只需往Map里注册新实现,核心分发逻辑不用改。这就是策略模式带来的收益。
5.4 用多态还有一个隐藏的好处:可测试性
面向接口/父类编程之后,单元测试的便利性也上来了。比如要测试一个调用ReportExporter的业务方法,你可以轻松创建一个匿名或 mock 的实现来返回固定数据,完全不需要初始化真实的 Excel 导出器。这对快速定位问题、做回归测试都很有帮助。代码可测试性提升了,维护成本自然下降。
6. 多态踩坑实录:六个让我调试到想砸键盘的问题
理论上限讲得再多,不如踩过一次坑记得牢。以下六个问题都是我在实际项目里真实遇到过、并且每个都花了不少时间排查的。希望能帮你少走点弯路。
6.1 坑一:父类构造器调用了可重写方法
第一版代码长这样:
public class Base { public Base() { init(); } public void init() { System.out.println("初始化 Base"); } } public class Derived extends Base { private List<String> list = new ArrayList<>(); public Derived() { // 隐式调用 super() } @Override public void init() { System.out.println("初始化 Derived"); list.add("hello"); } } new Derived();这段代码运行时会抛出NullPointerException,因为Derived的list字段是在构造器里初始化的,但在执行Derived构造器之前,必须先执行父类的构造器super(),而父类构造器里调用了init()——由于动态绑定,它调用的是Derived重写后的init(),此时list还是null。
真正的原因是对象的初始化顺序:父类构造器先执行,这时候子类字段尚未初始化,可重写方法一旦在这个时间点被调用,就可能访问到子类未初始化的数据。
解决方案很简单:
- 构造器里尽量避免调用可重写方法。
- 如果确实要扩展初始化逻辑,把初始化的接口设计成“子类覆盖钩子方法”时,确保钩子方法里不使用尚未初始化的子类字段。
- 或者干脆在构造器里只做参数校验和字段赋值,其他逻辑放到
init()方法里由调用方显式触发。
6.2 坑二:重载方法里传 null,编译器直接疯了
看这段代码:
public class OverloadTest { public void process(String s) { System.out.println("String 版本"); } public void process(Object o) { System.out.println("Object 版本"); } } new OverloadTest().process(null);输出结果是“String 版本”。因为String比Object更具体,编译器选择最具体的重载。听起来还好,但如果再加一个process(Integer i)呢?null可以匹配String也能匹配Integer,两者没有继承关系,编译器不知道选谁,直接报编译错误:reference to process is ambiguous。
这个坑的教训是:重载参数的选取是编译期行为,跟运行时多态无关。重载方法设计得太多,尤其是有多个接收不同类型参数但调用时可能传入null的情况,非常容易出这种问题。建议在代码规范里直接要求:process这种泛化的方法名不要同时提供多个看似可以互相替代的重载版本。如果确实需要,调用时用显式转型消除歧义。
6.3 坑三:instanceof用多了,多态就白用了
有一次评审看到一段代码:
if (food instanceof Apple) { ((Apple) food).eat(); } else if (food instanceof Banana) { ((Banana) food).eat(); }这跟第一节的if-else问题本质上是一样的——都是用“类型判断”代替了“多态分发”。要修很简单:
food.eat();每种食物类自己实现eat()就好。我理解写instanceof的同事有时是觉得自己在“增强判断逻辑”,但这样的判断一旦多了,新类型进来很容易忘了加分支,而且类型强转还伴随着ClassCastException的风险。能用多态的地方,尽量别让instanceof出现在业务代码的日常分支里。当然,instanceof不是完全不能用——它适合用来处理某些跨类型的通用逻辑(比如判断是不是某种标记接口),但绝不该成为主流分发手段。
6.4 坑四:向下转型没检查,ClassCastException教你做人
向上转型(父类引用指向子类对象)是安全的;反向的向下转型(把父类引用强转为子类类型)则必须保证引用真实指向的是目标子类对象,否则运行期就炸:
Animal animal = new Dog(); Cat cat = (Cat) animal; // ClassCastException为了避免炸,有人会写:
if (animal instanceof Cat) { Cat cat = (Cat) animal; }这在某些场景(比如要从集合里挑出某种具体类型)是有必要的。但如果你发现自己频繁地向下转型,请回头想想是不是抽象层次设计出了问题。比如一个List<Animal>容器里,为什么不直接定义成List<Cat>或者用一个CatService来管理猫的行为?频繁向下转型往往说明“抽象太粗”或者“类型划分不合理”。
特别提醒:
instanceof判断和ClassCastException还有一个隐藏的父子类顺序问题——如果Dog和Cat都在同一继承层级里,判断animal instanceof Dog不会误伤Cat,但判断animal instanceof Animal一定为真。初学者容易把这个搞混。
6.5 坑五:把“私有方法”和“静态方法”误当成多态入口
有人在子类里这样写:
public class Child extends Parent { @Override public void execute() { doWork(); } private void doWork() { System.out.println("子类私有方法"); } }如果父类也有一个private doWork(),子类这个doWork()其实不是重写,因为私有方法对子类不可见,子类只是恰好声明了一个同名方法。这时如果父类的execute()里调用了doWork(),它调用的永远是父类自己的私有版本,子类的私有方法根本不会执行。
静态方法同理,前面第 4 节已经说过。总结一条铁律:只有实例方法并且对子类可见(非 private)时才可能被重写,才会走动态绑定。
6.6 坑六:equals这个被重写烂了的方法
equals是一个比较特殊的方法——它被重写的频率极高,但很少有人意识到它跟“类型判断”纠缠在一起。最常见的错误版本:
public class Money { private int amount; private String currency; @Override public boolean equals(Object o) { if (o instanceof Money) { Money m = (Money) o; return m.amount == amount && m.currency.equals(currency); } return false; } }如果Money将来出现一个子类TaxedMoney,那money.equals(taxedMoney)的结果会比较微妙——因为taxedMoney也instanceof Money。反方向taxedMoney.equals(money)如果TaxedMoney重写了equals,可能返回 false。这就是“对称性被破坏”的经典问题。
在写equals时如果你要处理继承关系,建议明确判断条件。比如要求getClass()精确相等,或者反过来用o instanceof Money判定。更稳妥的方案是:尽量用组合替代继承,避免为equals的跨类比较写太多特判逻辑。我后来在这个问题上反复改过三次,每次都是因为业务新增了子类,可见这个坑的触发频率有多高。
6.7 一条关于坑位的总结
| 坑 | 本质原因 | 最佳对策 |
|---|---|---|
| 构造器调可重写方法 | 字段初始化顺序 | 构造器不做扩展调用 |
| null 重载歧义 | 编译期选择最具体类型 | 减少过度重载 |
| instanceof 分发 | 手动替代动态绑定 | 改为多态 |
| 向下转型 | 强转时不匹配 | 检查 instanceof 或重新设计抽象 |
| 私有/静态方法 | 不属于虚方法 | 明确方法可见性设计 |
| equals 跨类型 | 类型判断模糊 | 明确比较策略或组合替代继承 |
7. 判断多态用得对不对的三个标准
有读者可能会问:多态不是越多越好吗?这个问题其实问得挺关键。多态用得好,代码简洁优雅;用得过度,反而会把类型关系搞得很难懂。我后来给自己定了几条判断标准,供参考。
第一条:调用方不需要知道具体类型。如果调用方代码里出现了大段if-else、switch,或者频繁使用instanceof,那往往意味着本该用多态的地方没用。反过来,如果调用方代码已经非常抽象,甚至根本不需要知道“现在处理的是哪种子类”,恭喜你,多态用得很到位。
第二条:新增需求时,修改的是新增文件而不是旧文件。这是开闭原则的直接体现。比如新增一种动物,你要做的事是“新建一个 Animal 子类”,而不是“打开原来的调用代码加一个分支”。如果每次加需求都要回去改已经验证过的代码,说明抽象层次没划对。
第三条:继承层级不超过两层,且每个层级的抽象是有意义的。多态依赖继承或实现,但继承本身是有成本的。层级太深,每个子类继承的方法和字段越来越多,调试和测试的复杂度都会飙升。我一般建议继承关系尽量浅平,能用接口组合就用接口,尽量避免出现“动物→哺乳动物→犬科→狗→金毛→三个月大的金毛”这种一眼望不到头的继承链。
这三条标准不是从教科书上抄的,是拿很真实的项目教训换来的。我自己最早写代码的时候,恨不得把所有用得到差异的地方都塞进同一个类里;后来踩过坑,学会用多态之后又走向另一个极端,在类设计里疯狂堆砌抽象层。最终发现,多态的价值不在“用了多少”,而在“该用的时候用对了多少”。它像一把很锋利的刀,不该用来切面包的时候拿出来,只会让事情变得更复杂;但在真正需要干净利落切分逻辑的场合,它的价值很难替代。
回到开头那个同事的问题。代码评审结束后,我帮他把那串if-else换成了多态的写法,几个小时后他把新动物接入功能的时候特意跑来跟我说了声“原来多态是这么用的”。我这才觉得,这篇文章没白写。