1. 先理解多态解决什么问题
猫和狗都是动物,都能“吃”,但吃的方式不同。我们希望调用时统一写成 animal.eat(),运行时却能根据实际对象,分别执行猫或狗的吃饭方法。
同样的方法调用,因为引用指向的对象不同,执行不同的实现,这就是本章讨论的运行时多态。
在本章的继承体系中,体现多态需要三个条件:
- 子类继承父类。
- 子类重写父类的方法。
- 通过父类引用调用这个被重写的方法。
先看一个完整例子,保存为 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() 的判断分为两步:
- 编译时检查能否调用:
Animal中有可访问的eat(),因此通过检查。 - 运行时确定具体实现:
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() 会执行不同实现。
这样做有两个好处:
- 减少按类型判断的分支:不必编写“是圆就画圆,是矩形就画矩形”的一串
if-else。 - 方便扩展:增加
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?按顺序看:
- 创建
D对象时,其实例字段先获得默认值,int类型的num此时为0。 - 执行子类的初始化过程前,先执行父类
B的构造方法。 -
B的构造方法调用func(),仍会发生动态绑定,因此执行的是D.func()。 - 此时
private int num = 1;中的显式赋值还没有执行,所以打印0。 - 父类构造方法返回后,才执行子类字段的显式初始化,
num变为1;随后完成子类构造。 -
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? | 动态绑定调用了子类实现,但子类字段的显式初始化尚未执行 |
课件练习还提到接口。接口可以先理解为约定一组行为,由实现类提供具体实现;接口类型的引用也能指向不同实现类对象,调用时体现多态。具体语法留到接口章节学习。
