1. 先理解多态解决什么问题

猫和狗都是动物,都能“吃”,但吃的方式不同。我们希望调用时统一写成 animal.eat(),运行时却能根据实际对象,分别执行猫或狗的吃饭方法。

同样的方法调用,因为引用指向的对象不同,执行不同的实现,这就是本章讨论的运行时多态。

在本章的继承体系中,体现多态需要三个条件:

  1. 子类继承父类。
  2. 子类重写父类的方法。
  3. 通过父类引用调用这个被重写的方法。

先看一个完整例子,保存为 TestAnimal.java 即可运行。后面的动物示例继续使用这里的三个类。

class Animal { String name; public Animal(String name) { this.name = name; } public void eat() { System.out.println(name + "吃饭"); } } class Cat extends Animal { public Cat(String name) { super(name); } @Override public void eat() { System.out.println(name + "吃鱼"); } public void mew() { System.out.println(name + "喵喵叫"); } } class Dog extends Animal { public Dog(String name) { super(name); } @Override public void eat() { System.out.println(name + "吃骨头"); } public void bark() { System.out.println(name + "汪汪叫"); } } public class TestAnimal { public static void feed(Animal animal) { animal.eat(); } public static void main(String[] args) { feed(new Cat("元宝")); feed(new Dog("小七")); } } 

输出:

元宝吃鱼 小七吃骨头 

执行过程:

调用 形参 animal 的声明类型 实际指向的对象 执行的方法
feed(new Cat("元宝")) Animal Cat 对象 Cat.eat()
feed(new Dog("小七")) Animal Dog 对象 Dog.eat()

feed 只负责调用动物的 eat(),不必判断传进来的是猫还是狗。共同行为由父类规定,具体行为由子类实现。

2. 方法重写:让子类提供自己的实现

2.1 什么是重写

重写(override)是子类重新实现父类中允许被重写的实例方法。 上例中,Cat.eat() 和 Dog.eat() 都重写了 Animal.eat()。

重写要遵守以下规则:

检查项 规则
方法名 与父类方法相同
参数列表 参数类型、个数、顺序相同;参数名可以不同
返回类型 通常相同;父类方法返回类类型时,子类方法可以返回该类型的子类
访问权限 不能缩小,例如父类为 public,子类也必须为 public
不能重写的方法 static 方法、private 方法、final 方法、构造方法

返回类型的例外叫作协变返回类型。例如,父类方法为 Animal getAnimal(),子类可以重写为 Cat getAnimal()。方向不能反过来;基本类型也不能这样改变,例如 int 不能改成 long。

建议重写时加上 @Override:它让编译器检查这个方法是否真的构成重写。如果把 eat 错写成 aet,就能及时发现。重写由方法本身的关系决定,并不是写上注解才发生。

练习中还涉及“检查异常”:可暂时理解为编译器要求声明或处理的一类异常。重写方法不能新增父类方法未声明的检查异常,也不能把它扩大为更宽泛的类型;可以缩小或不声明。本章先记住这个限制即可。

2.2 重写和重载不要混淆

对比项 重写(override) 重载(overload)
核心含义 子类改变继承来的方法实现 同名方法接收不同的参数
关系 父类与子类之间 常见于同一个类,也可涉及继承来的方法
方法名 相同 相同
参数列表 相同 必须不同
返回类型 相同,或符合协变规则 可以不同,但不能仅凭返回类型构成重载
访问权限 不能缩小 不受重写的权限规则限制
决定调用的时机 运行时确定重写方法的实现 编译时选择重载版本

例如,eat() 和 eat(String food) 是重载;子类重新实现父类的 eat() 才是重写。

重写也有利于扩展已有功能:在适合继承的场景中,可以新建子类、复用共有内容并重写需要变化的方法,减少对已有类和原有使用者的影响。

3. 理解多态的关键:声明类型与实际对象

Animal animal = new Dog("小七"); animal.eat(); // 输出:小七吃骨头 // animal.bark(); // 编译错误:Animal 中没有 bark() 

