封装 / 继承 / 多态
Java 初级 · 面向对象
封装、继承、多态是面向对象的三大特性,它们共同解决「代码复用」与「可扩展」的问题。很多教程只给结论,本节重点讲清多态的底层——虚方法表(vtable)与动态分派,让你明白「父类引用为何能调到子类方法」。这一节是面向对象真正的分水岭。
一、封装:不止是 private
封装 = 隐藏内部实现细节,只暴露受控接口。上一节已铺垫:字段 private + getter/setter,在 setter 中校验,保证对象永远不会进入非法状态。封装的价值是把「怎么实现」和「怎么用」解耦——内部改了实现,调用方无感知。
二、继承:代码复用的「is-a」关系
class B extends A,子类获得父类非 private 成员,Java 单继承(一个类只能有一个直接父类,但可实现多个接口)。
class Animal {
public void sound() { System.out.println("动物叫"); }
}
class Dog extends Animal {
@Override
public void sound() { System.out.println("汪汪"); } // 重写
}继承表达的是 is-a,不是 has-a
Dog extends Animal 成立(狗是动物);但「汽车有发动机」不该写成 Car extends Engine,而应是 Car 里组合一个 Engine 字段。组合优于继承:继承会固化强耦合、破坏封装(子类依赖父类实现),非 is-a 关系硬继承,后期极难维护。
重写(override)的规则
- 方法名、参数列表必须完全一致;返回类型可为协变(子类方法返回父类方法返回类型的子类型)。
- 访问修饰符不能比父类更严格(父类
public,子类不能改protected)。 - 子类抛出的受检异常不能比父类更宽。
- 静态方法、
private方法、final方法不能被重写(它们是静态绑定,见下)。 - 务必加
@Override注解:一旦签名写错(拼错方法名、参数不符),编译器立刻报错,而非默默变成「重载」。
三、多态的核心:运行期动态分派
Animal a = new Dog(); // 父类引用指向子类对象
a.sound(); // 运行时输出「汪汪」,不是「动物叫」「a 的编译期类型是 Animal,运行期实际类型是 Dog」——JVM 在运行期根据对象真实类型决定调用哪个 sound(),这叫动态分派(dynamic dispatch)。它是多态成立的底层机制。
虚方法表(vtable):动态分派怎么快
如果每个方法调用都去查「这个对象到底是什么类」,会非常慢。HotSpot 用虚方法表(vtable) 加速:
- 每个类加载时,JVM 为它建一张 vtable,记录「每个虚方法名 → 实际要跳到哪个实现」。
- 子类重写父类方法时,子类的 vtable 里那一栏被替换成子类实现(如上
sound指向Dog.sound)。 - 调用
a.sound()(invokevirtual)时:JVM 顺着对象头里的类型指针找到Dog的 vtable,取sound槽对应的实现执行。
因为查表是 O(1),动态分派几乎无额外开销。这正是 invokevirtual + vtable 支撑起 Java 多态的工程实现。
静态绑定 vs 动态绑定对照
| 场景 | 指令 | 绑定时机 | 是否走 vtable |
|---|---|---|---|
static 方法 | invokestatic | 编译期 | 否 |
构造/private/super | invokespecial | 编译期 | 否 |
| 普通实例方法 | invokevirtual | 运行期 | ✅ 走 vtable |
| 接口方法 | invokeinterface | 运行期 | 类似(查接口表) |
所以:private、构造、static 方法是静态绑定(编译期就锁定目标,无法被多态改写);只有普通实例方法才走 vtable 实现多态。
四、抽象类 vs 接口:模板 vs 契约
抽象类(abstract class)
可包含抽象方法(无实现)和具体方法(有实现),适合「抽取子类共性」。它不能被 new,必须由子类继承实现抽象部分。
abstract class Shape {
abstract double area(); // 抽象方法:子类必须实现
void describe() { // 具体方法:子类直接复用
System.out.println("面积=" + area());
}
}接口(interface)
Java 8 之后接口能力很强:可有抽象方法、默认方法(default)、静态方法、私有方法。接口表达「能力契约」——「能飞」「能比较」,与继承的 is-a 互补。
interface Flyable {
void fly(); // 抽象方法
default void start() { // 默认方法:提供默认实现,子类可不重写
System.out.println("起飞准备");
}
}选择原则:
- 表示「是什么(is-a)」、要共享状态/代码 → 抽象类。
- 表示「能做什么(can-do)」、要支持多实现、面向行为编程 → 接口。
- 声明变量/参数类型时面向接口/抽象类,而非具体类,便于替换实现(如
List<String> list = new ArrayList<>())。
接口默认方法冲突
一个类同时实现两个接口,且两个接口都有同名同参的 default 方法时,编译报错,必须在该类里显式重写解决(或 InterfaceA.super.method() 指定用哪一个)。这是「多继承」带来的钻石问题在接口的投影。
五、多态的价值:开闭原则
List<Animal> zoo = List.of(new Dog(), new Cat());
for (Animal a : zoo) {
a.sound(); // 同一行代码,各自表现不同行为
}新增一种动物(如 Bird),只需加一个子类实现 sound(),调用方 zoo 和循环完全不用改。这就是「对扩展开放、对修改关闭」的开闭原则——多态让系统能在不改动旧代码的前提下长出新行为。
六、常见误区与注意点
WARNING
- 重写与重载混淆:重写是父子类、签名相同(运行期多态);重载是同类、参数不同(编译期决议)。
@Override漏写:手写拼错方法名时编译器不报错,加了注解能立刻发现签名不匹配。- 误用继承:不是 is-a 关系就别硬继承,优先组合。
- 父类构造调用可重写方法:子类半初始化时被调用,字段可能是零值,危险。
- 接口默认方法冲突:实现多接口同名默认方法必须重写解决。
- 以为字段能多态:多态只作用于方法,字段没有重写(子类同名
public字段会「隐藏」父类字段而非覆盖,访问看编译期类型)。
七、最佳实践
- 用
@Override标注所有重写方法,防止签名写错。 - 优先面向「接口/抽象类」编程,而非具体类,便于替换实现。
- 复用代码用继承(is-a),描述「能力」用接口(如
Comparable、Runnable、Flyable)。 - 组合优于继承:不是 is-a 就别继承。
- 新增行为优先「加子类/加实现」而非改调用方,践行开闭原则。
📚 官方文档与参考资料
- Java SE 21 文档:https://docs.oracle.com/en/java/javase/21/
- Java 教程 · 接口与继承:https://docs.oracle.com/javase/tutorial/java/IandI/index.html
- OpenJDK 项目:https://openjdk.org/
✏️ 练习与自测
- 定义
Animal抽象类与Cat、Dog子类,分别重写sound(),用父类数组遍历调用,验证动态分派。 - 定义
Swimmable接口,让Fish与Duck都实现它,体会接口表达「能力」的价值。 - 说明为何通常声明为
List<String> list = new ArrayList<>();而非ArrayList<String> list = new ArrayList<>();(提示:编译期类型与运行期实现解耦、便于替换)。 - 画一张简图:类
Animal和Dog各自 vtable 中sound槽指向哪里?解释Animal a = new Dog(); a.sound();如何通过 vtable 调到Dog.sound。
本节小结
封装隐藏细节、继承复用代码(is-a,单继承)、多态让同一调用产生不同行为(运行期动态分派)。多态的底层是 vtable:每个类加载时建表记录虚方法实现,子类重写即替换对应槽;invokevirtual 顺对象类型指针查 vtable 取到真实实现,O(1) 完成分派。static/构造/private 是静态绑定不走 vtable。抽象类给「模板」(可有字段/具体方法),接口给「契约」(can-do、可多实现);面向接口编程最大化解耦。重写(父子、签名同、运行期)≠ 重载(同类、参数异、编译期)。