1. 继承到底解决了什么问题:先搞懂IS-A再谈复用
很多从零开始学Java的朋友,在类和对象那一块还能跟上,一到继承就开始迷糊。这一篇是Java基础系列的第八篇,咱们专门把继承掰开揉碎讲清楚:继承解决什么问题、super怎么用、重写有哪些边界、多态为什么和继承绑在一起,以及面试里那些重写重载、初始化顺序、深浅拷贝的题目到底怎么答。这篇适合已经掌握类与对象基本语法、见过构造器但还没完全消化继承关系的读者,也适合准备Java面试想快速把基础八股串起来的人。
1.1 代码复用不是继承的第一目的
继承最直观的作用看起来是代码复用:子类不写就能拿到父类的属性和方法,很爽。但如果你只是想让两个类共用一段逻辑,就硬套 extends,很快会翻车。继承真正表达的是 IS-A 关系:Student is a Person,Dog is an Animal。只有当一个类确实可以看成另一个类的子类型时,继承才是合理的。
这里我常用一个生活化类比:继承不是“从别人家拿东西”,而是“我是你的一种”。拿东西是组合,是一种类型才是继承。比如汽车有引擎,这是 HAS-A,用组合;小轿车是汽车,这是 IS-A,用继承。代码复用在继承里是副产品,不是目的。若方向反了,你会见到诸如“让企鹅继承鸟,然后重写鸟的 fly 方法抛出异常”这种设计,这已经违背了里氏替换原则:凡是父类能出现的地方,子类都应该能顶上去且不破坏原有逻辑。企鹅顶替鸟的时候,fly 这个行为就不成立了,说明这个继承关系从建模开始就是错的。
1.2 为什么说继承是一把双刃剑
继承带来最明显的成本是耦合。子类和父类在编译期就绑定了:父类改一个方法签名或修改内部逻辑,所有子类都可能跟着变,哪怕你只是想改一个私有实现。Java 对此用了比较保守的设计:类只允许单继承,接口可以多实现。这样避免了很多语言里“菱形继承”的直接冲突,但也让不少人觉得限制太多。
所以业界流行一句“组合优于继承”。这并不是说继承没用,而是提醒你在写 extends 之前先停两秒:我要的是能力复用,还是类型语义?能力复用优先考虑组合,把一个对象放进另一个对象里调用;类型语义、多态分发、模板方法这类场景才优先考虑继承。说句实在话,我在日常项目里,自定义类之间的继承用得其实不多,更多是把继承用在抽象类和接口的体系设计上。
2. super和构造器:继承的第一步经常卡在这里
2.1 super关键字到底super在哪里
super 有三个用途:调用父类构造器、访问父类成员变量、调用父类方法。最常见的坑是“以为 super 是个像 this 一样的引用”。this 是一个实实在在的引用变量,可以传来传去;super 不是引用,它是编译器认识的关键字,作用是“跳过当前类对某个方法的覆写,直接走父类那一层”。
写法上注意三点。第一,在子类构造器首行写 super(参数) 调用父类构造器,而且必须写在第一行。第二,访问父类字段用 super.field,但只能访问到非 private 字段,实际开发里直接跨层访问字段的频率很低,能封装就封装。第三,调用父类被覆盖的方法用 super.method(),典型场景是子类重写父类方法时,想在父类逻辑基础上追加处理,比如父类的 save() 里做了日志,子类重写 save() 里先调 super.save() 再补自己的权限校验。
还要提醒一点:super 不能连续跨层,你不能在孙子类里写 super.super.print(),Java 没有这种语法。如果出现这种需求,往往说明层级设计有问题,应该通过组合或者调整继承结构解决。
2.2 构造器执行顺序,父类永远先走
创建子类对象时,内存里必须先有父类部分。JVM 会保证父类构造器先执行,然后才是子类构造器。这是面试必考题:static 代码块按父类到子类顺序执行,且只执行一次;实例变量初始化和实例代码块在构造器之前执行;如果父类没有无参构造器,子类构造器第一行必须显式写 super(参数),否则编译不通过。
举个最容易错的场景:
class Father { static { System.out.println("1 父静态块"); } { System.out.println("2 父实例块"); } Father() { System.out.println("3 父构造器"); } } class Son extends Father { static { System.out.println("4 子静态块"); } { System.out.println("5 子实例块"); } Son() { System.out.println("6 子构造器"); } }第一次 new Son() 的输出是:1 父静态块、4 子静态块、2 父实例块、3 父构造器、5 子实例块、6 子构造器。第二次再 new,静态块不再执行。这个顺序很多人背不住,其实套路很固定:先把类加载阶段的静态信息准备好,再按“父类实例部分 → 父类构造器 → 子类实例部分 → 子类构造器”的顺序组装对象。
3. 方法重写与隐藏:别把重载当成重写
3.1 重写的三条硬性规则
重写的本质是:子类重新实现父类的方法,运行时走子类逻辑。第一条,方法签名必须一致,方法名和参数列表相同;第二条,返回类型可以是父类返回类型的子类型,这叫协变返回;第三条,访问权限不能比父类更严格,抛出的受检异常不能比父类更多。
为什么访问权限不能更严格?因为继承要保证 IS-A 替换。父类方法如果公开,说明任何人都可以调用;子类把它改成 private,外部调用者按父类引用调用时编译期看不出问题,运行期却发现调不了,这就不合理了。同理,受检异常也不能扩大,否则调用方按父类签名只 catch 了父类声明的异常,子类却抛出一个新异常,程序会出大问题。非受检异常不受这个限制,但也不建议乱加。
写重写方法时,强烈建议加 @Override 注解。它有两个作用:一是给编译器做校验,签名写错了、权限收紧了,马上报错;二是给读代码的人明确“这个方法不是新写的,是重写父类的”。不加也能运行,但加上之后,你能少踩不知道多少坑。
3.2 静态方法和成员变量没有多态
这是很多从零开始的人容易忽略的点:静态方法不能重写。在子类里写一个和父类同签名的静态方法,正确说法叫“隐藏”,不叫重写。调用哪个方法取决于你用什么类型的引用去调,而不是对象实际是什么类型。成员变量的情况也一样:变量没有多态,编译器按引用的声明类型来决定访问哪个字段。
看下面这段:
class Person { String name = "Person"; static void hello() { System.out.println("Person hello"); } void say() { System.out.println("Person say"); } } class Student extends Person { String name = "Student"; static void hello() { System.out.println("Student hello"); } @Override void say() { System.out.println("Student say"); } } Person p = new Student(); System.out.println(p.name); // Person p.hello(); // Person hello p.say(); // Student say前两个结果是不是和你直觉不一样?这正是因为字段和静态方法都看“左边声明类型”,只有实例方法看“右边实际类型”。很多面试题就喜欢在这里挖坑。解决的办法也简单:尽量避免在子类里重新声明父类同名的字段,这基本等于自埋地雷;静态方法如果确实需要不同实现,就独立命名,不要搞隐藏。
4. 多态与动态绑定:继承真正的价值在这里
4.1 向上转型和多态的成立条件
先说结论:继承 + 方法重写 + 父类引用指向子类对象,三个条件凑齐,多态才成立。实际执行时,JVM 不是看引用类型的编译期类型,而是看堆上对象的实际类型来决定调用哪个方法,这个过程叫动态绑定。这也是面向对象里“封装继承多态”三个词能连在一起的原因:没有继承,就没有多态;没有多态,继承就真的只剩代码复用了。
向上转型是从子类引用转成父类引用,比如 Person p = new Student()。这一步是绝对安全的,因为 Student 一定是 Person,你只会把能力范围缩小,不会碰到不存在的成员。反过来向下转型就需要小心了,咱们下一节说。接口也一样,一个类实现多个接口后,可以向上转型成任意一个接口类型,这是 Java 面对“多继承能力”时的常用做法。
4.2 向下转型与 instanceof 的正确姿势
向下转型是把父类引用再转回子类引用,比如 (Student) p。这一步风险点在于:这个对象到底是不是 Student,编译器不知道,运行时如果类型不匹配,直接抛 ClassCastException。所以向下转型之前,标准做法是用 instanceof 判断。
Java 16 开始,instanceof 可以带类型匹配变量,写法更干净:
if (obj instanceof Student s) { s.study(); }这个语法同时完成了判断和强转,s 只在 if 块内有效,代码比老写法 (Student) obj 安全得多。还有个小细节:instanceof 左边的对象为 null 时,结果直接是 false,所以它既能做类型判断,也能顺手挡掉空指针风险。
我自己的经验是:如果一个方法里频繁出现 instanceof,就要警惕多态没有用好。合理设计应该把不同的行为放进各自类里,让调用方通过父类引用直接调,从而去掉大量类型判断。当然,某些场景比如基于继承体系做校验或兜底逻辑,instanceof 仍然是有价值的工具。
5. 抽象类、接口与组合:继承的进阶取舍
5.1 抽象类和接口到底怎么选
老八股问题,但真能答清楚的人不多。抽象类适合描述“是什么”:它有状态、可以有成员变量,也能有具体方法,子类通过继承获得这些状态和默认行为。接口适合描述“能做什么”:它更像是能力契约,一个类可以实现多个接口,从而具备多方面的能力。
Java 8 之后接口里可以有 default 方法和 static 方法,Java 9 还允许私有方法,这让接口和抽象类的边界变得模糊了一些。但有一个核心差别一直没有变:抽象类能保存状态,也就是实例字段;接口本质上是行为契约,字段只能是常量。所以选择时可以这样判断:需要共享私有成员变量、要提供模板方法骨架,用抽象类;只需要定义能力规范、需要多实现来组合能力,用接口;两者都想要也很常见,典型写法是一个接口定契约,加上一个抽象类做骨架实现。
5.2 接口默认方法带来的菱形问题
default 方法让接口在升级时不用强制所有实现类改代码,这是它最大的价值。但代价是引入了类似多继承的冲突:如果两个接口都有同一个签名的 default 方法,某个实现类同时实现了它们,Java 不知道听谁的,编译直接报错。此时必须在实现类里重写该方法,或者用 接口名.super.方法名() 指定走哪一个。
interface Walkable { default void go() { System.out.println("Walkable go"); } } interface Runnable { default void go() { System.out.println("Runnable go"); } } class Athlete implements Walkable, Runnable { @Override public void go() { Walkable.super.go(); Runnable.super.go(); } }这种 接口名.super 的语法看起来和普通 super 差不多,但它只能出现在实现了对应接口的类里,不能到处乱用。实际业务里,接口默认方法尽量少用来承载关键业务逻辑,它更适合做兼容性兜底和少量通用工具方法。
5.3 组合优于继承,但不是万能
组合就是把一个类作为另一个类的成员变量,然后委托调用。比如一个“导出服务”需要日志能力,传统做法是让它继承一个 Logger 基类,但这样做会带来继承强耦合;改成组合后,导出服务里放一个 Logger 成员,用它记录日志,想换实现时只要把 Logger 接口实现换掉就行。
那组合是不是万能?不是。组合能解决代码复用和灵活替换,但它不表达 IS-A,所以当你需要明确的多态关系时,还是离不开继承。比如模板方法模式里,父类定好一套算法骨架,把某些步骤交给子类实现,这种结构就是为继承设计的。又比如你希望把同一族对象放进一个父类类型的容器里统一处理,这也得靠继承或接口实现。正确姿势是:先看语义,再看需要,不要被口号绑架。
6. 继承面试题和实操踩坑实录
6.1 高频面试题速查:super、重写重载、初始化顺序、深浅拷贝
把常考的点整理成一个速查表,面试前翻一翻很有用:
| 面试问题 | 答案要点 |
|---|---|
| super 和 this 能同时用吗? | 不能。二者都要求写在构造器首行,首行只能有一个。 |
| 重写和重载有什么区别? | 重写是父子类之间,签名相同,多态相关;重载是同类中多个方法,参数列表不同,编译期决定。 |
| 静态方法能被重写吗? | 不能,只能隐藏。调用看引用声明类型。 |
| 父类私有方法能被重写吗? | 不能。子类里写同名方法只是新方法。 |
| 构造器执行顺序? | 类加载静态块先执行,然后父类实例块、构造器,再子类实例块、构造器。 |
| final 类能被继承吗? | 不能。final 方法不能被重写。 |
| 抽象类能有构造器吗? | 能。虽然不能直接 new,但子类构造器会调用它完成父类部分初始化。 |
| 接口能多继承吗? | 接口可以 extends 多个接口,类只能单继承类,但可以实现多个接口。 |
还有一个实操中很常见的考点:对象深度拷贝。如果需要拷贝的对象处在继承体系里,最简单的方式是重写 clone 方法,但 super.clone() 默认是浅拷贝,复制出来的新对象和原对象共享内部引用对象。想要深拷贝,要么手动把每一个可变引用字段都 new 一份,要么利用序列化后反序列化实现,代价是性能差一些、要求所有字段可序列化。如果里里外外字段很多,我更推荐写一个专门的拷贝构造器,逐个字段复制,代码看着多,但可控性最好。
6.2 三个容易翻车的继承坑
第一个坑,把重载当重写,还忘了注解。最典型的是想重写 equals,结果方法签名写成 equals(Student s),参数类型变了,这其实是在重载 Object.equals,完全不影响多态。调试时发现集合里的对象怎么都 equals 不成功,查半天才发现名字写错了。所以我一律建议,重写方法先写 @Override 让编译器把关。
第二个坑,父类构造器里调用了被子类重写的方法。由于父类构造器先执行,它会提前触发子类的方法实现,而这时候子类自己的字段还没初始化,你可能会读到 null 或 0。比如:
class Base { Base() { init(); } void init() { System.out.println("Base init"); } } class Child extends Base { String value = "hello"; @Override void init() { System.out.println(value); } }new Child() 时,父类构造器先执行,调用的是子类重写的 init(),此时 value 还是默认值 null,输出 null。这个坑在 Spring 之类的框架里也很常见,解决办法是:不要在构造器里调用可重写方法,把初始化逻辑拆到普通方法里,等对象完全构造好后再显式调用。
第三个坑,重写 equals 时用错了类型判断方式。老生常谈:如果用 instanceof 判断,子类对象也能和父类对象互相 equals 成功,有可能破坏对称性;如果用 getClass() 判断,又要求两个对象必须是同一个类,会对子类比较很不友好。原则是业务里要求父子类对象不能相等就用 getClass(),要求只要类型兼容就比较内容就用 instanceof,但一定要配套处理 hashCode。没有一致的 hashCode,HashSet、HashMap 里就会出现“放进去找不着”的诡异问题。
6.3 排查思路快速速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| ClassCastException | 向下转型类型不匹配 | 转之前用 instanceof 判断,检查对象实际类型 |
| 编译报“Implicit super constructor undefined” | 父类没有无参构造器,子类没显式 super(参数) | 在子类构造器首行调用父类构造器 |
| 子类输出 null 或默认值 | 父类构造器调用了可重写方法 | 把初始化逻辑移出构造器,避免重写方法在字段初始化前执行 |
| 方法没生效,走了父类逻辑 | 方法签名写错,实际是重载不是重写 | 加 @Override 注解,让编译器提示 |
| HashSet 元素异常 | equals 和 hashCode 不一致 | 重写 equals 时同步重写 hashCode |
写到这里,我自己最想说的一点是:继承能力本身不难,难的是判断“该不该继承”。我现在写代码前,会先默问一句:这俩东西是 IS-A 吗?如果不是,就住手,改用组合或接口。这个习惯帮我躲过了很多后期维护的坑,也推荐你在练习时就养成。下一篇咱们可以聊封装进阶或者 Object 里的常用方法,慢慢把 Java 基础体系的最后几块拼图补上。