讲“类和对象(下)”,我最怕的就是把它讲成“语法点扫盲”。继承、多态、接口这些概念,单独拿出来背定义,每一个都不难,难的是你在写代码的时候,心里真的清楚“我这一步是为什么”。前一篇我们聊完了类和对象的基础部分,类的定义、构造器、方法、基本封装,都是“对象怎么造”的问题。到了下一章,真正的重头戏才来:面对十几个、几十个相互关联的类,怎么组织它们的关系,怎么让新需求加进来的时候不至于把老代码顶塌。而这正是继承与多态的地盘。
这篇文章我会面向 Java 为主来展开,因为 Java 里这三个概念的前后依赖关系特别典型。看的时候你只需要记住一个核心问题,贯穿全文:一段代码,编译期的类型和运行期的实际类型,到底谁说了算?这句话想通了,继承、重写、多态,基本就通了一大半。
1. 继承:先用结构化解耦,再谈代码复用
1.1 继承解决的最原始问题
面向对象里强调复用,但复用有两种完全不同的复用法。组合是“我包含一个某某能力”,继承是“我就是一个某某”。比如你做一个员工管理系统,需要把“普通员工”和“管理者”都建模。管理者是不是员工?代码模型里说“是”很合理,因为管理者也有工号、姓名、入职时间,也参与考勤,也得发工资。这时你用Manager extends Employee,通过继承拿到了员工的基本属性和方法,再单独补充管理者的特有字段和逻辑。这属于最基础的复用思路,也最符合直觉。
但这只是继承的冰山一角。更核心的作用是——它可以让你针对基类写算法,却对子类自动生效。这就是常说的依赖倒置。比如你写一个报表方法,入参是Employee,批量导出一堆人的考勤记录。如果没有继承,你得写两套方法,一套吃Employee,一套吃Manager;有了继承,Manager就是Employee,一套方法直接两端通用。你看,复用只是表象,真正的价值在于,抽象层次高的代码不依赖具体的小类。
1.2 父类怎么设计:从“共性抽取”到“行为稳定”
继承不是想到哪写到哪,父类本身就是一张设计稿。设计父类时,我最看重两件事:字段是不是真的稳定,方法是不是子类都会用到。比如你把一个“会飞”的方法强塞给鸟类祖先类,那企鹅、鸵鸟这些子类就全被带沟里了,明明不会飞还得硬着头皮继承。这种脏设计层层传下去,改起来非常恐怖。
我见过太多人在建父类的时候先堆字段,比如“我看了下所有员工都有手机号、学历、紧急联系人,那就全部放父类”。这种做法要打个问号。手机号、学历这种东西,可能在某个子类型里根本用不上,或者字段意义完全不同。父类字段过多会带来两个连锁反应:第一,构造器参数爆炸,子类每次 super 调构造器要传一长串你不关心的参数;第二,数据库映射的时候,父类字段最终全落到子类表里,空值一堆。所以父类字段应该是真正的“高置信度共性”,不是“看起来大家都有”。
1.3 super 和构造器链:一个绕不过去的执行顺序问题
继承关系里有一个执行顺序问题,初学者几乎都会踩:创建子类对象时,构造器到底怎么跑?答案很明确,JVM 会从最高层父类开始,一层一层往下执行构造器,最终才执行到子类自己的构造体。也就是说,你 new 一个Manager对象,实际是先执行Employee构造器,再执行Manager构造器。这是祖先的字段先初始化的一个必然要求,否则子类构造器里访问父类字段,发现还是默认值,整个对象就是坏的。
所以在子类的构造器第一行,你通常会看到super(...)。这一行必须写成构造器里的第一条语句,Java 规定得死死的。如果你不写,编译器就会自动插入一个无参的super(),去调用父类的无参构造器。这就是为什么父类一旦声明了带参构造器,却不写无参构造器,子类编译直接报错,因为自动插入的super()找不到对应方法。
这里还有一个很隐蔽的坑——父类构造器里调用了一个被子类重写的方法。比如Employee构造器里调用了calculateBonus(),而这个方法被子类Manager重写了。那么创建Manager对象时,父类构造器执行期间,调用的是Manager里那个重写后的方法。可此时Manager自己的字段还没初始化完毕,很多字段仍然是默认值,计算结果直接出错。这类 bug 非常难排查,因为你明明没动父类构造器,输出却不对。处理办法是:构造器里尽量只做初始化,不要调用任何可被重写的方法,这算是 Java 编程里的老规矩了。
2. 重写与多态:让同一种调用,跑出不同结果
2.1 重写和重载,真的别搞混
每个学面向对象的人都在这两个概念上晕过。重写(Override)是子类写一个和父类“同签名”的方法,替换父类的行为;重载(Overload)是在同一个类里写多个“同名不同参数”的方法,本质上是语法糖,编译期就区分开了。一个动的是运行期,一个动的是编译期,两者完全不在一个维度。
我见到的初学者错误是,以为可以靠返回值类型来区分重载。实际上重载只看方法名和参数列表,不看返回值。你写int add(int a)和String add(int a)放在同一个类里,编译器直接报错,因为参数列表一样,语言分不清你要调哪一个。重写时还有一条铁律:子类重写方法不能比父类方法有更宽松的访问权限。父类方法如果是protected,子类可以写成public,但不能写成private,否则语义上会打破“子类是一种父类”的承诺,外部用父类引用去调用这个行为时会失控。
2.2 动态绑定:多态是怎么跑起来的
多态最简单的表现是:父类引用指向子类对象。比如:
Employee e = new Manager(); e.calculateBonus();编译的时候,编译器看到变量e的类型是Employee,它只知道去Employee类里找calculateBonus()这个方法。如果父类里没有,直接报错。但是运行的时候,JVM 发现e实际指向的对象是Manager,于是真正执行的是Manager里重写后的那个方法。这就是动态绑定,也叫运行时多态。
很多人问,为什么不能“编译时直接绑定Manager的方法”?因为编译时根本不知道运行时会传什么对象进来。你的方法参数可能是Employee,也可能是它的任意子类,这个对象可能在另一个模块里才定义。动态绑定的意义是,让代码写出“面向父类”的通用版本,而实际执行时自动适配具体对象。
要想运行期绑定生效,记住一个硬条件:被调用的方法不能被private、static、final修饰。不能重写的方法,自然没有多态可言。字段访问则更没有多态一说,Java 里字段访问是看编译期类型的。父类引用来访问同名字段,拿到的永远是父类字段,这是很多人调试时百思不得其解的一个点。我建议你宁可少用父类引用访问子类特有字段,也别指望它给你惊喜。
2.3 多态好用的三个前提
有了多态,代码确实灵活,但灵活是有前提的,否则写得越多越乱。
第一,继承层级别太深。三层以内的继承还看得清,超过三层,每个子类都往父类里的行为上做手脚,你调一个方法,完全定位不到实际执行路径。我处理过的项目里最怕那种七层继承,一个方法在三个层级里被重写过,追 bug 要反复跳转,体验极差。
第二,子类要保持行为一致性。既然是“某个父类的一种”,重写后的行为应该在语义上跟父类保持一致。父类的calculateBonus()计算逻辑是“拿基础工资乘一个比例”,子类可以改比例、改基数,但不能改成“扣钱”甚至“随机数”。保持不了语义一致,多态就是灾难。
第三,方法调用尽量靠近接口定义。多用接口类型或抽象类类型作变量声明、方法参数、返回类型,不要一上来就用具体类。这不是风格问题,这是松耦合问题。将来换实现的时候,只换具体对象,不换调用代码,才能体现出多态带来的维护收益。
3. 抽象类与接口:把能力契约和代码骨架分开
3.1 抽象类:捕捉“半成品”的共性
抽象类最典型的应用场景,是多个子类共享一套状态和基础行为,但其中某个核心行为必须交给子类实现。比如你定义一个Shape,它有颜色、面积、名称这些属性,也有draw()这种不清楚怎么画的方法,那就把draw()声明成抽象方法,类本身标记为抽象类,子类再去实现具体的画法。
抽象类不能直接new,设计意图非常明确:它自己不是一个完整的对象,只是一个模板。子类继承抽象类时,如果没把所有的抽象方法实现完,子类自己也得是抽象类。在实际项目里,我把抽象类理解成“骨架”,因为它能把通用字段、通用方法、通用的构造流程都替子类做掉。子类只需要填空,填的是差异化的部分。
有一个细节要提醒:抽象类不一定非要包含抽象方法。你可以定义一个没有任何抽象方法的抽象类,用来禁止外部直接实例化。这在框架代码里偶尔会见到,比如工具类不想被new,但也不确定将来会扩展成什么样,就用抽象类表达“请勿直接创建”的意图。当然,绝大多数情况下你会直接用final、private构造器这些手段来禁new,云而不能仅靠一个抽象标记。
3.2 接口:一套纯行为约束
如果说抽象类是骨架,接口就是合同。Java 的接口在 Java 8 之后确实可以有默认方法和静态方法,但这不改变接口的核心语义:它描述“能做什么”,不关心“内部怎么存数据”。实现接口的类,得把合同里的每个条款都兑现。
接口最大的价值是突破单继承限制。Java 类只能继承一个父类,但可以实现多个接口。比如一个Bird类,既想有“飞行能力”,又想有“产卵能力”,在真实语义里它只有一个父类,但能力标签可以同时挂很多个。这就是接口比抽象类更灵活的地方。很多现代设计模式,比如策略模式、观察者模式,实操上都是基于接口来解耦的:调用方只面向接口编程,你的具体实现是一号还是二号,完全不影响调用方逻辑。
3.3 接口和抽象类怎么取舍:基于行为的现实判断
在实际工作里,我选型标准很简单:不确定将来要做什么、但确定“一定得有个类型约定”的时候,优先接口;确定一组类天然共享同一套字段和构造逻辑、且差异主要集中在局部行为时,优先抽象类。
举一个真实场景。做一个支付系统,需要对接微信支付、银行卡支付、支付宝支付,它们都包含“订单号、金额、支付时间”这些共同字段,也都需要一个核心的pay()流程。这种时候,把共同字段和支付流程主框架放在抽象类里很好,因为你已经把数据结构和公共方法钉死了,子类只实现具体渠道的逻辑。但如果你要定义的是“可被缓存”“可被序列化”“可被导出”这种横切能力,跟业务字段完全无关,就应该用接口。缓存、序列化、导出,这些都是能力标签,不该靠继承体系去绑定。
还有一个小陷阱:接口里定义常量,比如int SUCCESS = 1;,这种写法在旧代码里非常常见。我建议少用,接口常量本质上把实现类和外部耦合死到具体数值上,一旦数值变更,改动波及面很广。真正的接口应该只描述行为和能力,数据要么走常量所在的具体配置类,要么用枚举定义。
4. 封装边界与 static:进阶者最容易忽略的设计细节
4.1 访问控制符的本质是“设计契约”
四个访问级别private、默认(包可见)、protected、public,看起来只是关键字,实际上是在约束代码边界。很多人不太在意,大而化之全写public,等类的使用者多了,你根本没有能力约束外部应该如何调用,任何一个字段的变更都可能造成连锁反应。
说到项目实操层面,我自己是“能私有就私有”。字段一律private,子类需要读写时通过protected的 setter/getter 暴露,外部只通过方法来访问数据。刚开始你可能觉得写 setter/getter 麻烦,但好处是后面你可以随时在 setter 里增加校验逻辑,比如金额不能为负、名称不能为空。若一开始就把字段设为public,外部代码直接改字段,等于所有校验都绕过了,你连后悔的机会都没有。
这里需要特别说明一下protected。在一个继承体系里,protected修饰的成员允许子类直接访问,但这个访问还受到包级别的限制。如果你跟子类不在同一个包,子类访问父类的protected成员时,它只能透过“自己或自己子类”的实例来访问,用父类引用来访问会报错。这个规则很多老手也会记混,写跨包继承时要格外小心。
4.2 static:类级别的成员,是把双刃剑
static成员属于类,不属于任何一个对象。无论你 new 多少个实例,同一个static变量只有一份。这正是全局共享数据的基本实现机制,比如计数器、统一配置项、工具类方法,都适合用static。
但static也是出问题的重灾区。最常见的问题是,在static方法里试图访问实例字段。这必然编译失败,因为你连“是哪个实例的字段”都指定不了。初学者会跑来问“为什么我static方法里不能直接用count这个实例变量”,原因简单直接:它没有对象上下文。解决方式是,要么把变量也改成static,要么通过传入对象来处理。
还有一个特别值得警惕的坑——static变量被多个线程并发访问时的数据一致性问题。别人常年在项目里因为static缓存出问题,你以为“数据库查出来的配置放一个static集合里,只读不改就没事”,可实际上只要有一个线程在更新它,其他线程就会面临半更新状态。如果你非要用static做全局缓存,至少要考虑并发控制手段,否则最终伤害的是后台任务的稳定性。
4.3 static 在继承中的奇怪行为
静态方法不存在重写,只存在隐藏。也就是说,子类写一个和父类静态方法签名完全相同的方法,看起来语法像重写,实际不是多态。你用父类引用调用这个静态方法,调的是父类的;用子类引用调用,调的是子类的;用子类对象加父类引用调用,调的还是父类的。这符合编译期原则——静态方法属于类,所以调用跟着声明类型走。
最好的做法是,代码里不要通过“父类引用.子类对象”来调静态方法,应该直接用类名调用,避免产生“这到底用的谁的方法”的错觉。这也是很多静态扫描工具会强制要求的话,因为代码可读性太差。还有一点:静态方法里不能调用非静态方法,非静态方法却可以调用静态方法,因为静态方法是类级别的,不依赖实例,反过来就做不到。这是很多初学者搞反的顺序关系。
5. 组合优于继承:架构层面的刹车与平衡
5.1 继承的强耦合会让演进变慢
有些问题继承解决得并不好。比如橡皮鸭玩具,在玩具类体系里它可以继承“发声玩具”,同时又想支持“擦除文字”的能力。如果你坚持用继承解决一切关系,就会有“橡皮鸭继承发声玩具”和“橡皮鸭继承书写工具”两难,Java 只允许单继承,你走不通。即便你在某些语言里能多重继承,也会引起巨大的语义混淆:同名方法到底调用谁的?
继承还有一个潜在麻烦,就是父类一旦发生变化,所有子类都会被波及。你只是想在父类增加一个字段,子类构造器、序列化、数据库映射全得跟着检查一遍;父类某个方法签名变化,所有调用点都要动。越是大型系统,继承的牵一发动全身问题越致命。所以很多架构经验总结起来,就是“多用组合,少用继承”。
5.2 组合到底怎么用
组合的本质是“持有”。一个类里声明另一个类的实例作为字段,通过调用这个实例的方法来获得能力。比如橡皮鸭可以持有一个Eraser对象,要用擦除功能时委托给Eraser对象处理。这样做的好处是,你可以在运行时决定这个橡皮鸭是用铅笔擦还是钢笔擦,甚至随时换一种擦除策略,不需要改类本身。
实际代码里,组合常见的做法是构造器注字段,把被组合对象通过构造器传进来。这样类之间的依赖关系外显,测试的时候也能很方便地替换成 mock 对象。如果你直接new一个具体对象作为字段,那就变成硬编码组合,耦合度也很高。真要发挥组合的优势,最好配合接口使用,字段类型写成接口,具体实现交给调用方决定。
5.3 什么时候仍然选继承?
尽管“组合优先”,但继承在特定场景下依然是正确选择。比如当一个类是另一个类的严格子类型,且父类提供了你完全认可的基础实现,你不希望重复造轮子,继承就很合适。像各种框架里让你继承的基础类,把流程模板都做好,你只需要覆写模板方法里的钩子,这时候继承的意义是“框架提供骨架,你填逻辑”,和纯粹为了复用字段的父类完全不同。
我的建议是,你把“是不是真的 is-a 关系”作为最高判断标准。如果你说不清子类和父类之间的“是一个”关系,只是觉得“能复用一些方法”,请务必把这段继承改成组合。另一个判断法是,问问自己“将来子类会不会有很多种”,如果子类五花八门,继承体系的稳定性往往更差,组合的灵活性会明显胜出。做取舍时,不用沉迷于某个绝对原则,而是看哪个方向能让你未来改代码时更轻松。
6. 实战中的常见坑与调试经验
6.1 父类构造器里调多态方法导致的“未初始化”问题
前面提到过,这个坑我再展开一次,因为太经典。假设有这段代码:
class Base { Base() { show(); } void show() { System.out.println("Base"); } } class Child extends Base { private String tag = "ChildTag"; Child() { tag = "ChildTag2"; } @Override void show() { System.out.println("Child tag = " + tag); } }当你执行new Child()时,实际输出的是什么?会是Child tag = null。原因是父类构造器调用的show()动态绑定到子类实现,但此时子类字段tag还没进入初始化阶段,显示为null。你觉得这不可能?你手写一遍就发现,它真的就是null。解决手段无非两个:父类构造器里只做父类字段安全的初始化,不调任何可重写方法;或者把show()声明成final杜绝重写。
6.2 equals 与 hashCode:继承场景下的双人舞
重写equals的时候,多态会让事情更难搞。你在父类写了一个比对逻辑,子类继承后若不重写,两个不同子类的对象可能被“误判相等”。比如Manager和Employee都继承Employee的equals比较,比较工号、姓名之后返回 true,但这两个对象根本不是同一个业务实体。更麻烦的是,如果你在子类里重写了equals,却忘了重写hashCode,两个对象放进HashSet时就可能因为哈希值不一致变成两个元素。业界标准做法是,equals里先判断getClass()是否相等,而不是直接用instanceof,这样能避免跨子类误判。但如果你想要的是“子类对象也能等价比较”,那又得单独设计,原则就是别以为继承会自动给你解决一切语义问题。
6.3 构造器里不要用可重写方法,但这个规则有一条例外
前面说构造器里不要调用可重写方法,这是个安全原则,不是绝对语法约束。有的框架就是故意在父类构造器里调抽象方法,比如模板方法模式,父类构造器定义整体流程,调一个抽象方法让子类提供实现。这种情况在框架代码里是精心设计的,因为子类在父类构造器执行时还没初始化完,框架会控制这种调用发生的行为安全边界。但在你自己写的业务代码里,完全没有必要这么做。业务代码追求的是可预测性,不是巧妙。如果你的业务代码里出现依赖子类初始化状态的构造器行为,多半是设计失误,直接修改设计比在调用顺序上打补丁要更干净。
6.4 调试多态问题的三条路径
多态出 bug 时,第一件事是确定实际运行的对象类型,而不是盯着代码里的声明类型。常用调试方式是在可疑方法第一行打印this.getClass(),看实际类型是哪个。第二件事是排查方法签名:你以为你重写了父类方法,但可能因为参数类型不一致,实际是重载了。比如父类是void process(String s),子类写void process(String str),参数名变化不影响重写,可如果你改成void process(Object obj),就完全变了。一旦参数类型不匹配,多态结果就是“只调父类方法,子类逻辑永远没跑”。第三件事是确认访问级别没有把重写方法调低,否则编译器也会报错;如果编译器没报错但行为不对,多半是提前使用了static或final修饰,导致动态绑定失效。这个排查顺序,基本能覆盖日常 90% 的多态问题。
7. 收尾前还想再唠叨几句
“类和对象(下)”真正的学习难点是思维切换:从写一个个孤立的类,切换成设计一套可以协同工作的类体系。继承、多态、抽象类、接口、组合,每一招都有它的适用半径,也有它的反噬方式。学的时候不要急着把所有概念堆到同一个项目里,而是先在一个小例子里把继承和多态跑通,再逐步加入抽象类和接口,最后再试着用组合去重构掉不太合理的继承。这个顺序走下来,比你背一百个定义都管用。
我自己的经验是,写代码容易,改代码的时候才见功底。你留下一个继承体系,将来别人想扩展、想测试、想替换实现的时候,都会切身体会到你的设计功底。所以,每次你准备敲下extends这个关键字之前,先花五秒钟问自己一句:这里,我到底是要表达“就是这种”,还是“只是复用一下”?这一问,能让你少趟很多坑。