这里有两个类型,不能混为一谈:

  • 引用的声明类型是 Animal:决定编译器允许通过它调用哪些方法。
  • 对象的实际类型是 Dog:决定运行时执行哪个重写后的实例方法。

所以,animal.eat() 的判断分为两步:

  1. 编译时检查能否调用:Animal 中有可访问的 eat(),因此通过检查。
  2. 运行时确定具体实现:animal 指向 Dog 对象,因此执行 Dog.eat()。

而 animal.bark() 在第一步就失败了。虽然对象是狗,但编译器根据 Animal 类型检查方法,找不到 bark()。

对于这里的实例方法调用,可以记成:编译看引用类型,运行看实际对象。

静态绑定与动态绑定

“绑定”可以简单理解为确定方法调用对应哪个方法。

  • 静态绑定(早绑定):编译时确定。重载版本的选择是典型例子,依据实参表达式的编译时类型等信息完成。
  • 动态绑定(晚绑定):运行时根据实际对象确定重写方法的实现。

例如,假设有两个重载的静态方法 show(Animal a) 和 show(Dog d):

Animal animal = new Dog("小七"); show(animal); // 选择 show(Animal a),因为 animal 的声明类型是 Animal 

如果 show(Animal a) 内部再调用 a.eat(),这次实例方法调用才会根据实际对象执行 Dog.eat()。

有些资料把重载称为“编译时多态”,把重写引起的动态绑定称为“运行时多态”。读题时要注意范围,不能因此认为重载也会在运行时重新选择参数版本。

4. 向上转型:统一当作父类使用

把子类对象的引用交给父类类型的变量,就是向上转型。 它可以自动完成,不需要强制类型转换。

Animal animal = new Cat("元宝"); 

这并没有把猫对象变成一个新的 Animal 对象。对象仍然是猫,只是通过父类类型的引用来使用它。

向上转型常见于以下三种场景。

4.1 直接赋值

Cat cat = new Cat("元宝"); Animal animal = cat; 

cat 和 animal 指向同一个对象。

4.2 方法传参

// feed 的形参类型是 Animal feed(new Cat("元宝")); feed(new Dog("小七")); 

一个父类类型的形参,就可以接收不同子类的对象。

4.3 方法返回

下面的方法可放在 TestAnimal 类中:

public static Animal buyAnimal(boolean wantCat) { if (wantCat) { return new Cat("元宝"); } return new Dog("小七"); } 

调用时:

Animal animal = buyAnimal(false); animal.eat(); // 输出:小七吃骨头 

方法声明返回 Animal,实际可以返回 Cat 或 Dog 对象。

向上转型让代码可以统一处理不同子类,代价是无法直接通过父类引用调用子类特有的方法。需要使用这些方法时,才考虑向下转型。

5. 向下转型:使用子类特有的方法

5.1 强制转换不会改变对象的实际类型

向下转型是把父类类型的引用转换为子类类型,需要显式写出目标类型:

Animal animal = new Dog("小七"); Dog dog = (Dog) animal; dog.bark(); // 输出:小七汪汪叫 

animal 和 dog 仍指向同一个狗对象;animal 本身的声明类型也没有改变。

能否转换成功,取决于对象实际是不是目标类型或它的子类。 写上强制转换,不代表一定能成功:

Animal animal = new Dog("小七"); Cat cat = (Cat) animal; // 能通过编译,但运行时抛出 ClassCastException cat.mew(); // 上一行异常未处理,本行不会执行 

ClassCastException 表示类型转换异常:实际是狗,不能当成猫使用。此处先理解异常会中断正常执行即可,不必提前学习异常处理语法。

5.2 用 instanceof 判断后再转换

引用 instanceof 类型 用来判断:引用指向的对象是否属于该类型或它的子类。

Animal animal = new Dog("小七"); if (animal instanceof Cat) { Cat cat = (Cat) animal; cat.mew(); } else if (animal instanceof Dog) { Dog dog = (Dog) animal; dog.bark(); } 

输出:

小七汪汪叫 

判断为 true 后,再把同一个引用转换为对应类型,就能避免这里的类型转换异常。引用为 null 时,instanceof 的结果为 false。

