刚接触面向对象编程那会儿,我一度以为extends就是把两个类的代码“打通”的语法糖,子类自动拿到父类的一切。直到我在一个类库项目里写出了一棵深度四层的继承树,然后在改父类的一个方法时,眼睁睁看着三个子类同时崩溃,我才明白:继承是类库设计里约束最强、也最容易引发连锁反应的一种关系。这个系列的第6篇单独拎出来讲extends,说明它值得一次完整的梳理。
这篇文章围绕继承展开,面向的是已经知道“子类通过extends可以拿到父类字段和方法”但还想往深走一步的同学。我会从继承在类库设计中的定位讲起,把super、方法重写、构造器链路、继承与组合的取舍这几块掰开揉碎,配合真实可跑的Java代码,你也可以直接照着这篇文章复查你自己的类设计,看看哪些地方其实不该用继承,哪些地方是继承反而更优雅。
1. 继承在类库设计中的位置:什么场景下你才真的需要 extends
1.1 没有继承时,你的代码在疯狂复制粘贴
假设你在写一个员工管理系统,先定义一个普通员工类:
public class Employee { private String name; private String id; private double salary; public Employee(String name, String id, double salary) { this.name = name; this.id = id; this.salary = salary; } public double getSalary() { return salary; } public String getInfo() { return "姓名: " + name + ", 工号: " + id; } }过两天需求升级,要加一个经理类。经理和普通员工最大的区别是什么?经理有下属,可能还有绩效奖金。但如果不用继承,你只能这样写:
public class Manager { private String name; private String id; private double salary; private double bonus; private List<Employee> subordinates; public Manager(String name, String id, double salary) { this.name = name; this.id = id; this.salary = salary; } public double getSalary() { return salary; } public String getInfo() { return "姓名: " + name + ", 工号: " + id; } // 其余经理独有方法... }看出来了吗?name、id、salary三个字段,getSalary()、getInfo()两个方法,几乎原封不动地复制了一遍。一次两次还能忍,如果一个系统里有十几种员工类型,每加一种都要把这段代码抄一遍,出了 bug 得同时改十几个文件,这就是典型的“复制粘贴式开发”。继承在这里解决的就是这个最原始也最核心的问题:把公共状态和行为抽到父类,子类只保留差异。
所以在类库设计的语境下,继承的第一步不是写extends,而是先识别出哪些东西是“不变的公共部分”,哪些是“未来会变化的部分”。公共部分放父类,变化部分留子类。这比语法本身更重要。
1.2 继承背后的 is-a 关系,是建模时最先要确认的事
很多人学了语法就开始乱用继承,看到一个类需要另一个类的几个方法,就立刻extends上去。实际上,继承在面向对象建模里是一个语义极强的声明,它表示的是**is-a(是一种)**的关系。Manager是Employee的一种,所以Manager extends Employee在语义上是通顺的。
我习惯在类设计阶段先画一张简单的继承关系图,节点就是类,连线就是extends。不需要工具,纸上画都行。关键是画的时候要反复问自己:这个子类真的“是一种”父类吗?如果答案是“它只是用了父类的几个方法”,那这个关系就不该用继承,而应该用组合(后面第5节我会专门展开)。
画图还有个附带好处:能让你一眼看出继承树是不是太深了。原则上,我建议一棵继承树从上到下控制在三层以内。类库设计中尤其要注意这点,因为使用这个类库的人会沿着继承链一层层往上翻文档,每多一层,理解成本就翻一倍。
1.3 你每天都在用的类库,里面全是继承链
继承不是教科书里才有的概念。随便打开一个 Java 类库看一眼就明白了:ArrayList继承自AbstractList,AbstractList继承自AbstractCollection,最上面是Object。FileInputStream继承自InputStream,BufferedInputStream又继承自FilterInputStream。整个集合框架、IO框架,就是一张庞大的继承网。
为什么类库设计者这么喜欢用继承?因为在框架层面,继承是代码复用和扩展点声明的有效载体。模板方法模式(Template Method)就是典型例子:父类定义算法的骨架,把某些步骤留成抽象方法或可重写方法,子类通过继承填入具体的实现细节。比如AbstractList里有一个iterator()方法,它的实现依赖于子类提供的get(int index)和size(),子类只需要实现这两个方法,迭代逻辑父类全部替你搞定。
这就是类库中继承的真正意义:**父类负责把不变的东西写死,子类只负责提供变化的部分。**理解了这一层,你再看extends就不会仅仅把它当成一种“拿到父类成员”的手段了。
2. extends 基础语法:子类究竟从父类那里拿到了什么
2.1 从最基础的写法开始
Java 里用extends关键字声明继承关系,而且只能是单继承,一个类只能有一个直接父类。还是以员工系统为例,我们让Manager继承Employee:
public class Manager extends Employee { private double bonus; private List<Employee> subordinates = new ArrayList<>(); public Manager(String name, String id, double salary, double bonus) { super(name, id, salary); this.bonus = bonus; } public void addSubordinate(Employee e) { subordinates.add(e); } }这里Manager自动拥有了Employee里定义的name、id、salary字段,以及getSalary()、getInfo()方法。在Manager的代码里,你可以直接调用getInfo(),就好像这个方法定义在Manager里一样。在此基础上,子类还能新增自己的字段(bonus、subordinates)和方法(addSubordinate),也能重新定义一个父类已有的方法(后面第3节详谈)。
这段代码里已经出现了一个关键点:super(name, id, salary)。这是在调用父类的构造器,而且是子类构造器里的第一条语句。父类里没有无参构造器,所以必须显式调用带参构造器,否则编译都过不去。这一点后面第4节还会展开,现在先记住结论:子类的构造过程,一定是从父类构造器开始的。
2.2 父类的私有成员:别想着直接碰
一个特别容易踩坑的知识点,先说清楚:子类继承的是父类中所有可见成员,但private字段和方法是拿不到的。上面那个例子,子类Manager里想直接访问name,编译器会直接报错,因为name是Employee的私有字段。
很多初学者在这里卡住,觉得“子类不就该拥有父类的一切吗?”不是的。private本身就隐含了“仅类内部可见”的语义,这个封装边界连子类都不能突破。那子类想访问怎么办?两个方案:
- 通过父类提供的
getter/setter方法(比如getName()) - 把字段声明为
protected,允许子类直接访问
我的建议是:**默认用getter/setter,protected只在同一个类库包内部、且你真的希望子类直接用的时候才用。**因为protected一旦放出去,就意味着你把这个字段的结构细节暴露给了所有不知道在哪的子类,后续想改字段名或者加校验逻辑,波及面会非常大。类库设计尤其如此——你的子类不在你手上,你控制不了别人怎么用。
2.3 super 关键字:不只用来调构造器
super除了在构造器里调用父类构造器,还能在实例方法里访问父类的成员。最典型的使用场景是子类重写了父类的方法,但新方法里又想复用父类的一部分逻辑。
比如我要在Manager里重写getInfo(),把经理的奖金也打出来。如果完全从零写一遍,就浪费了父类已经写好的姓名和工号拼接逻辑。这时候super就派上用场了:
@Override public String getInfo() { return super.getInfo() + ", 奖金: " + bonus; }super.getInfo()优先调用父类的版本,拿到基础信息后,再追加子类特有的部分。这种写法在类库设计中非常重要,因为你不知道使用者会怎么扩展你的类,但你至少可以保证:只要子类愿意调用父类版本的基础逻辑,父类的核心契约就不会被破坏。
顺带提一个细节:super()必须放在子类构造器的第一行,但super调用普通方法没有位置限制。这两者经常有人搞混。
2.4 一个完整的可运行示例
把上面的代码拼起来,给你一个可以直接跑通的最小例子,建议本地复制运行一遍再继续往下读:
// Employee.java public class Employee { private String name; private String id; private double salary; public Employee(String name, String id, double salary) { this.name = name; this.id = id; this.salary = salary; } public double getSalary() { return salary; } public String getInfo() { return "姓名: " + name + ", 工号: " + id; } } // Manager.java public class Manager extends Employee { private double bonus; public Manager(String name, String id, double salary, double bonus) { super(name, id, salary); this.bonus = bonus; } @Override public String getInfo() { return super.getInfo() + ", 奖金: " + bonus; } } // Main.java public class Main { public static void main(String[] args) { Employee e = new Employee("张三", "E001", 8000); Manager m = new Manager("李四", "M001", 15000, 5000); System.out.println(e.getInfo()); System.out.println(m.getInfo()); System.out.println(m.getSalary()); } }输出:
姓名: 张三, 工号: E001 姓名: 李四, 工号: M001, 奖金: 5000 15000.0注意最后一行:m.getSalary()这个方法是父类定义、子类不改的,但子类对象照样能用。继承最朴素的收益在这里表现得很直观。
3. 方法重写与多态:继承最有价值的三分之一
3.1 重写(Override)不是你想怎么写就怎么写
重写指的是子类重新定义父类中已有的方法,方法签名(方法名、参数列表)必须完全一致。上面例子中getInfo()就是一个重写。但重写有几条硬性规则,少记一条就会编译报错,或者埋下运行时隐患:
| 规则项 | 要求 |
|---|---|
| 方法名与参数列表 | 必须与父类被重写方法完全一致 |
| 返回类型 | 可以是父类返回类型的子类型(协变返回),但最终必须兼容 |
| 访问修饰符 | 不能比父类更严格(父类public,子类不能protected) |
| 抛出的受检异常 | 不能比父类范围更大 |
static方法 | 不能被重写,只能被隐藏 |
第一条和第三条是最容易踩的坑。getInfo(String prefix)这种加了一个参数的方法不是重写,是重载(后面马上讲)。把父类的public方法重写为protected更是直接编译失败。我见过有人因为子类里手滑把public写成了private,编译期发现不了(因为被当作普通新方法处理),运行时机密逻辑没走子类版本、被调用时抛异常,排查了很久。
只要是你有意重写的方法,一律加上@Override注解。这个注解不加也不影响编译(只有方法签名对不上时才会报错提示你写错了,这是它最大的价值),一旦你写错签名,编译器会直接告诉你这不是重写,能省大量排查时间。
3.2 重载(Overload)和重写,别再傻傻分不清
重载是同一个类里出现多个同名方法,参数列表不同,和继承没有半毛钱关系。重写是父子类之间的方法重新定义。两者区别如下:
| 维度 | 重写(Override) | 重载(Overload) |
|---|---|---|
| 发生位置 | 父子类 | 同一个类中 |
| 方法签名 | 必须完全一致 | 参数列表必须不同 |
| 返回类型 | 协变或相同 | 无限制 |
| 关键字 | @Override | 无 |
| 调用绑定 | 运行时动态绑定 | 编译期静态绑定 |
类库设计中,重写和重载经常配合使用。父类提供一个通用版本的方法,子类重写它,同时子类自己又增加一个带附加参数的重载版本。重写保证多态生效,重载提供更多的调用便利。
3.3 多态:父类引用指向子类对象,动态绑定在起作用
继承带来最有用的能力就是多态。简单说,就是父类类型的引用可以指向子类对象,调用某个方法时,实际执行的是子类重写的版本。
Employee e = new Manager("李四", "M001", 15000, 5000); System.out.println(e.getInfo());这段代码中e的编译类型是Employee,实际对象类型是Manager。调用getInfo()时,Java 虚拟机在运行时发现实际类型是Manager,就去找Manager里重写的那个版本执行,输出结果是带奖金的经理信息。这个运行时才确定调用哪个方法的行为,叫动态绑定。
这有什么用?类库设计里最常见的场景是基于父类类型写通用代码。比如打印系统里所有员工的工资单:
public static void printPayroll(List<Employee> employees) { for (Employee e : employees) { System.out.println(e.getInfo() + ", 应发: " + e.getSalary()); } }这个方法只依赖Employee类的方法,但你传Manager、Engineer、Intern都没问题,只要它们都是Employee的子类,且重写了getInfo(),打印时各自显示各自的信息。如果未来新增一个Director类,整个printPayroll方法一行都不用改。这就是多态对扩展性最核心的贡献:面向父类编程,而不是面向具体类编程。
3.4 用模板方法模式模拟类库里的继承扩展
理解了动态绑定,再看类库中一个特别经典的用法——模板方法模式。我用一个小例子来演示,你可以把它当作你写类库时最常用到的继承模式:
public abstract class DataParser { // 模板方法:定义整个流程骨架,不允许子类重写 public final void parse(String filePath) { String data = readFile(filePath); String parsed = parseData(data); writeToDb(parsed); } protected abstract String readFile(String filePath); protected abstract String parseData(String data); protected void writeToDb(String data) { System.out.println("写入数据库: " + data); } } public class CsvParser extends DataParser { @Override protected String readFile(String filePath) { return "读取CSV: " + filePath; } @Override protected String parseData(String data) { return "解析CSV内容"; } }父类DataParser把解析流程写好(读文件→解析→写库),子类只负责实现两个抽象方法。这就是类库设计中的经典扩展方式:**使用继承的人不用关心流程,只需要按父类约定的接口提供细节实现。**你写类库的时候,如果想把“扩展点”暴露给使用者,模板方法是比接口更合适的选择,因为接口只能约束行为,模板方法还能约束业务流程的顺序。
4. 构造方法与继承链:这一节全是坑
4.1 子类构造器第一行,一定藏着一条 super()
先记住一个结论:创建子类对象时,一定先创建父类对象(或者说先执行父类构造器),然后才执行子类构造器。Java 编译器在子类构造器第一行自动插入super()调用,如果你不写,它就隐式调用父类的无参构造器。
public class A { public A() { System.out.println("A 构造器"); } } public class B extends A { public B() { // 这里隐藏了 super(); System.out.println("B 构造器"); } }执行new B(),输出顺序是A 构造器然后是B 构造器。这个顺序从Object一路往下,整条继承链上的构造器都会按从父到子的顺序执行。多条子类调用链下来,每个类只关心自己直接父类,Java 编译器会自动帮你把整条链串起来。
4.2 父类没有无参构造器:最经典的编译错误
这是我在各种项目里看到频率最高的编译错误之一。父类一旦定义了带参构造器,Java 就不再提供默认的无参构造器。这时候子类构造器如果不显式调用super(...),编译器尝试调用父类无参构造器就会失败:
public class Parent { public Parent(String name) { // ... } } public class Child extends Parent { public Child(String name) { // 编译错误:隐式 super() 找不到无参构造器 } }解决办法很简单,子类显式调用父类的带参构造器:
public Child(String name) { super(name); // 显式调用父类带参构造器 }所以我的建议是:类库中的父类,要么给个无参构造器,要么把构造器设计成带必填参数,并指望子类显式调用。但有一点要注意,如果某些字段是业务上必须有的(比如员工必须有工号和名字),那尽量不要提供无参构造器,否则别人可以构造出不合格的对象,这个约束靠构造器参数来保证比靠后面加校验更可靠。
4.3 初始化顺序:一个让你面试和排错都少走弯路的点
类加载和对象初始化是有固定顺序的,继承把这个顺序拉成一条链。以类B extends A为例,创建B对象时顺序是:
- 加载类
A,执行A的静态初始化块和静态字段赋值 - 加载类
B,执行B的静态初始化块和静态字段赋值 - 执行
A的实例初始化块(按代码顺序) - 执行
A的构造器 - 执行
B的实例初始化块(按代码顺序) - 执行
B的构造器
注意静态初始化只执行一次,且在第一次加载类时执行,不是每次创建对象都执行。你可以写个小程序打印一下顺序,亲眼看一遍比背十遍都管用。
这个顺序最重要的实际含义是:子类构造器执行前,父类构造器已经把父类字段初始化好了。所以在父类构造器里访问父类字段,永远不会遇到字段还没初始化的“半成品状态”。但前提是——不要违反下一条规则。
4.4 父类构造器里调用了被子类重写的方法:运行时事故
这是我在实际项目里踩过的最深的一个坑,也是面试官特别爱问的一道题。看代码:
public class Parent { public Parent() { init(); } private void init() { System.out.println("Parent.init"); } } public class Child extends Parent { private String name = "child"; public Child() { // 隐式 super() 先执行 } @Override public void init() { System.out.println("Child.init, name=" + name); } }执行new Child(),会输出什么?答案是Child.init, name=null。
原因:子类构造器第一行触发父类构造器,父类构造器调用init()时,由于动态绑定,实际执行的是Child里重写的init()。而此时Child的构造器还没有执行,name字段还没来得及赋值为"child",所以打印出来是null。
更要命的是,如果init()里访问了某个还没初始化的字段,甚至会抛出NullPointerException。
怎么规避?核心原则只有一条:不要在构造器里调用可被重写的方法。如果必须在构造器里做初始化,用private方法(不可重写),或者把初始化逻辑放到子类构造器里,由子类自己完成。这是类库设计者必须严守的纪律,因为你根本不知道未来哪个子类会重写哪个方法。
5. 撕开继承的边界:那些你必须知道但不一定能接受的事实
5.1 单继承限制与接口的补位
Java 一个类只能有一个直接父类,如果允许多重继承,就会出现经典的菱形问题:假设D同时继承B和C,而B和C都重写了A的一个方法,那D到底该继承谁的实现?C++ 为了处理这个问题设计了复杂的虚继承规则,Java 干脆一刀切,只允许单继承。
那“一个人既是老师又是程序员”这种需要多个身份的场景怎么办?用接口。接口允许多实现,接口里的默认方法(default方法)从 Java 8 开始也可以有实现,如果两个接口有相同的默认方法,实现类必须显式重写解决冲突。类库设计里有一个实战中的参考标准:优先用接口定义行为,用继承复用实现。如果你的类库使用者只需要“会飞”的能力,就给他一个Flyable接口,而不必强迫他去继承某个具体的Bird类。
5.2 final 关键字:给继承踩刹车
final有三个作用位置,每一个都是在给继承“设限”:
final class:这个类不能被继承。Java 标准库里String就是final class。为什么?因为字符串太常用、太核心了,如果允许子类继承,子类可以重写方法然后改变字符串的底层行为,整个 JVM 的安全模型都会被攻破。final method:这个方法不能被子类重写。上面的模板方法模式里,parse()方法我用final声明,就是为了保证流程骨架不被子类破坏。final variable:变量只能赋值一次,和继承没有直接关系,但可以让字段值不可变。
我的建议是:别吝啬final。在类库设计里,能加final的地方尽量加,它不是限制,而是保护。它明确告诉使用你类库的人:这里是安全的、稳定的内核,你放心用;只有那些你明确开放扩展点的地方,才是你能动手改的。
5.3 怎么在继承和组合之间做选择
继承(is-a)和组合(has-a)之争是面向对象设计里最经典的讨论之一。组合的意思是:一个类持有另一个类的引用,通过调用被持有者的方法来实现功能。
// 继承实现 public class Stack<E> extends ArrayList<E> { public void push(E e) { add(e); } public E pop() { return remove(size() - 1); } }这个设计看着挺顺,但 Java 标准库里Stack就是个被长期吐槽的反面教材。真正实现的Stack继承自Vector,问题是Stack本该只支持push、pop、peek,可因为父类是Vector,子类继承了add、remove、get等等一堆破坏栈语义的方法。调用方可以直接stack.add(0, xxx)把元素塞到栈底,栈的“后进先出”约束瞬间形同虚设。
用组合重写:
public class Stack<E> { private ArrayList<E> list = new ArrayList<>(); public void push(E e) { list.add(e); } public E pop() { return list.remove(list.size() - 1); } public int size() { return list.size(); } }组合把所有不该暴露的方法都挡住了,栈的语义被彻底保护起来。我总结了一个简单的判断标准:
| 判断问题 | 用继承 | 用组合 |
|---|---|---|
| 子类和父类的关系是什么? | 是(is-a) | 有(has-a) |
| 子类是否需要父类的全部能力? | 是 | 否 |
| 父类的方法是否都会被子类使用? | 基本全用 | 只需要一部分 |
| 是否需要多态扩展? | 需要 | 不需要或可通过接口实现 |
类库设计里,优先组合,只在明确存在 is-a 关系且需要多态扩展时用继承。这个顺序颠倒过来,是绝大多数类设计腐化的开始。
5.4 里氏替换原则:翻车前的最后一道防线
里氏替换原则(Liskov Substitution Principle)的内容可以用一句话概括:凡是能用父类对象的地方,都应该能替换成任意子类对象,且程序行为不出错。如果替换后行为异常或者干脆报错,就说明你的继承关系设计有问题。
最经典的违反案例是“正方形继承长方形”:
public class Rectangle { private int width; private int height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } } public class Square extends Rectangle { @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); // 为了保持正方形,必须同时设置height } // ... }看起来没问题,但如果调用方写了一个通用方法:
public void resize(Rectangle rect) { rect.setWidth(10); rect.setHeight(5); assert rect.getHeight() == 5; // 对Square来说,这里会断言失败 }传入Square对象后,setWidth(10)会把高也改成10,紧接着setHeight(5)再把宽改成5,最终宽高都是5,而不是预期的宽10高5。行为对不上,替换失败,继承关系崩塌。
检查你的类设计时,如果你发现“父类引用指向子类对象时某个方法行为会变得诡异”,赶紧停下来反思:要么子类没有真正继承父类的契约,要么这个继承关系本身就不该存在。宁可改成组合,也不要硬凑一个看似合理实则随时会碎的多态。
6. 一些值得长期养成的习惯
写了这么多,最后分享几点我从类库设计和实际工程里沉淀下来的个人习惯,算是对上面的内容做一个浓缩:
- 继承关系建立之前,先写 is-a 测试。问自己“这个类真的是一种那个类吗?”,如果犹豫,就别用继承。
- 重写方法一律加
@Override,编译器会帮你盯住那些“以为重写出错却不知道错在哪”的签名问题。 - 父类构造器里永远不调用可重写方法,这条纪律能防住一大类诡异的运行时 bug。
- 父类尽量用
protected而不是public暴露扩展点,能用组合就不把继承面铺得太大。 - 深挖一下你常用类库的源码。打开
ArrayList、HashMap的类声明,逐个看它们的父类、接口、哪些方法是final/protected/abstract,把「你们都在用」的类库当成学习继承的最佳教材,胜过读十篇博客。
继承本身没有对错,它只是众多代码复用手段之一。关键是把它的适用场景想清楚:什么时候该用它享受扩展性,什么时候该绕开它用组合控制复杂度。这条路走通了,你的类设计就基本成熟了。