
1. 继承到底解决了什么问题先看一段让人抓狂的重复代码两年前我带过一个刚入行的同事他写了一个订单模块。最初只处理线上支付订单类结构很简单一个Order类搞定。后来产品说要支持到店自提订单他复制粘贴了整个Order类改了几个字段名就叫SelfPickupOrder。再后来要支持预售订单他又复制了一份改成PreSaleOrder。三个类五百多行代码里面有四百行是一模一样的只有几个方法略有不同。我问他为什么不抽一个父类出来他的回答很经典继承那不是考试才用的东西吗这句话我记到现在。其实很多自学的人也有同样的困惑看教程里讲继承讲得全是动物啊、猫啊狗啊这种例子看完觉得懂了一写业务代码根本不知道从哪里下手。继承性这个概念如果真的只理解成一个语法关键字那就太可惜了。它是面向对象编程里解决多个类型共享同一套行为这个问题的核心手段。说白了当你发现两个类之间有大量重复的字段和方法并且它们之间存在A是一种B的关系这时候就该用继承来消除重复把公共部分上移到父类。就拿订单这个场景来说线上支付、到店自提、预售这三种订单本质都是订单。它们有相同的订单号、用户ID、总金额、创建时间、状态也有相同的计算金额、校验状态、生成订单快照的方法。不同的只是创建流程、履约方式和核销逻辑。这个场景用继承就是一个非常自然的设计// 父类把共性的字段和行为放进来 public class Order { protected String orderId; protected String userId; protected BigDecimal totalAmount; protected String orderStatus; public BigDecimal calcAmount() { // 基础的金额计算逻辑 return totalAmount; } public void validateStatus() { if (!CREATED.equals(orderStatus)) { throw new IllegalStateException(订单状态不允许此操作); } } } // 子类只需要关注自己的特殊逻辑 public class SelfPickupOrder extends Order { private String pickupCode; // 自提码这是子类独有的 private String storeId; // 门店ID这也是子类独有的 public void verifyPickupCode() { // 自提特有的核销逻辑 } } public class PreSaleOrder extends Order { private LocalDateTime presaleStartTime; // 预售特有的字段 private LocalDateTime presaleEndTime; Override public BigDecimal calcAmount() { // 预售有定金膨胀之类的特殊算法覆写父类方法 return super.calcAmount().multiply(new BigDecimal(0.9)); } }写父类的时候心里要有个判断标准如果两个类之间是is-a是一个的关系比如自提单是一个订单那用继承没问题。如果只是has-a有一个比如订单有一个地址对象那就用组合别硬套继承。这个判断标准后面我还会详细说因为实际项目中把组合误用成继承的情况一点不比不用继承少。继承解决了两个层面的问题。第一个层面是代码复用公共字段和方法只写一遍修改也只需要改一处不会出现改了线上支付的订单逻辑忘了改自提单的尴尬情况。第二个层面是类型建模父类类型可以接收所有子类对象这是多态的基础能用一套代码处理所有订单类型。很多人在这一步就开始晕了别急下面我们一步一步拆。2. 继承的核心机制拆解extends、super与构造器调用链说完了继承解决什么问题我们来看它具体是怎么工作的。Java里面用extends关键字建立继承关系Python里面是class Child(Parent)C用的是冒号。语法不同底层机制大同小异这里我用Java来讲因为它在这块的语法约束最严格把Java搞明白了其他语言基本是降维打击。2.1 extends关键字与子类的诞生上一个订单例子里已经写了public class SelfPickupOrder extends Order这个extends建立的是SelfPickupOrder是Order的一种这个类型关系。子类一旦继承了父类就有两个非常大的权限第一自动拥有父类中非private修饰的字段和方法。这意味着父类里写好的orderId字段、calcAmount方法子类不需要再声明一遍直接就能用。注意这个非private的限定private成员子类拿不到这是后面要单独讲访问修饰符的原因。第二子类可以声明自己的新字段和新方法。比如自提单的pickupCode、storeId这是父类没有的也是子类区别于父类的关键。如果子类一个自己的成员都没有那这个子类基本没有存在的必要。一个类如果没有显式写extends那它默认继承的是Object类。所有类最终都继承自Object这也是toString()、equals()、hashCode()这些方法在任意对象上都能调用的原因。2.2 super关键字的两副面孔super是子类访问父类成员的钥匙它有且仅有两种用法。用法一调用父类构造器。子类的构造器第一行必须是super(...)用来初始化父类部分的字段。如果你不写编译器会自动补一个无参的super()。public class SelfPickupOrder extends Order { public SelfPickupOrder(String orderId, String userId, String storeId) { super(orderId, userId); // 第一步先让父类把自己的字段初始化好 this.storeId storeId; // 第二步再初始化子类自己的字段 } }这个顺序是强制性的父类的初始化永远发生在子类之前。你可以把子类想象成父类对象外面又包了一层里面那层没建好外面这层不可能凭空立起来。用法二调用父类的方法或访问父类的字段。最常见的就是覆写方法里先让父类做基础逻辑再叠加子类的扩展逻辑。刚才PreSaleOrder.calcAmount()里那行super.calcAmount()就是干这个的。2.3 构造器调用链是新手最容易翻车的点我见过无数刚学继承的人在构造器这里被一个Implicit super constructor Order() is undefined错误拦住了然后一脸懵。这个错误的本质是父类只有带参构造器没有无参构造器而子类构造器默认调用了不存在的super()。来看这个反面教材public class Order { private String orderId; public Order(String orderId) { // 父类只提供了带参构造器 this.orderId orderId; } } public class SelfPickupOrder extends Order { private String storeId; public SelfPickupOrder(String orderId, String storeId) { // 这里编译器会默认加一行 super()但父类没有无参构造器直接编译报错 this.storeId storeId; } }解决办法有两个要么子类构造器第一行显式写上super(orderId)像前面正确示例那样要么在父类里补一个无参构造器。但在大多数业务场景里一个订单如果没有订单号就不应该存在所以在父类补无参构造器这个方案往往是坏的——你为了满足编译器的语法要求把对象创建时的必填约束给放开了等于允许一个不完整状态的父类对象存在。我能给的经验是父类字段如果都是必填项就别加无参构造器强制子类必须显式调用带参的super这样反而能保证对象从出生就是完整的。构造器的完整调用链是这样的创建子类对象时先执行父类构造器从继承链最顶端的Object开始一层一层往下执行最后才执行子类自己的构造器体。这个过程可以用一句话记先父后子从上到下。3. 方法覆写与重载总有人把这两兄弟搞混继承机制里子类有两个途径可以改编父类的行为覆写Override和重载Overload。名字像、功能也像但是底层逻辑完全不同。面试的时候问一句Override和Overload的区别能刷掉一大半基本功不扎实的人。其实这俩搞清楚了往后看多态就轻松很多。3.1 覆写Override同一个方法签名子类重新实现覆写发生在继承关系里子类和父类有完全相同的方法签名方法名 参数列表子类决定自己实现一套行为。public class Order { public String getOrderType() { return NORMAL; } } public class PreSaleOrder extends Order { Override public String getOrderType() { return PRE_SALE; } }这里我加了Override注解。这不是一个可有可无的装饰它是一个编译期检查器。如果你写的方法签名跟父类对不上比如参数类型写错了编译器会直接报错而不是让你以为自己在覆写其实写了一个新方法。我建议所有覆写方法都加这个注解给自己省掉很多排查的时间。覆写有三条硬性规则方法签名必须完全一致方法名和参数列表返回值类型可以相同或者是父类返回值类型的子类Java 5之后支持协变返回类型访问权限不能比父类更严格父类是public子类不能是protected或private第三条规则很多人忽略。它的逻辑其实很好理解子类对象在需要以父类身份被使用的时候向上转型后面讲别人能调用的方法到了子类对象身上没理由变得更不可见。访问权限只能放宽不能收紧这是里氏替换原则在语法层面的体现。3.2 重载Overload同一个方法名参数列表不同重载跟继承没有必然关系写在同一个类里也能重载。它的核心是方法名相同但是参数类型、参数个数、参数顺序至少有一个不同。重载是编译期的静态行为编译器根据你传入的参数类型来决定调用哪一个。public class Order { public void create(String userId) { ... } public void create(String userId, String promoCode) { ... } // 参数个数不同 }3.3 用一个表把覆写和重载分清楚对比维度方法覆写Override方法重载Overload发生位置子类和父类之间同一个类里方法名必须相同必须相同参数列表必须相同必须不同返回类型相同或是父类返回的子类可以任意不要求相关访问权限不能比父类更严格没有限制异常声明不能抛出比父类更宽泛的受检异常没有限制绑定时机运行期动态绑定编译期静态绑定关键字Override无对应注解有了这个表再遇到笔试面试题基本不会错。3.4 静态方法不行、private方法不行、final方法也不行有三个非常容易踩的坑我单独拎出来说。第一静态方法只能被隐藏不能被覆写。子类可以定义一个跟父类静态方法签名相同的方法但这叫方法隐藏调用哪个取决于引用类型而不是对象实际类型。这个行为很反直觉所以我不建议在子类里写跟父类静态方法同签名的方法。第二private方法对子类不可见所以不存在覆写这回事。子类写一个跟父类private方法同名的public方法那是一个全新的方法跟父类的那个半毛钱关系都没有。第三final方法禁止覆写。这就是final关键字的作用——锁死方法实现不允许子类篡改。4. 访问修饰符与继承边界哪些成员真的进入了子类在学继承的时候有个非常常见的误区以为子类继承了父类的所有成员。这个说法不够准确。Java里有四个访问级别每个级别对继承的影响都不一样。我画过一个表到现在都很实用访问修饰符同类内同包内不同包子类不同包非子类private可以不可以不可以不可以默认不写可以可以不可以不可以protected可以可以可以不可以public可以可以可以可以4.1 private成员子类眼里的黑盒父类的private字段和方法子类无法直接访问。但是要理解这个字段仍然存在于子类对象的内存布局里只是你看不见、摸不着。我举个实际的例子。Order父类里有个private String orderId子类SelfPickupOrder里不能直接写this.orderId编译器会报错。但子类创建出来之后这个订单号字段实实在在存在于对象里只是你只能通过父类提供的public String getOrderId()方法去读它。这个设计是有道理的。父类对自己的数据拥有完全的控制权。比如订单状态这个字段父类可能维护着一个状态机CREATED-PAID-SHIPPED-COMPLETED。如果子类可以直接操作这个字段分分钟给你改成非法的状态值整个状态机就崩了。所以一般字段设private对外暴露受保护的修改方法子类通过方法去改变状态状态合理性由父类统一校验。4.2 默认权限package-privateJava里最容易被忽视的级别默认权限也就是不写任何修饰符意味着同包可见。这在继承里造成了一个非常微妙的局面同一个包里的子类可以访问父类这个字段不同包的子类反而访问不到。这导致很多人写代码时发现自己的子类访问父类默认权限字段有时能编译过、有时编译不过玄学一样。如果确定了要做继承字段我希望你用protected或者private加访问方法。默认权限更适合同一个包内的一组类协作的场景比如一个模块内部的几个辅助类。4.3 protected成员给子类的专属窗口protected是继承场景下最常用的权限它兼顾了子类能用和外部不能用两个需求。不过有一个细节很容易被忽略同包的类能访问protected成员这一点很多人没意识到。如果父类是给其他包里的子类用的那protected完全没有问题。但如果是在同一个包内部protected和default实际上一样。我在团队里定的一个不成文规则是字段一律用private需要给子类开放的用protected方法能不给子类用的方法一律不让出去。继承已经破坏了封装就没必要在权限上继续大敞四开。4.4 一个关于继承边界的思考题来一道我经常在文章里夹带的思考题。父类A有个public void doSomething()方法子类B没有覆写它。那么在B的对象上调用doSomething()执行的是谁的方法答案是A的方法而且是在B对象的上下文里执行A的方法。这句话看着像废话但想深一层就有意思了A的方法里如果访问了A的private字段这些字段在B对象里是存在的所以执行没有任何问题。如果A的方法里调用了一个可以被子类覆写的方法那实际执行的可能就是B覆写后的版本了。这个机制叫动态绑定也是下一章多态的核心基础。继承的边界到这里基本盘清楚了接下来看继承和多态一旦叠加会碰撞出哪些有意思也有风险的事情。5. 多态与继承的连锁反应向上转型、向下转型与instanceof继承里面隐藏的大招其实是多态。很多讲继承的教程把多态放在下一章但我一直觉得这俩拆开讲反而让人更难理解。继承是多态的基础多态是继承的目的分开讲就像把钥匙和锁分开讲听着都懂了用到的时候都对不上。5.1 向上转型父类引用指向子类对象多态的第一个前提是向上转型。看这段代码Order order new SelfPickupOrder(ORD123, user001, STORE_01);变量order是Order类型但右边实际创建的是SelfPickupOrder对象。这在Java里合法因为子类是一个父类。用生活的话说我指着一只猫说这是一只动物完全没问题。向上转型之后order这个引用只能调用父类里声明过的方法子类自己特有的方法比如verifyPickupCode调用不了。如果父类的方法被子类覆写了调用时会执行子类的覆写版本。Order order new PreSaleOrder(...); order.getOrderType(); // 编译看父类有没有这个方法运行看子类怎么覆写 // 执行的是 PreSaleOrder 覆写后的版本返回 PRE_SALE这段代码最核心的价值在于你写代码的时候可以只依赖父类的类型来编程不用关心到底传进来的是哪个子类。这就是面向抽象编程的意义。一个方法写完线上支付订单能用预售订单也能用后面再加十种新订单类型这个方法的代码一行都不用改。5.2 动态绑定运行期才决定调用谁Java里除静态方法和private方法外所有普通实例方法的调用都是动态绑定。也就是说程序编译的时候不知道会执行哪个方法版本要等运行的时候根据对象的实际类型来决定。这就是多态和继承配合工作的核心机制继承负责建立类型体系和公共行为覆写负责在子类里替换具体行为动态绑定负责在运行的时候把调用路由到正确的方法上。5.3 向下转型需要用的时候得原形毕露向上转型是自动的安全的。但如果确实需要调用子类特有的方法就得向下转型。Order order createOrder(); // 返回类型是 Order里面实际是什么不确定 if (order instanceof SelfPickupOrder) { SelfPickupOrder pickupOrder (SelfPickupOrder) order; pickupOrder.verifyPickupCode(); }向下转型前必须用instanceof检查对象的实际类型。如果没有这层检查直接强制转型如果对象根本不是目标类型运行时会抛出ClassCastException。Java 16开始instanceof有了模式匹配的写法可以把判断加转换写在一行if (order instanceof SelfPickupOrder pickupOrder) { pickupOrder.verifyPickupCode(); // 判断通过后pickupOrder 直接可用 }这个语法上的小改进很好用推荐试试。5.4 多态成立的三个条件缺一不可很多教材里会总结多态成立的三个条件继承、覆写、父类引用指向子类对象。三个条件环环相扣。没有继承就没有类型的父子关系不覆写调用父类方法就是调用父类的实现谈不上多种形态不用父类引用接收子类对象就没有面向抽象的效果。我实际开发里发现一个体会多态不只是语法特性更是一种编程方式。当你习惯把代码写成Order order而不是SelfPickupOrder order把方法参数写成Order而不是具体的订单类型你的代码会被解耦得很好。往上加新订单类型的时候基本不需要回头改老代码。6. 继承与组合的取舍什么时候真的不该用继承继承不是万能药。实际上继承优先于组合还是组合优先于继承这个话题在社区里争论了二十年。组合优于继承composition over inheritance是一个偏工程偏实践的设计原则它提醒我们继承这种关系比看起来的要脆。6.1 is-a和has-a选型的第一判断标准学习的时候最容易犯的一个错是为了省代码而用继承。比如一个Order类里有一堆跟User相关的字段和方法有人图省事让Order extends User这样订单就能直接调用用户方法了。但这不符合现实语义订单不是一个用户订单有一个用户。判断标准简单粗暴A 是一个 B → 用继承SelfPickupOrder extends OrderA 有一个 B → 用组合Order里放一个User字段A 能用 B 的行为 → 用接口或者组合别继承6.2 继承破坏封装父类的内部实现细节泄露给子类这是一个非常实际的问题。父类定义了一个方法generateOrderSnapshot()本来是对外提供的功能但子类因为继承关系可以覆写它、调用它、依赖它。一旦父类后续版本调整了这个方法的内部逻辑子类的覆写代码可能直接崩掉。Java里最经典的反面教材是java.util.Properties继承Hashtable。Properties本该是键值都是字符串的配置类但它继承了Hashtable于是put(key, value)方法可以对非字符串值开放直接绕过了Properties对类型安全性的限制。JDK设计者甚至在里面留了注释承认这个设计是历史遗留的 mistake。你去看任何一本讲Effective Java的书都会看到复合优先于继承这一条。6.3 继承层级过深变更传递的成本继承层级一旦超过三层维护的复杂度就开始指数上升。我接过一个历史项目有一个类继承链是BaseEntity-BaseOrder-AbstractOrder-GenericOrder-SpecialOrder五个层级中间任何一层的改动都可能引发下方所有子类的连锁反应。一般我会在团队里定这样的规矩继承层级控制在三层以内不含Object中间层要么是抽象类要么是有明确语义的基类如果没有is-a关系的把握优先组合6.4 抽象类与接口的配合让继承更安全Java里继承的一个补充手段是抽象类。抽象类不能被实例化它存在的目的就是被别人继承。抽象类里可以写具体方法也可以声明抽象方法强制子类去实现public abstract class BaseOrder { protected BigDecimal totalAmount; // 通用逻辑直接写实现 public BigDecimal getTotalAmount() { return totalAmount; } // 不同类型的订单算法不同让子类各自实现 public abstract BigDecimal calcAmount(); }结合接口使用的话设计空间会更大接口定义能做什么抽象类处理怎么复用具体子类负责具体怎么做。这样继承带来的风险能被压缩到一个相对可控的范围里。7. 实际项目里的继承设计原则与避坑清单到这一节语法层面的东西基本讲完了。但语法会了跟能写好是两码事。下面我把这些年实际项目里沉淀下来的继承设计原则和踩过的坑整理成清单项目里写继承的时候把这些过一遍可以少踩很多坑。7.1 里氏替换原则子类不能给调用方拆台里氏替换原则Liskov Substitution Principle是面向对象设计五大原则之一它的核心是所有使用父类对象的地方都可以无缝替换成子类对象而程序的正确性不被破坏。这个原则听起来抽象其实落到代码上就几句话子类覆写方法时参数校验不能比父类更严格子类方法不能抛出父类方法没有声明的异常子类不能削弱父类方法的契约比如父类方法承诺返回非null子类就不能返回null违反里氏替换原则最经典的案例是正方形继承长方形。长方形的宽度和高度可以独立修改正方形要求长宽相等子类强行覆写setWidth、setHeight去维持相等约束结果在依赖父类的代码里表现诡异。这就是典型的is-a关系在真实世界成立但在代码世界不成立。正方形确实是一个长方形但在变长变宽这个行为上它跟长方形不一致。这个例子提醒我们继承关系要以行为为中心而不能只看分类。7.2 构造器里绝对不能调用可覆写方法这是我刚工作的时候吃过亏的地方。父类构造器里调用了一个public方法而子类覆写了这个方法。创建子类对象时父类构造器先执行这时候子类的字段还没初始化全是默认值但它调用的却是子类覆写后的方法读到的是null或0。public class Order { public Order() { init(); // 危险这里调用了一个可覆写方法 } public void init() { // 父类初始化逻辑 } } public class PreSaleOrder extends Order { private BigDecimal discountRate; public PreSaleOrder() { this.discountRate new BigDecimal(0.8); } Override public void init() { // 此时 discountRate 还是 null父类构造器已经调用了这个方法 BigDecimal realAmount getTotalAmount().multiply(discountRate); // 空指针 } }解决办法很简单构造器里只调用private或final方法绝不要调用可以被覆写的方法。如果有初始化逻辑需要子类参与用模板方法模式会是一个更安全的设计——在业务方法里留一个抽象钩子方法等对象完全创建完再触发。7.3 模板方法模式继承最值得用的设计模式如果说继承在项目里只有一个最高频的最佳实践我提名模板方法模式。它的思路是这样的父类写好一个业务方法的骨架把流程固定住把可变的部分暴露成抽象方法或可覆写方法让子类去填。public abstract class OrderProcessor { // 这个方法是流程骨架子类不能改final public final void process(Order order) { validate(order); // 第一步校验 BigDecimal amount calcAmount(order); // 第二步算钱 deductStock(order); // 第三步扣库存同类订单一样写死 createLog(order, amount); // 第四步记日志 } protected abstract void validate(Order order); protected abstract BigDecimal calcAmount(Order order); private void deductStock(Order order) { // 订单扣库存逻辑所有子类通用 } }这种方式把一个业务流程的骨架钉死把政策的差异点校验规则、计费规则下沉给子类。新接入一种订单类型时只需要继承OrderProcessor实现两个方法主流程根本不会动。模板方法模式让继承变得非常有结构感也把复用和定制两种诉求结合得很好——这也是我实际写业务代码时最常用到继承的地方。7.4 用final锁住不该变的部分前面在讲覆写的时候提过final方法。在设计继承体系时final也是一个很实用的工具。final类不允许任何类继承比如java.lang.String说明这个类的设计者不想让别人改动它的行为final方法子类可以继承但不可以覆写适合锁住固定的业务流程骨架模板方法里的process方法就可以设finalfinal字段不可变字段在对象创建完成后就不能改了写继承体系时有一个问题值得时常自问这个类/方法被继承/覆写后会带来什么后果如果你接受不了后果那就用final把口子堵上别把所有的扩展空间都留出来。7.5 覆写equals和hashCode时的继承陷阱这个坑虽然不属于继承语法本身但在继承场景下特别容易翻车。父类覆写了equals和hashCode子类新增了字段但没有重写这两个方法。比如SelfPickupOrder里有storeId字段但它的equals继承自Order只比较订单号两个不同门店的同号订单会被判定为相等这在业务上可能是错误的。更麻烦的是当父类的equals里用了getClass()做类型判断时子类永远不可能跟父类相等如果用instanceof判断子类之间的对称性又容易出问题。我的建议是继承体系里的equals和hashCode非常难做对除非有极强理由否则不要在继承层级里实现值比较一般用唯一业务ID来比较就够了。7.6 避开继承里最常见的六类坏味道结合我实际带人和code review的经验碰到下面这些情况基本可以认定设计有问题需要重新考虑要不要用继承坏味道问题所在建议子类只想复用父类一个方法就强行继承为了复用代码破坏了is-a语义改用组合把那个方法所在的类当成字段子类覆写父类方法时直接抛异常说明父类设计的契约子类根本不支持回看父类的抽象是否合理重构接口继承层级超过三层变更传播成本高代码可读性差压平层级中间层改用组合或接口子类使用父类protected字段直接做运算父类字段容易被子类改坏父类封好状态变更逻辑子类只调用方法父类构造器里调用可覆写方法子类字段没初始化就执行了子类逻辑改成构造器里只调private/final方法子类仅为了满足多态写法而覆写一个空实现代码里出现大量空实现覆盖是设计无意义的信号没有差异就压缩成一个类别为继承而继承翻过这几类问题之后继承这块基本就稳了。继承学到能写出来的程度才算真的学会了。我给的建议是找自己手头的一个真实业务场景比如订单、商品、用户这种常见的对象模型先梳理出它们的共性再动手抽出父类。写的过程中把上面提到的构造器、访问权限、覆写规则这些细节全部过一遍比看十遍教程都管用。等你反过来再去看那些讲猫啊狗啊的继承案例时你会发现它们背后其实就是这些机制在撑着。我自己带人这些年最大的体会是继承不是语法考试里的填空题它是你设计代码结构时的一把工具工具用得对不对完全取决于你对业务关系理解得透不透。下次写代码之前可以先停两秒问自己一句这个父类和子类的关系是真的是一个还是只是长得像这个问题想明白不少重构的活儿其实都不用干到一半。