类的继承这个话题我在不同项目里反复碰见也反复踩坑。不管是刚学Java的大学生还是写了几年业务代码的工程师只要涉及面向对象设计就绕不开继承。这篇笔记我自己整理了很久不打算讲教科书上那套抽象理论而是把真正用得上、容易错的东西写出来。如果你正在学类与继承或者写代码时对父类子类的设计拿不准这篇基于真实工作经验的拆解应该能帮到你。1. 继承到底解决了什么问题1.1 从代码复用聊起继承的本质很多人一提到继承第一反应就是子类复用父类的代码。这话没错但只说对了一半。继承真正解决的是类型关系上的抽象问题代码复用只是它捎带手带来的好处。我举个生活化的例子。假设你是一家餐厅的老板你现在有三种员工后厨厨师、前台收银员、传菜员。这三类人都有共同的特征——都有工号、都有姓名、每个月都要发工资。如果你分别定义三个完全独立的类工号和姓名这两个字段就得写三遍发工资的方法也得写三遍。这不是不行但只要你改一下工资的计算规则就得同时改三个地方漏改一个就出bug。这时候继承就派上用场了。你定义一个Employee父类把工号、姓名、发工资这些公共的东西放进去然后让Chef、Cashier、Waiter三个子类去继承它。子类只需要写自己特有的东西比如厨师特有的菜品评分收银员特有的收银差错率。这样一来公共逻辑只维护一份改工资规则只改父类三个子类自动生效。我在实际项目里常用的判断标准是如果两个类之间不是is-a是一个的关系就不要硬用继承。厨师是一个员工所以厨师继承员工没问题。但餐厅和厨师之间是has-a有一个的关系餐厅包含厨师这种关系就该用组合而不是继承。很多设计混乱的代码根源就是把is-a和has-a搞混了。1.2 什么时候该用继承什么时候别硬凑我自己总结了一套判断原则不一定适合所有人但实测下来能避免不少后期重构的麻烦。适合用继承的场景通常满足这几个条件子类确实是一个更具体的父类类型比如Dog继承Animal逻辑上完全成立。子类需要复用父类的完整实现并且只做扩展、少做修改。多个子类之间存在大量公共字段和方法把这些提到父类能显著减少重复代码。你希望在运行时通过父类引用来统一处理一组子类对象这就是多态的基础。不适合用继承的场景也很多比适合的场景更容易踩坑仅仅为了省几行代码就把两个不相干的类强行挂上父子关系。父类的方法实现对于子类来说大部分都用不上。比如你继承了ArrayList但你的类根本不需要add方法这种继承就是过度设计迟早会在某个调用方手里炸掉。继承层级超过三层。我在工作里见过六层继承的代码改一个底层方法上面五个层级全受影响那种感觉就像推倒一张多米诺骨牌你不知道会倒到哪一张才算完。子类需要大幅重写父类方法的时候。如果每个子类都把父类方法重写了个遍而且公共代码几乎没有那说明继承关系本来就是硬凑的不如各写各的接口。还有一个特别常见的场景是工具类要不要继承。后台开发中很多人喜欢建一个BaseController或者BaseService把日志、鉴权、参数校验塞进去然后让所有Controller或者Service继承它。这个思路本身没啥问题但如果Base类里堆了七八个互不相干的方法子类实际上只用到了其中一两个这就违背了继承的初衷。更好的做法是把那些互相独立的能力拆成单独的类通过组合引用来使用。1.3 封装、继承、多态三者是绑定关系在面向对象的世界里封装、继承、多态从来不是三个独立的知识点而是互相依赖的一个整体。网上能搜到大量关于封装继承多态的考题但我发现很多人只是背概念从来不理解它们为什么非要绑在一起。封装解决的是边界问题。一个类把内部状态藏起来只暴露有限的方法给别人调用。父类里的private字段子类也看不到这个边界意识尤其重要——你在Father类里写了一个private int money子类想直接用是编译不过的必须通过protected或者公共的getter才拿得到。继承解决的是复用和扩展问题。子类在父类的基础上继续细化复用父类已经实现好的能力再补充自己特有的能力。多态解决的是统一处理问题。因为子类是父类的一种所以你可以把一堆不同的子类对象塞进一个父类类型的数组里然后逐个调用同一个方法每个对象的实际行为由它自己的类决定。举个经典例子Animal类有一个speak()方法Dog重写成汪汪汪Cat重写成喵喵喵。你写一个函数参数类型是Animal里面调用speak()方法然后传入Dog对象就打印汪汪汪传入Cat对象就打印喵喵喵。这段代码里封装保证了每个类内部状态不越界继承保证了Dog和Cat确实是Animal多态保证了你只需要写一个函数就能处理所有Animal的子类。这三者缺一个整个设计就转不起来。2. 核心语法与关键细节2.1 Java继承extends、super与重写的边界Java里实现继承用的是extends关键字这是Java语法里最直白的一个词。一个类只能继承一个父类这是Java和C最大的区别之一Java故意砍掉了多继承用接口来弥补。写一个基本的继承代码如下// 父类 public class Animal { protected String name; public Animal(String name) { this.name name; } public void speak() { System.out.println(name 发出声音); } } // 子类 public class Dog extends Animal { public Dog(String name) { super(name); // 调用父类构造方法 } Override public void speak() { System.out.println(name 汪汪汪); } }这里有几个细节值得展开说。第一super关键字是子类访问父类成员的通道。在子类的构造方法里super(name)必须在第一行这是Java语法强制规定的。原因很实在你得先把父类部分构造完成子类的扩展部分才能安全创建。如果允许父类构造在后子类初始化时用到父类字段就会拿到未初始化的数据。第二Override注解的作用是告诉编译器这个方法我要重写。它最大的价值不是给人看而是给编译器做检查。如果你方法名拼错了比如把speak拼成sppeak没写注解的话编译完全通过你以为是重写实际上子类悄悄新增了一个方法。一旦写了Override拼错了直接编译报错能把这种隐蔽bug堵在编译阶段。我在开发中有一条几乎强制的规定凡是重写父类方法一律必须加Override注解否则代码Review直接打回。第三protected修饰符在继承场景里非常重要。父类的private字段子类不可见public又谁都能访问中间那个档位protected就是专门为继承准备的——子类可以访问外部其他类不能访问。上面例子里的name字段如果用private修饰子类Dog的方法体里直接访问name会编译报错。反过来说如果什么字段都用public封装就形同虚设了外部代码可以随便改你的内部状态。第四重写Override和重载Overload的区别经常被拿来当面试题。重写是子类对父类方法重新实现方法名、参数列表、返回类型都必须一致重载是同一个类里多个同名方法参数列表不同。我见过有人把这两个概念混着说写代码的时候也容易搞混。简单记重写发生在父子类之间重载发生在同一个类内部。2.2 Python继承从class Dog(Animal)说起Python的继承语法比Java更简洁没有extends关键字直接在类名后面的括号里写明父类即可。Python还有一个Java没有的能力支持多继承一个子类可以同时继承多个父类。class Animal: def __init__(self, name): self.name name def speak(self): print(f{self.name} 发出声音) class Dog(Animal): def __init__(self, name): super().__init__(name) def speak(self): print(f{self.name} 汪汪汪)Python版本的继承有两个细节跟Java不同特别容易踩坑。第一个坑是super().__init__()。Python的super()不需要显式传入父类名这一点比Java方便。但这里有个常见的初学者错误写了Animal.__init__(self, name)而不是super().__init__(name)。第一种写法在单继承下能用但一旦涉及多继承硬编码父类名的写法会导致复杂的调用链出问题。super()的作用不只是调父类而是按照__mro__方法解析顺序调下一个类。多继承下这个顺序搞错了初始化就会奇奇怪怪地乱跑。第二个坑是Python的类属性与实例属性。在Python类里直接写在类体中的变量是类属性写在__init__里的是实例属性。类属性是所有实例共享的实例属性是每个对象独立的。这个区别在继承场景里很有迷惑性比如class Parent: items [] # 类属性所有实例共享 class Child(Parent): pass a Child() b Child() a.items.append(1) print(b.items) # [1]因为a和b共享了同一个类属性items这个坑我在实际项目中遇到过。某次定义一个配置类把列表写成了类属性结果一个实例修改了配置其他实例全部跟着变排查了很久才发现是类属性共享导致的。结论很简单可变对象列表、字典、集合千万不要直接作为类属性定义除非你有意让所有实例共享。Python还有几个跟继承相关的工具函数值得掌握比如isinstance(obj, Class)用来判断对象是否为某个类的实例issubclass(Child, Parent)判断子类关系getattr(obj, method_name)动态获取属性或方法。在写框架代码、反射调用的时候这三个函数远比硬编码调用更灵活。2.3 构造方法与初始化顺序父子类执行的先后坑不管是Java还是Python继承场景下的初始化顺序都是个经典问题。Java里更严格Python里相对灵活但坑一个都不少。Java的执行顺序是这样的加载父类静态代码块和静态变量初始化。加载子类静态代码块和静态变量初始化。执行父类实例代码块。执行父类构造方法。执行子类实例代码块。执行子类构造方法。我经常看到面试题出这种题问创建子类对象时输出什么顺序。核心规则就两条先父后子、先静态后实例。记住这两条基本不会错。但实际开发中更有价值的坑是父类构造方法里调用了被子类重写的方法会执行子类的方法实现。Java的动态绑定机制决定了对象实际是子类类型时任何方法调用都会优先匹配子类的重写版本哪怕这个调用发生在父类的构造方法里。public class Parent { public Parent() { init(); // 这里调用的是子类的init() } protected void init() { System.out.println(Parent init); } } public class Child extends Parent { private int value 10; Override protected void init() { System.out.println(Child init, value value); } }实例化Child的时候输出结果是Child init, value0而不是value10。原因在于父类构造方法执行的时候子类的value 10还没执行因为子类的字段初始化发生在父类构造完成后。此时读value拿到的是Java默认值0。这个坑我在真实项目中碰到过一次当时是某个配置加载类父类构造方法里调用了loadConfig()这个方法是重写的结果子类里依赖的字段还没初始化加载出来全是默认值。后来把字段初始化挪到构造方法里的一个init()方法里由子类显式调用问题才解决。我的一条经验规则父类构造方法里不要调用可被重写的方法。如果非要调用就在父类里用private或者final修饰这样就不会被子类重写也就消除了动态绑定的意外。Python的初始化顺序稍微不一样但核心逻辑一致。super().__init__()保证了父类先初始化多继承的时候按照__mro__顺序依次初始化。Python还有一个Java没有的特点Python的__init__不是强制必须调用的父类的__init__不会被自动调用必须显式super().__init__()。忘记调用父类初始化方法子类就访问不到父类在__init__里定义的字段这个报错信息是AttributeError遇到这个错先检查是不是漏了super().__init__()。3. 继承体系设计实操从UML到代码落地3.1 用一个真实场景把继承讲透前面讲的都是语法细节和概念辨析这一节我来做一个完整的实操案例从需求分析到类图设计再到代码实现完整走一遍流程。假设你要做一个简单的宝可梦风格的角色对战游戏。游戏里有多种角色每种角色有生命值、攻击力、防御力这几个基础属性攻击的方式各不相同。有些角色可以施放技能有些角色只能普通攻击。这个需求特别适合用继承来建模也因为类宝可梦游戏 源码是网上很热门的学习项目类型很多初学者都在找参考。先做需求整理所有角色都有名称、生命值、攻击力、防御力。所有角色都能进行普通攻击。部分角色可以施放特殊技能技能效果各不相同。攻击时需要考虑目标的防御力计算实际伤害。后续可能新增更多角色类型比如飞行系、水系、电系。这个需求如果用继承来设计思路应该是这样的首先定义一个抽象父类Pokemon宝可梦把公共属性、公共方法都放进去。public abstract class Pokemon { protected String name; protected int hp; protected int attack; protected int defense; public Pokemon(String name, int hp, int attack, int defense) { this.name name; this.hp hp; this.attack attack; this.defense defense; } // 普通攻击所有角色通用 public void attack(Pokemon target) { int damage Math.max(1, this.attack - target.defense); target.hp - damage; System.out.println(name 攻击 target.name 造成 damage 点伤害); } // 特殊技能需要被子类各自实现 public abstract void useSkill(Pokemon target); public boolean isAlive() { return hp 0; } }然后定义几个具体子类。比如皮卡丘电系、妙蛙种子草系、小火龙火系。public class Pikachu extends Pokemon { public Pikachu() { super(皮卡丘, 350, 60, 40); } Override public void useSkill(Pokemon target) { // 十万伏特电系技能伤害较高且无视部分防御 int damage this.attack 40; target.hp - damage; System.out.println(name 使用十万伏特造成 damage 点伤害); } } public class Charmander extends Pokemon { public Charmander() { super(小火龙, 320, 70, 30); } Override public void useSkill(Pokemon target) { // 火焰喷射火系技能 int damage this.attack 30; target.hp - damage; System.out.println(name 使用火焰喷射造成 damage 点伤害); } }这个设计有个特别重要的优点战斗系统的核心代码可以和具体的角色类解耦。你写一个战斗管理器参数类型是Pokemon不需要判断对方到底是皮卡丘还是小火龙直接调用useSkill()和attack()就行。public class BattleManager { public static void startBattle(Pokemon a, Pokemon b) { System.out.println(a.name 与 b.name 的对战开始); while (a.isAlive() b.isAlive()) { a.useSkill(b); if (b.isAlive()) { b.useSkill(a); } } Pokemon winner a.isAlive() ? a : b; System.out.println(对战结束胜利者是 winner.name); } }以后新增一个角色只需要新写一个继承Pokemon的类重写useSkill方法然后传入BattleManager就行战斗系统一行代码都不用改。这就是面向对象设计的好处扩展靠新增类而不是修改现有代码也就是开闭原则对扩展开放对修改关闭。3.2 用类图把继承关系画清楚代码写之前我强烈建议先用UML类图把继承关系画出来。很多程序员觉得画图浪费时间但面对复杂的继承体系一张图能让你在5分钟内发现设计的问题比如继承层级过深、父类方法职责不明确、子类重复定义了父类已有字段。画UML类图常用的工具是StarUML这是一个免费开源的建模工具支持UML标准类图。另外如果你用的是IntelliJ IDEA它有内置的类图生成功能操作路径是右键点击一个类选择 Diagrams - Show Diagram Popup就可以看到该类的继承关系图。这个功能在阅读陌生项目代码时尤其好用一眼就能看出各个类之间的父子关系和依赖关系。UML类图中继承关系用一条带空心箭头的实线表示箭头指向父类。这个箭头含义必须记清楚空心箭头是继承实线是关联虚线是依赖空心菱形是聚合实心菱形是组合。很多人画图时把继承画成实线箭头这就是完全理解反了。还是拿宝可梦的例子来说。类图画出来应该是最顶层是Pokemon抽象类下面三个分叉箭头分别指向Pikachu和Charmander如果需要Bulbasaur就在箭头上面多加一条线。如果还有其他非继承的关系比如BattleManager管理战斗双方这是一种依赖关系用虚箭头从BattleManager指向Pokemon。画完类图你会立刻注意一个问题如果每个子类的useSkill都是独特实现父类的抽象方法就完全合理不会有冗余。但如果子类之间出现了大段重复代码比如Pikachu和Raichu皮卡丘的进化形态的技能逻辑高度相似你就应该考虑之间再插一层中间类或者用模板方法模式把公共流程提到父类。3.3 特殊场景多继承、抽象类与接口的取舍说到继承就不能不提抽象类和接口。我看到网上关于抽象类和普通类的区别的问题很多这里用最直白的方式说清楚。普通类可以实例化抽象类不能实例化。为什么不能实例化因为抽象类里定义了一个或多个没有方法体的抽象方法这些方法等待子类来完整实现。一个没有完整实现方法的类如果允许实例化调用者调用到那个方法就会不知所措。Java的解决思路很简单就是不让你new它。接口和抽象类的区别很多书上都列了对比表但真正理解起来只需要抓住一点抽象类是部分实现接口是纯约定。抽象类可以有字段、有构造方法、有已经实现好的公共方法也可以有抽象方法接口在Java 8之前只能有方法声明和常量Java 8之后可以加默认方法但仍然不能有实例字段。在继承设计里我的经验法则是如果你有多个类共享了大量可复用的实现代码用抽象类如果你只是想约定多个类必须实现某些方法用接口。一个典型的组合是抽象类实现接口把公共代码留在抽象类里子类只实现各自差异的部分。Java自身很经典的一个例子就是AbstractList。List是接口约定了一组方法AbstractList是抽象类把add、remove等方法的公共逻辑实现了一大半ArrayList和LinkedList只需要各自实现底层存储相关的少数方法。这个设计既保证了接口的统一约束又把复用做到了极致。Python的多继承虽然灵活但也不是想怎么继承就怎么继承。多继承最著名的坑叫菱形问题钻石问题D同时继承B和C而B和C都继承自A那么D调用一个从A继承的方法到底走B还是C的版本Python用__mro__方法解析顺序解决了这个问题大体规则是深度优先保持单调。而Java直接拒绝了这个麻烦只允许单继承类多继承用接口实现接口之间也可以继承。接口之间可以多继承但接口没有方法实现也就不存在菱形问题带来的方法冲突。4. 常见问题与排查技巧实录4.1 编译报找不到类的真正原因我在日常开发中被问过最多的问题就是明明代码都在为什么编译报找不到类。这类问题新手遇到非常抓狂但其实排查思路很固定按顺序来基本都能解决。最常见的几个原因类路径classpath没配好。代码文件存在但编译器或运行时没有把它所在的目录、JAR包加到类路径里。用命令行写Java程序时javac和java的-cp参数是很多人经常漏掉的。用IDEA等IDE开发时依赖没被正确引入也属于这一类。Maven或Gradle依赖没刷新。在pom.xml里添加了依赖但没有重新导入IDE里的类库索引还是旧的。这个问题的解决方案通常是点一下Maven Reload Project按钮。模块信息module-info.java导致的模块不可见问题。Java 9引入了模块系统如果模块没有exports对应的包其他模块就访问不到里面的类。这类问题在普通开发中不常见但在多模块项目里经常折腾人。包名与目录不一致。Java要求类的包名必须与文件路径对应放错了目录编译器找不到类是必然的。我实际遇到过一个特别值得分享的例子同事用Maven编译项目报错找不到com.sun.image.codec.jpeg.JPEGCodec。代码里确实引用了这个类IDE里也搜得到但编译就是过不去。原因是什么com.sun开头的包是JDK内部APIJava 9之后默认不允许访问需要额外加--add-exports参数才能编译。这类内部API引用在旧项目里非常常见尤其是涉及图片处理的代码。解决方案有两个一是升级替换成非JDK内部API比如用ImageIO二是临时加编译参数放行。我强烈建议选方案一因为内部API在后续JDK版本中随时可能被移除现在能编译不代表两个月后还能编译。另一个高频问题是在Eclipse里报找不到或无法加载主类 org.apache.catalina.startup.Bootstrap这个一般是Tomcat插件或运行配置出了问题。排查思路是先确认Tomcat的运行时环境是否配置正确、server runtime是否关联。顺带提一句现在新项目大多数用Spring Boot内嵌Tomcat或者直接用IDEA跑遇到传统Eclipse Tomcat问题的人已经越来越少了。4.2 匿名内部类、Lambda与继承的关联继承和内部类看起来很独立但在Java实际开发里经常碰面。匿名内部类本质上是没有显式类名的局部类它可以继承某个父类或者实现某个接口。典型的使用场景是一次性定制某个对象的行为。比如Pokemon specialPokemon new Pokemon(神秘角色, 999, 99, 50) { Override public void useSkill(Pokemon target) { // 特殊定制技能 target.hp 0; } };这段代码创建了一个继承自Pokemon的匿名子类实例重写了useSkill方法。匿名内部类能访问外部方法里的局部变量但如果局部变量在内部类里被读取这个变量必须是final或effectively final的。Java 8之前必须显式加final关键字Java 8之后放宽为值不变就行。这个限制的原因是生命周期问题——局部变量在栈上内部类对象可能存活更久Java通过把变量值复制一份到内部类对象里来保证安全如果允许变量后续被修改复制过来的旧值和新值不一致就会引发逻辑混乱。Java 8之后Lambda表达式大量出现匿名内部类的使用频率下降了不少。Java里lambda调用内部类示例是一个常见的并发编程技巧——在Lambda表达式中调用外部对象的内部类方法。但这个模式容易踩一个坑Lambda捕获外部类实例时会持有该实例的隐式引用如果这个引用本该被GC回收却因为Lambda存在而被长期持有就会导致内存泄漏。这个问题的经典案例就是用Lambda在静态线程池里执行任务结果Lambda里绑定了某个Activity/Fragment对象对象释放不掉了。比较新的技术方案是把回调转换成挂起函数。Android开发中经常听到kotlin回调转挂起工具类本质上是用suspendCancellableCoroutine把传统的回调API包装成一个可挂起的函数避免回调地狱。这个思想在纯Java里也可以用CompletableFuture实现原理是一样的——把异步回调包装成统一的结果对象。4.3 析构顺序与资源释放的坑父类子类的析构函数执行顺序是个经典考题。Java的析构概念已经淡化了finalize()从Java 9开始被标记过时基本上不推荐使用所以Java这边的答案意义不大。但C或者Python项目中这个问题非常重要。C中析构顺序和构造顺序完全相反先构造父类先执行子类析构再执行父类析构。原因是编译器保证子类部分的内存先释放再释放父类部分。这个顺序是严格约定的如果你手动写代码模拟从父到子的析构顺序很容易造成子类使用的父类资源已经被释放掉的问题。Python中的__del__方法也是类似规则不过Python是垃圾回收语言__del__的触发时机不可预测依赖它来做资源清理不是一个好主意。Python官方推荐的资源清理方案是用with语句配合上下文管理器而不是依赖析构函数。在继承体系中如果一个父类定义了__del__子类也定义了__del__子类的__del__不会自动调用父类的__del__必须手动调用super().__del__()漏掉的话父类的清理逻辑不会执行。这种坑在Python项目里遇到过不止一次。Java在资源释放上使用的是try-with-resources语法这个机制在继承场景下也需要注意。如果父类实现了Closeable接口子类继承了它那么子类如果自己持有额外的资源就必须在子类的close()中先释放自己的资源再调用父类的close()。顺序和析构一样先子后父。很多人写close()时直接盖掉父类的实现而不调用super.close()导致父类持有的资源泄漏。4.4 找不到方法的运行时错误如果说编译期的找不到类还算排查容易那运行期的找不到方法就更隐蔽也更考验技术。这类问题的根源通常是接口或父类的方法不存在、包冲突导致加载了旧版本的类库。我处理过一个典型的案例项目引入了两个第三方JAR这两个JAR里各自打包了同一个类但版本不一致运行时类加载器按顺序找到了旧的那个版本结果新版本才有的方法在旧版本里不存在一调用就抛NoSuchMethodError。排查方法是用Maven的dependency:tree插件检查是否有重复依赖然后用exclusion把旧的排除掉。另一个常见场景是JDK版本升级导致的类库不兼容。旧项目从Java 8升到Java 11或17如果用了反射代码或者RMI这类依赖内部实现的框架经常会遇到ClassNotFoundException或NoClassDefFoundError。核心原因是Java模块系统把这个包从默认可见性里移除了。遇到这类问题要先去看报错信息里是哪个类找不到再判断这个类属于哪个模块最后决定加什么JVM参数或者换掉这个用法。4.5 Maven编译找不到类的几个特殊场景Maven项目的找不到类问题比普通Java项目更容易出因为Maven的编译生命周期和IDE的缓存机制存在一些差异。下面几个场景我在开发中都见过值得单独列出来mvn compile能通过但mvn test报找不到测试类。原因是测试代码没有放在src/test/java目录下或者测试类文件名与类名不一致。打包时找不到本地JAR中的类。项目中引用了通过system scope引入的本地JARMaven打包时默认不会把system scope的依赖带进来需要在插件里额外配置。现在这个做法不推荐了建议把本地JAR安装到本地仓库或者使用maven-install-plugin处理。子模块找不到父模块的类。在多模块项目中子模块的pom.xml里忘记加parent依赖或者父模块没有先执行mvn install安装到本地仓库子模块编译自然找不到父模块的类。IDE能编译但命令行Maven报找不到类。这种一般是IDE的编译输出目录和Maven的target/classes不完全同步。多次mvn clean后再编译一次基本能解决。Eclipse项目还有个老旧但常见的错误是找不到或无法加载主类 org.apache.catalina.startup.Bootstrap。这个错误几乎都与Eclipse内置Tomcat的运行时环境有关典型的排查步骤是右键项目 - Build Path - Add Library - Server Runtime - 选择对应Tomcat版本。项目如果换了机器或者Tomcat路径变了记得检查这个配置。5. 继承体系设计的一些个人经验这篇文章写到现在核心内容基本覆盖了。最后分享几条我在实际工作中攒下来的体会。第一继承的深度尽量控制在三层以内。我在代码评审时看到继承层级超过四层的第一反应就是建议重构。层数每加一层你理解代码时就得在前缀多绕一圈排错时也要多翻一层。这个成本不是线性增长的是几何级数增长的。第二父类里尽量多用模板方法模式。把流程框架写到父类把可变步骤抽象成方法由子类实现。这样做的好处是业务主流程只维护一份不会因为各个子类各自写了一套流程导致流程漂移。我在支付模块重构时用过一次这个模式原来四五个渠道各自写了一遍流程重构后统一父类模板方法渠道差异压缩到只有几个钩子方法。第三优先用组合而不是继承。这句话是Effective Java里最经典的建议之一但很多人只记住了组合优于继承这句话不知道什么时候用。我的体会是如果你只需要复用某个类的实现但逻辑上两个类构不成is-a关系就用组合——在类里放一个字段来持有那个类的实例。比如电脑和CPU之间就是组合关系你不会让电脑去继承CPU而是在电脑类里持有一个CPU对象。继承是把任何能复用就往上挂的思维在新手代码里特别常见这种代码最需要重构。第四画图不丢人代码看不懂才丢人。在动手写继承代码前先画UML类图确认关系这是很多大厂做系统设计时的标准动作。IDEA里自动生成类图看一下再回来看代码大部分怎么绕都绕不出来的问题都能迎刃而解。继承这个东西说起来就是Object-Oriented的三大基石之一但用得好与用得烂写出来的代码天差地别。我用宝可梦游戏的案例串了整篇文章因为游戏角色的继承关系是最直观的——你想想属性相克这种需求如果没有继承和多态代码会变成什么样。这本身就是很好的自我检验方式如果你能设计出一套让新增角色只需新增一个类、不动一行已有逻辑的设计那你对继承的理解就基本过关了。