Skip to content

封装 / 继承 / 多态 ​

Java 初级 · 面向对象

封装、继承、多态是面向对象的三大特性,它们共同解决「代码复用」与「可扩展」的问题。很多教程只给结论,本节重点讲清多态的底层——虚方法表(vtable)与动态分派,让你明白「父类引用为何能调到子类方法」。这一节是面向对象真正的分水岭。

一、封装:不止是 private ​

封装 = 隐藏内部实现细节,只暴露受控接口。上一节已铺垫:字段 private + getter/setter,在 setter 中校验,保证对象永远不会进入非法状态。封装的价值是把「怎么实现」和「怎么用」解耦——内部改了实现,调用方无感知。

二、继承:代码复用的「is-a」关系 ​

class B extends A,子类获得父类非 private 成员,Java 单继承(一个类只能有一个直接父类,但可实现多个接口)。

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 注解:一旦签名写错(拼错方法名、参数不符),编译器立刻报错,而非默默变成「重载」。

三、多态的核心:运行期动态分派 ​

java
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/superinvokespecial编译期否
普通实例方法invokevirtual运行期✅ 走 vtable
接口方法invokeinterface运行期类似(查接口表)

所以:private、构造、static 方法是静态绑定(编译期就锁定目标,无法被多态改写);只有普通实例方法才走 vtable 实现多态。

四、抽象类 vs 接口:模板 vs 契约 ​

抽象类(abstract class) ​

可包含抽象方法(无实现)和具体方法(有实现),适合「抽取子类共性」。它不能被 new,必须由子类继承实现抽象部分。

java
abstract class Shape {
    abstract double area();              // 抽象方法:子类必须实现
    void describe() {                    // 具体方法:子类直接复用
        System.out.println("面积=" + area());
    }
}

接口(interface) ​

Java 8 之后接口能力很强:可有抽象方法、默认方法(default)、静态方法、私有方法。接口表达「能力契约」——「能飞」「能比较」,与继承的 is-a 互补。

java
interface Flyable {
    void fly();                          // 抽象方法
    default void start() {              // 默认方法:提供默认实现,子类可不重写
        System.out.println("起飞准备");
    }
}

选择原则:

  • 表示「是什么(is-a)」、要共享状态/代码 → 抽象类。
  • 表示「能做什么(can-do)」、要支持多实现、面向行为编程 → 接口。
  • 声明变量/参数类型时面向接口/抽象类,而非具体类,便于替换实现(如 List<String> list = new ArrayList<>())。

接口默认方法冲突

一个类同时实现两个接口,且两个接口都有同名同参的 default 方法时,编译报错,必须在该类里显式重写解决(或 InterfaceA.super.method() 指定用哪一个)。这是「多继承」带来的钻石问题在接口的投影。

五、多态的价值:开闭原则 ​

java
List<Animal> zoo = List.of(new Dog(), new Cat());
for (Animal a : zoo) {
    a.sound();     // 同一行代码,各自表现不同行为
}

新增一种动物(如 Bird),只需加一个子类实现 sound(),调用方 zoo 和循环完全不用改。这就是「对扩展开放、对修改关闭」的开闭原则——多态让系统能在不改动旧代码的前提下长出新行为。

六、常见误区与注意点 ​

WARNING

  • 重写与重载混淆:重写是父子类、签名相同(运行期多态);重载是同类、参数不同(编译期决议)。
  • @Override 漏写:手写拼错方法名时编译器不报错,加了注解能立刻发现签名不匹配。
  • 误用继承:不是 is-a 关系就别硬继承,优先组合。
  • 父类构造调用可重写方法:子类半初始化时被调用,字段可能是零值,危险。
  • 接口默认方法冲突:实现多接口同名默认方法必须重写解决。
  • 以为字段能多态:多态只作用于方法,字段没有重写(子类同名 public 字段会「隐藏」父类字段而非覆盖,访问看编译期类型)。

七、最佳实践 ​

  1. 用 @Override 标注所有重写方法,防止签名写错。
  2. 优先面向「接口/抽象类」编程,而非具体类,便于替换实现。
  3. 复用代码用继承(is-a),描述「能力」用接口(如 Comparable、Runnable、Flyable)。
  4. 组合优于继承:不是 is-a 就别继承。
  5. 新增行为优先「加子类/加实现」而非改调用方,践行开闭原则。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 定义 Animal 抽象类与 Cat、Dog 子类,分别重写 sound(),用父类数组遍历调用,验证动态分派。
  2. 定义 Swimmable 接口,让 Fish 与 Duck 都实现它,体会接口表达「能力」的价值。
  3. 说明为何通常声明为 List<String> list = new ArrayList<>(); 而非 ArrayList<String> list = new ArrayList<>();(提示:编译期类型与运行期实现解耦、便于替换)。
  4. 画一张简图:类 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、可多实现);面向接口编程最大化解耦。重写(父子、签名同、运行期)≠ 重载(同类、参数异、编译期)。