继承、super、this、抽象类,这四样东西到底怎么串起来用
今天聊的是Java面向对象里最容易绕晕的一组核心概念:继承、super、this、抽象类。很多初学者学到day09左右,都会在继承这块卡一下——概念都看得懂,一写代码就不知道super和this该用谁,抽象类写出来也总觉得跟普通类没啥区别。
先给个整体认知:继承是骨架,super和this是继承体系里用来“找人”的两把钥匙,抽象类是继承体系里专门放“规则”的地方。把这条主线理清楚,后面学多态、学接口、学设计模式都会顺很多。这篇文章我按自己平时带新人时讲的思路来写,配合代码示例和踩坑记录,适合正在学Java基础和准备面试的读者参考,也适合复习时当查漏补缺的清单。
1. 继承这件事,核心是搞清楚“为什么要继承”
1.1 继承解决的本质问题
继承解决的核心问题是代码复用和行为扩展,同时它也是多态的前提。
举个例子:一个系统里要有学生类、教师类,它们都有姓名、年龄、身份证号,都有自我介绍的方法。不用继承时,你得写两个几乎一样的类,字段重复、方法重复,改一个地方就要改两个文件。
用了继承之后,可以把公共的部分抽到一个Person类里,Student和Teacher各自extends Person,只写自己特有的东西。这就是“复用”。
但继承不是单纯为了少写几行代码,更深层的价值在于表达了“is-a”关系:Student是一个Person,Teacher也是一个Person。这个关系一旦建立起来,就能用父类类型的变量去接收子类对象,也就是多态。所以面试里经常把封装、继承、多态并列为面向对象三大特性,因为它们本身就是一整套配合使用的机制。
1.2 Java里继承的几种落地方式
Java的继承在类层面只支持单继承,一个类只能有一个直接父类,这一点跟C++那种多继承不同。单继承的好处是继承链清晰,不会出现“菱形继承”那种二义性问题;代价是表达力受限,所以Java用接口来弥补,一个类可以实现多个接口。
类继承的关键字是extends:
public class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat() { System.out.println(name + "正在吃东西"); } } public class Dog extends Animal { public Dog(String name) { super(name); } public void bark() { System.out.println(name + "汪汪叫"); } }这里Dog就继承了Animal的name字段和eat方法,同时自己新增了bark方法。
还有几种常见的继承变体,虽然看上去不像继承,本质上也属于继承体系的一部分:
- 匿名内部类:
new Animal("x") { ... },本质是创建了一个Animal的匿名子类对象,可以重写父类方法。 - 接口继承:接口用extends继承另一个接口,可以多继承,比如
public interface C extends A, B。 - 抽象类作为父类:父类只定义方法签名或部分实现,强制子类填充细节,这是抽象类最常见的用法。
提示:类继承是单继承,但父类本身可以继承别的类,所以继承链可以很长。实际工程里建议控制继承深度,一般不要超过三四层,否则代码可读性和维护性都会下降。
1.3 什么时候不应该用继承
这点很重要,但很多教程很少讲。继承不是越多越好,用错了反而制造麻烦。
继承真正适用的场景是子类和父类之间有稳定的is-a关系。如果拿不准,优先考虑组合——就是在一个类里持有另一个类的对象引用。
举两个反例:
- 想让Stack复用ArrayList的存储能力,于是让Stack extends ArrayList。Stack是ArrayList吗?不是,Stack应该是“内部有ArrayList”。继承在这里就是错误的,因为你把Stack暴露成了List,什么add、remove、get都可以调用,破坏了Stack的语义。
- 为了让DropdownButton复用Button的样式逻辑而继承Button,但下拉按钮的点击行为和外观跟普通按钮差异很大,强行套继承会逼着子类重写一堆方法。
所以判断标准很简单:子类能不能“是一个”父类?能,才用继承;不能,用组合。继承是白盒复用,能访问父类内部细节,耦合高;组合是黑盒复用,只通过公开接口协作,耦合低。
2. super和this:两把钥匙,管的是不同的门
2.1 this是怎么“指向自己”的
this在Java里代表当前正在执行方法的那个对象。它最朴素的用法有三个:
第一,区分成员变量和局部变量。方法参数叫name,成员变量也叫name时,this.name = name左边的this.name是成员变量,右边的name是参数。
第二,调用本类的其他构造器。比如一个类有全参构造器和无参构造器,可以让无参构造器调用有参构造器,避免代码重复:
public class User { private String name; private int age; public User() { this("未命名", 0); } public User(String name, int age) { this.name = name; this.age = age; } }注意:this(...)调用构造器时,必须是构造器里的第一条语句,否则编译报错。
第三,把当前对象传出去。比如在内部类里访问外部类对象,或者写链式调用时return this,这是很常见的写法。
2.2 super:访问父类成员的专用通道
super在Java里代表父类对象的引用,你可以用它来访问父类中被覆盖的成员变量、被重写的实例方法,以及调用父类构造器。
常用场景是子类重写了父类方法,但还想复用父类的逻辑:
public class Manager extends Employee { private double bonus; public Manager(String name, double salary, double bonus) { super(name, salary); this.bonus = bonus; } @Override public double getSalary() { double baseSalary = super.getSalary(); return baseSalary + bonus; } }这里的super.getSalary()调的是父类Employee的getSalary方法,如果直接写getSalary()就会无限递归调用自己,最终栈溢出。
super还有一个关键用途是调用父类构造器。子类构造器默认会调用父类的无参构造器,如果父类没有无参构造器,就必须显式写super(参数)。
注意:
super(...)和this(...)一样,必须在构造器第一条语句。而且二者不能同时出现,因为都需要占第一行的位置。
2.3 super和this的区别速查表
很多初学者分不清super和this,我把它们的关键差异整理成一张表:
| 对比维度 | this | super |
|---|---|---|
| 代表对象 | 当前类的当前对象 | 父类对象(逻辑上的引用) |
| 查找范围 | 先从当前类找,找不到往上找 | 直接从父类开始找 |
| 调用构造器 | this(...) 调用本类其他构造器 | super(...) 调用父类构造器 |
| 静态上下文 | 不能使用 | 不能使用 |
| 是否能省略 | 大部分场景可省略 | 父类成员被子类遮蔽时不可省略 |
一个容易混淆的点:this并不总是能省略的。子类里有一个方法名和父类同名时,如果直接用方法名调用,调用的是子类自己的;只有用super.方法名()才是父类的。同理,子类定义了和父类同名的成员变量时,直接用变量名拿到的是子类的,super.变量名才能拿到父类的。
2.4 构造器调用链——先父后子的关键逻辑
继承体系下,new一个子类对象时,构造器的执行顺序是:先执行父类构造器,再执行子类构造器。这是JVM保证的,因为子类对象在内存中包含父类部分的数据,必须先初始化父类部分。
看一段典型代码:
public class A { public A() { System.out.println("A的构造器"); } } public class B extends A { public B() { System.out.println("B的构造器"); } } public class C extends B { public C() { System.out.println("C的构造器"); } } // new C()输出: // A的构造器 // B的构造器 // C的构造器每个子类构造器里第一行都有个隐式的super(),就算你不写它也在那儿。如果父类没有无参构造器,编译就会报错:
Implicit super constructor A() is undefined. Must explicitly invoke another constructor解决办法就是子类构造器里显式调用父类的有参构造器。
这块有个衍生考点:静态代码块、实例代码块、构造器的执行顺序。我建议自己动手写个带继承的类打印一下顺序,比死记硬背强。
3. 方法重写:继承中最容易出问题的环节
3.1 重写的基本规则
子类重写父类方法,目的通常是“保留方法的行为框架,改变具体实现逻辑”。重写需要满足几个硬性规则:
- 方法名、参数列表必须和父类方法完全一致。
- 返回值类型可以是父类方法返回类型的子类型(协变返回类型)。
- 访问权限不能比父类更严格,比如父类是public,子类就不能改成protected或private。
- 不能抛出比父类更宽泛的受检异常。
- static方法不能被重写;final方法不能被子类重写;private方法对子类不可见,谈不上重写。
一个我在实际代码里经常看到的错误:子类方法参数写成了父类参数类型的父类,导致编译不报错,但这其实是重载而不是重写。比如父类有void eat(Animal a),子类写了void eat(Object o),两个方法签名不同,子类对象调用eat时传Dog对象,用的是子类方法,但穿Animal类型参数时父类的方法还在。这种隐蔽问题用@Override注解能立刻发现——编译器会提醒你根本没有重写任何方法。
提示:重写方法一定加
@Override注解,不是为了好看,是为了让编译器帮你检查签名是否真的匹配。
3.2 重写和重载的区别
重载(overload)发生在同一个类里,方法名相同、参数列表不同;重写(override)发生在继承体系里,方法名和参数列表都相同。
两句话总结:重载是“同一个方法名,多个版本”,重写是“同一个方法签名,换个实现”。
初学者常犯的误区是把重载误以为重写。记住一个判断技巧:看方法签名(方法名+参数类型),完全相同才可能是重写;参数不同就是重载,跟是不是父子类没关系。
3.3 静态方法能不能重写
结论:Java的静态方法可以用相同签名在子类中再写一遍,但这叫隐藏(hide),不叫重写,因为它是按引用类型而不是对象实际类型来决定调用哪个版本的。
public class Parent { public static void hello() { System.out.println("Parent.hello"); } } public class Child extends Parent { public static void hello() { System.out.println("Child.hello"); } } Parent p = new Child(); p.hello(); // 输出 Parent.hello,而不是 Child.hello这个例子足以证明静态方法不具备多态性。面试如果问到“静态方法能被重写吗”,先答“不能”,再解释隐藏机制的区别,基本就是满分回答。
4. 抽象类:把“规范”和“实现”分开
4.1 抽象类到底在表达什么
抽象类用abstract关键字修饰,它可以包含普通方法,也可以包含没有方法体的抽象方法。
抽象类存在的意义不是直接使用,而是给子类定义一套必须遵守的规矩。比如定义Shape类,里面写一个抽象方法double area(),那么所有继承Shape的子类都必须实现面积计算方法。父类说不清楚面积怎么算,就把它定义成抽象方法,让具体图形去实现。
抽象类和普通类的区别,可以从这几个维度看:
| 维度 | 普通类 | 抽象类 |
|---|---|---|
| 实例化 | 可以直接new | 不能new,只能作为父类 |
| 抽象方法 | 不能有 | 可以没有,也可以有 |
| 构造器 | 正常定义 | 可以有构造器,但只能被子类super()调用 |
| 用途 | 描述具体对象和实现细节 | 描述公共特征和行为规范 |
一个比较特殊的点:抽象类里没有抽象方法是合法的,比如abstract class Logger {}。这种类存在的意义通常是配合工厂模式或模板方法使用,让人不去直接new它。
但反过来,只要类里有抽象方法,类必须声明为abstract,否则编译报错。子类继承抽象类后必须实现所有抽象方法,否则子类也得是抽象类。
抽象类和普通类还有一个隐藏区别:抽象类不能是final的,因为final禁止继承,跟抽象类的设计意图直接矛盾。
4.2 抽象类和接口的区别
这是高频面试题,我平时基本按下面这张表讲:
| 对比维度 | 抽象类 | 接口 |
|---|---|---|
| 关键字 | abstract class | interface / implements |
| 继承方式 | 单继承(extends) | 多实现(implements) |
| 成员变量 | 普通字段、常量都行 | 只能是public static final常量 |
| 方法 | 抽象方法+具体方法 | Java 8之前只有抽象方法;Java 8后可以有default/static方法;Java 9后可以有private方法 |
| 构造器 | 有,供子类super()调用 | 没有构造器 |
| 设计语义 | is-a(是什么) | can-do(能做什么) |
| 类加载时机 | 随父类链一起初始化 | 常量可以被编译期优化,default方法逻辑是在接口里 |
用一个生活化类比:抽象类像“动物”这种分类——狗是动物,狗的呼吸方式可以定义在动物里;接口像“会飞”这种能力——鸟会飞,飞机也会飞,但鸟和飞机没有血缘关系,它们只是都有“飞”这个能力。所以“会飞”更适合做成接口。
实际开发中,如果想共享代码实现,用抽象类;想定义能力契约、强调解耦和多实现,用接口。Java 8之后接口有了默认方法,两者界限变得模糊,但语义上的分工依然清晰。
4.3 模板方法模式:抽象类最常见的玩法
抽象类在工程里最常见的应用是“模板方法模式”。父类定义算法的骨架,把某些步骤延迟到子类实现。
举个具体的流程场景:数据报表导出流程,步骤固定是“读取数据 -> 处理转换 -> 导出文件”。读取和处理对大多数数据源是共通的,导出格式则各不相同(Excel、PDF、CSV)。这种场景非常适合抽象类:
public abstract class ReportExporter { public final void export() { List<String> data = readData(); List<String> processed = process(data); writeFile(processed); } private List<String> readData() { // 公共实现,从数据库读取 } protected List<String> process(List<String> data) { // 默认处理逻辑,子类可以重写扩展 return data; } protected abstract void writeFile(List<String> data); }export方法加了final,防止子类破坏整体流程。子类只需要实现writeFile的细节,流程骨架在父类中被锁定。这个模式在框架源码里非常常见,理解了它,抽象类的价值就真正落地了。
5. 一整天踩坑实录:继承和抽象类的高频问题
5.1 高频编译错误与排查方法
我在给新人辅导和看项目代码时,遇到过下面这些高频问题,每个都附了排查思路。
问题一:Implicit super constructor is undefined
场景:父类只写了有参构造器,子类构造器里没有调用。 原因:每个构造器第一行有隐式super(),父类没有无参构造器时调用失败。 解决:在子类构造器第一行显式调用super(参数)。
问题二:子类中想调用父类被重写的方法,但一直调用到自己
场景:重写方法里写this.xxx()或直接xxx()。 原因:方法调用默认按实际类型分派,直接调用会调到子类自己的版本。 解决:使用super.xxx()。
问题三:抽象子类没实现全部抽象方法,报错说必须声明为abstract
场景:一个类继承抽象类但只实现了部分抽象方法。 原因:抽象方法没有被完全实现时,类仍含抽象成分,必须声明为abstract。 解决:要么实现全部抽象方法,要么把当前类改成了abstract。
5.2 几个容易忽略的细节
继承体系里的细节非常多,下面几个是很多人工作一两年都未必注意到的,我单独拎出来说一下。
父类private成员不是“不可继承”,而是子类不可见。内存上子类对象里确实有父类private字段的空间,但子类代码访问不到,只能通过父类的public/protected方法间接操作。So,面试被问“private方法能被重写吗”,答案是不能,它压根不可见。
构造器不能继承,也不能被重写。构造器名字和类名一致,没有返回值,子类不可能有同名的构造器。但子类构造器必须调用父类构造器,这是硬性要求。
变量的隐藏和字段遮蔽。子类和父类定义了同名变量时,直接使用变量名拿到的是子类的字段;用父类类型的引用访问这个字段,拿到的却是父类的字段。这就导致同一个变量在不同引用类型下结果不同。跟方法重写不同,字段没有多态性。
final关键字在继承里的作用。final修饰的类不能被继承,final修饰的方法不能被重写,final修饰的变量是常量。很多框架里的工具类都用了final类。
5.3 关于继承体系设计的几条建议
最后几条经验之谈,都是从实际项目中总结出来的:
第一,继承层次不要设计得太深。三层以内最好,一旦超过四层,代码阅读成本急剧上升,调试时你会在“这个方法到底是从哪里继承来的”上浪费大量时间。
第二,父类的构造器里不要调用可被重写的方法。因为在父类构造器执行阶段,子类对象还没完全构造好,此时调用被子类重写的方法,子类的字段可能还是默认值null或0,极易出现NullPointerException。如果必须调用,优先考虑模板方法模式中的protected方法,确保子类字段已初始化。
第三,能用接口表达的“能力”就不要强行做成继承。比如可序列化、可克隆、可比较这些能力,用implements表达更合理。抽象类适合表达“有共同血缘关系”的一族类。
第四,重构时如果发现子类对父类某个方法的重写大面积发生,而且重写后根本不调用super方法,就要考虑这个方法是不是根本不该放在父类里,也许应该下沉为接口或者干脆挪到子类里去。
最后再分享一个我的习惯
每次带新人学这块,我都会让他们写一个小练习:设计一个“员工管理系统”,包含抽象类Employee,派生出Manager和Developer两个具体类,要求Employee里有一个抽象方法work(),一个普通方法getInfo(),构造器传入姓名和薪资,Manager里还要能调用super的getInfo再拼接管理层信息。这个练习做完,继承也好、super和this也好、抽象类也好,基本就能串起来了。
另外有个小技巧:写代码前先在纸上把类图画出来,箭头标清楚谁继承谁、哪个方法被重写、哪个方法调用super。画不出来的地方,多半就是理解还有漏洞的地方。很多看似复杂的报错,本质上都是继承链没理清楚导致的。我个人的体会是,这组概念真正学透的标准不是背得出定义,而是能只看一份类的继承结构图,就准确说出创建子类对象时构造器的执行顺序、某个方法调用会被分派到哪个类的实现——能达到这个程度,后面的多态和设计模式学起来就轻松多了。