如果只是调用已有的共同方法 eat(),直接使用多态即可,不必先判断类型、再向下转型。

6. 多态为什么能减少分支、方便扩展

以绘制不同图形为例。下面是独立的完整示例,保存为 TestShape.java:

class Shape { public void draw() { System.out.println("画图形"); } } class Rect extends Shape { @Override public void draw() { System.out.println("画矩形"); } } class Circle extends Shape { @Override public void draw() { System.out.println("画圆形"); } } class Flower extends Shape { @Override public void draw() { System.out.println("画花朵"); } } public class TestShape { public static void main(String[] args) { Shape[] shapes = { new Circle(), new Rect(), new Flower() }; for (Shape shape : shapes) { shape.draw(); } } } 

输出:

画圆形 画矩形 画花朵 

数组中存放的是不同子类对象的引用。遍历时,shape 的声明类型始终为 Shape,实际指向的对象不断变化,因此同一句 shape.draw() 会执行不同实现。

这样做有两个好处:

  1. 减少按类型判断的分支:不必编写“是圆就画圆,是矩形就画矩形”的一串 if-else。
  2. 方便扩展:增加 Triangle extends Shape 并重写 draw(),再把三角形对象放入数组,原来的遍历和调用逻辑无需修改。

课件提到的“圈复杂度”衡量代码中独立执行路径的数量。分支越多,通常越难理解和测试;它并不等于简单地数出所有条件语句和循环语句。这里理解多态能减少调用处的类型判断即可。

多态的使用限制仍然是:父类引用不能直接调用子类特有方法;向下转型虽然能解决这个问题,但必须注意实际类型。

7. 构造方法中的陷阱:对象还没初始化完,就调用了子类方法

尽量避免在构造方法中调用可能被子类重写的实例方法。

下面的独立示例保存为 TestInit.java:

class B { public B() { func(); } public void func() { System.out.println("B.func()"); } } class D extends B { private int num = 1; @Override public void func() { System.out.println("D.func() " + num); } } public class TestInit { public static void main(String[] args) { D d = new D(); d.func(); } } 

输出:

D.func() 0 D.func() 1 

第一行为什么是 0?按顺序看:

  1. 创建 D 对象时,其实例字段先获得默认值,int 类型的 num 此时为 0。
  2. 执行子类的初始化过程前,先执行父类 B 的构造方法。
  3. B 的构造方法调用 func(),仍会发生动态绑定,因此执行的是 D.func()。
  4. 此时 private int num = 1; 中的显式赋值还没有执行,所以打印 0。
  5. 父类构造方法返回后,才执行子类字段的显式初始化,num 变为 1;随后完成子类构造。
  6. new D() 完成后,再调用 d.func(),就打印 1。

问题不在于 num 没有任何值,而在于它只有默认值,尚未完成子类的显式初始化。

构造方法应尽量只做必要的初始化。private、final 方法不能被子类重写,因此不会因重写而直接跳到子类实现;关键是避免在对象尚未构造完成时,执行依赖子类初始化结果的逻辑。

8. 复习自查

问题 答案
Animal a = new Dog("小七") 后,a.eat() 执行谁的方法? Dog.eat(),前提是狗类重写了该方法
为什么不能直接调用 a.bark()? 编译器按 Animal 类型检查,而该类型没有 bark()
向上转型会创建新对象吗? 不会,只是用父类类型的引用使用原对象
(Cat) a 一定成功吗? 不一定;实际对象必须是 Cat 或其子类对象
重写能缩小访问权限吗? 不能;返回的类类型则可以是原返回类型的子类
final 方法能通过子类重写产生运行时多态吗? 不能,因为它不允许被重写
重载和本章的多态是同一回事吗? 重载在编译时选择版本;本章重点是运行时选择重写实现
为什么构造方法里调用重写方法可能读到 0? 动态绑定调用了子类实现,但子类字段的显式初始化尚未执行

课件练习还提到接口。接口可以先理解为约定一组行为,由实现类提供具体实现;接口类型的引用也能指向不同实现类对象,调用时体现多态。具体语法留到接口章节学习。