目录
一、学习多态前的准备
1.1 向上转型
class Animal{
protected String name;
public Animal(String name){
this.name = name;
}
public void eat(String food){
System.out.println(this.name + "正在吃" + food);
}
}
class Bird extends Animal{
public Bird(String name){
super(name);
}
public void fly(){
System.out.println(this.name + "起飞🛫");
}
}
public class Test {
public static void main(String[] args) {
Bird bird = new Bird("圆圆");
}
}
这段代码中,有如下的语句.
Bird bird = new Bird("圆圆");
这个代码也可以写成这样子
Bird bird = new Bird("方方");
Animal bird2 = bird;
//也可以写成下面的方式.
Animal bird2 = new Bird("圆圆");
此时 bird2 是一个父类(Animal)的引用,指向一个子类(Bird)的实例.
这种写法称为向上转型.
为什么叫向上转型?
在面向对象程序设计中,针对一些复杂的场景(很多类,很复杂的继承关系),程序员会画一种 UML 图的方式来表示类之间的关系.
此时父类通常画在子类的上方. 所以我们就称为 “向上转型”,表示往父类的方向转.
向上转型发生的时机
- 直接赋值:上面父类引用指向子类的实例,已经演示过了;
- 方法传参
- 方法返回
方法传参
此时形参 animal 的类型是 Animal(基类),实际上对应到 Bird(父类)的实例.
从而实现了向上转型.
public class Test {
public static void main(String[] args) {
Bird bird = new Bird("鹦鹉");
feed(bird);
}
public static void feed(Animal animal){
animal.eat("玉米🌽");
}
}
class Animal{
protected String name;
public Animal(String name){
this.name = name;
}
public void eat(String food){
System.out.println(this.name + "正在吃" + food);
}
}
class Bird extends Animal{
public Bird(String name){
super(name);
}
public void fly(){
System.out.println(this.name + "起飞🛫");
}
}
//执行结果
鹦鹉正在吃玉米🌽
方法返回
此时方法findMyAnimal
返回的是一个 Animal 类型的引用,但实际上对应到 Bird 的实例.
public class Test {
public static void main(String[] args) {
Animal animal = findMyAnimal();
animal.eat("大米");
}
public static Animal findMyAnimal(){
Bird bird = new Bird("麻雀");
return bird;
}
}
//输出结果
麻雀正在吃大米
1.2 动态绑定
当子类和父类中出现同名方法的时候,再去调用会出现什么情况?
对前面的代码稍加修改,给 Bird 类也加上同名的 eat 方法,并且在两个 eat 中分别加上不同的日志.
class Animal{
protected String name;
public Animal(String name){
this.name = name;
}
public void eat(String food){
System.out.println("Animal");
System.out.println(this.name + "正在吃" + food);
}
}
class Bird extends Animal{
public Bird(String name){
super(name);
}
public void eat(String food){
System.out.println("Bird");
System.out.println(this.name + "正在吃" + food);
}
}
public class Test {
public static void main(String[] args) {
Animal animal1 = new Animal("圆圆");
animal1.eat("玉米");
Animal animal2 = new Bird("片片");
animal2.eat("稻谷");
}
}
//运行结果
Animal
圆圆正在吃玉米
Bird
片片正在吃稻谷
此时,我们发现:
- animal1 和 animal2 虽然都是
Animal
类型的引用,但是 animal1 指向Animal
类型的实例,animal2 指向Bird
类型的实例. - 针对 animal1 和 animal2 分别调用 eat 方法,发现
animal1.eat()
实际调用了父类的方法,而animal2.eat()
实际调用了子类的方法.
因此,在 Java 中,调用某个类的方法,究竟执行了哪段代码(是父类方法的代码还是子类方法的代码),要看究竟这个引用指向的是父类对象还是子类对象.
这个过程是程序运行时决定的(而不是编译期),因此称为动态绑定.
1.3 方法重写
针对刚才的 eat 方法来说
class Animal{
protected String name;
public Animal(String name){
this.name = name;
}
public void eat(String food){
System.out.println("Animal");
System.out.println(this.name + "正在吃" + food);
}
}
class Bird extends Animal{
public Bird(String name){
super(name);
}
public void eat(String food){
System.out.println("Bird");
System.out.println(this.name + "正在吃" + food);
}
}
子类实现父类的同名方法,并且参数的类型和个数完全相同,这种情况称为覆写/重写/覆盖(Override).
关于重写的注意事项
- 重写和重载完全不一样,不要混淆.
- 普通方法可以重写,static 修饰的静态方法不能重写.
- 重写中子类的方法的访问权限不能低于父类的方法访问权限.
- 重写的方法返回值类型不一定和父类的方法相同(但是建议最好写成相同,特殊情况除外).
另外,针对重写的方法,可以使用@Override
注解来显式指定.
// Bird.java
public class Bird extends Animal {
@Override
private void eat(String food) {
...
}
}
有了这个注解,能帮助我们进行一些合法性校验.
例如不小心把方法名字拼写错了(例如写成 aet ),那么此时编译器就会发现父类中没有 aet 方法,就会编译报错,提示无法构成重写。
所以推荐在代码中进行重写的方法,显式加上@Override
注解。
小结
重载和重写的区别
重载:
方法名称相同,参数类型以及个数不同;
只能在一个类中重载;
没有权限的限制。
重写:
方法名称、返回值类型、参数类型以及个数完全相同;
只出现在继承关系里;
被重写的方法不能拥有比父类更严格的访问控制权限。
1.4 体会动态绑定和方法重写
上面讲的动态绑定和方法重写是用的相同的代码示例.
事实上,方法重写是 Java 语法层次上的规则, 而动态绑定是方法重写这个语法规则的底层实现.
两者本质上描述的是相同的事情,只是侧重点不同.
二、理解多态
有了向上转型、动态绑定、方法重写之后,我们就可以使用多态(polypeptide)的形式来设计程序了.
我们可以只写一些关注父类的代码,就能够同时兼容各种子类的情况.
代码示例:打印多种形状
class Shape {
public void draw() {
// 啥都不用干
}
}
class Cycle extends Shape {
@Override
public void draw() {
System.out.println("○");
}
}
class Rect extends Shape {
@Override
public void draw() {
System.out.println("□");
}
}
class Flower extends Shape {
@Override
public void draw() {
System.out.println("♣");
}
}
/我是分割线//
// Test.java
public class Test {
public static void main(String[] args) {
Shape shape1 = new Flower();
Shape shape2 = new Cycle();
Shape shape3 = new Rect();
drawMap(shape1);
drawMap(shape2);
drawMap(shape3);
}
// 打印单个图形
public static void drawShape(Shape shape) {
shape.draw();
}
}
在这个代码中,分割线上方的代码是类的实现者编写的,分割线下方的代码时类的调用者编写的.
当类的调用者在编写drawMap
这个方法的时候,参数类型为 Shape(父类),此时在该方法内部并不知道,也不关注当前 shape 引用指向的时哪个类型(哪个子类)的实例.
此时 shape 这个引用调用 draw 方法可能会有多种不同的表现(和 shape 对应的实例相关),这种行为就称为多态.
多态顾名思义,就是 “一个引用,能表现出多种不同形态”.
一个引用,到底是指向父类对象,还是某个子类对象(也可能多个),要根据上下文的代码来确定.
2.1 使用多态的好处
1)类调用者对类的使用成本进一步降低.
-
封装是让类的调用者不需要知道类的实现细节.
-
多态能让类的调用者连这个类的类型是什么都不必知道,只需要知道这个对象具有某个方法即可.
因此,多态可以理解成是封装的更进一步,让类的调用者对类的使用成本进一步降低.
2)能够降低代码的“圈复杂度”,避免使用大量的if-else.
例如我们现在需要打印的不是一个形状,而是多个形状.
如果不基于多态,实现的代码如下:
public static void drawShapes() {
Rect rect = new Rect();
Cycle cycle = new Cycle();
Flower flower = new Flower();
String[] shapes = {"cycle", "rect", "cycle", "rect", "flower"};
for (String shape : shapes) {
if (shape.equals("cycle")) {
cycle.draw();
} else if (shape.equals("rect")) {
rect.draw();
} else if (shape.equals("flower")) {
flower.draw();
}
}
}
如果使用使用多态,则不必写这么多的 if - else 分支语句,代码更简单.
public static void drawShapes() {
// 我们创建了一个 Shape 对象的数组.
Shape[] shapes = {new Cycle(), new Rect(), new Cycle(),
new Rect(), new Flower()};
for (Shape shape : shapes) {
shape.draw();
}
}
扩展知识:
什么叫“圈复杂度”?
圈复杂度是一种描述一段代码复杂程度的方式. 一段代码如果平铺直叙, 那么就比较简单容易理解.
而如果有很多的条件分支或者循环语句, 就认为理解起来更复杂.
因此我们可以简单粗暴的计算一段代码中条件语句和循环语句出现的个数, 这个个数就称为 "圈复杂度".
如果一个方法的圈复杂度太高, 就需要考虑重构.
不同公司对于代码的圈复杂度的规范不一样. 一般不会超过 10 .
3)可扩展能力更强
如果要新增一种新的形状,使用多态的方式代码改动成本比较低.
class Triangle extends Shape{
@Override
public void draw() {
System.out.println("△");
}
}
2.2 向下转型
向上转型是子类对象转成父类对象,向下转型就是父类对象转成子类对象.
相比于向上转型来说,向下转型没那么常见,但是也有一定的用途.
// Animal.java
public class Animal {
protected String name;
public Animal(String name) {
this.name = name;
}
public void eat(String food) {
System.out.println("我是一只小动物");
System.out.println(this.name + "正在吃" + food);
}
}
// Bird.java
public class Bird extends Animal {
public Bird(String name) {
super(name);
}
public void eat(String food) {
System.out.println("我是一只小鸟");
System.out.println(this.name + "正在吃" + food);
}
public void fly() {
System.out.println(this.name + "正在飞");
}
}
接下来是我们熟悉的操作
Animal animal = new Bird("圆圆");
animal.eat("谷子");
// 执行结果
我是一只小鸟
圆圆正在吃谷子
接下来我们尝试让圆圆飞起来
animal.fly();
// 编译出错
找不到 fly 方法
注意事项
编译过程中,animal 的类型是 Animal,此时编译器只知道这个类中有个 eat 方法,没有 fly 方法.
虽然 animal 实际引用的是一个 Bird 对象,但是编译器是以 animal 的类型来查看有哪些方法的.
对于Animal animal = new Bird("圆圆")
这样的代码,
- 编译器通过 Animal 类型查看有哪些方法存在
- 执行时,究竟指向父类的方法还是子类的方法,看的是 Bird 这个类型.
那么想要起飞,就需要向下转型.
// (Bird) 表示强制类型转换
Bird bird = (Bird)animal;
bird.fly();
// 执行结果
圆圆正在飞
但这样的向下转型,有时是不太可靠的.
Animal animal = new Cat("小猫");
Bird bird = (Bird)animal;
bird.fly();
// 执行结果, 抛出异常
Exception in thread "main" java.lang.ClassCastException: Cat cannot be cast to Bird
at Test.main(Test.java:35)
animal 本质上引用的是一个 Cat 对象,是不能转成 Bird 对象的.
运行时就会抛出异常.
所以,为了让向下转型更安全,我们可以先判定一下看看 animal 本质上是不是一个 Bird 实例,再来转换.
Animal animal = new Cat("mm");
if (animal instanceof Bird) {
Bird bird = (Bird) animal;
bird.fly();
}
instanceof
可以判断一个引用是否是某个类的实例. 如果是,则返回 true.
这时候再进行向下转型就比较安全了.
2.3 super 关键字
前面的代码中由于使用了重写机制,调用的是子类的方法.
如果需要在子类内部调用父类方法怎么办?可以使用 super 关键字.
super 表示获取到父类实例的引用.
涉及到两种常见用法.
1)使用了 super 来调用父类的构造器(这个代码前面已经写过了)
public Bird(String name) {
super(name);
}
2)使用 super 来调用父类的普通方法
public class Bird extends Animal {
public Bird(String name) {
super(name);
}
@Override
public void eat(String food) {
// 修改代码, 让子调用父类的接口.
super.eat(food);
System.out.println("我是一只小鸟");
System.out.println(this.name + "正在吃" + food);
}
}
在这个代码中,如果在子类中 eat 方法调用 eat(不加super),那么此时就认为是调用子类自己的 eat(变成递归了).
而加上 super 关键字,才是调用父类的方法.
注意 super 和 this 功能有些相似,但是还是要注意其中的区别.
this:
访问本类中的属性和方法;
先查找本类,如果本类没有就调用父类;
表示的是当前对象。
super:
由子类访问父类中的属性、方法;
不查找本类而直接调用父类;
2.4 在构造方法中调用重写的方法(一个坑)
一段有坑的代码.
我们创建两个类,B 是父类,D 是子类.
D 中重写 func 方法,并且在 B 的构造方法中调用 func.
class B {
public B() {
// do nothing
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 Test {
public static void main(String[] args) {
D d = new D();
}
}
// 执行结果
D.func() 0
1. 构造 D 对象的同时,会调用 B 的构造方法.
2. B 的构造方法中调用了 func 方法,此时会触发动态绑定,会调用到 D 中的 func.
3. 此时 D 对象自身还没有构造, 此时 num 处在未初始化的状态, 值为 0.
结论:
“用尽量简单的方式使对象进入可工作状态” 尽量不要在构造器中调用方法(如果这个方法被子类重写,就会触发动态绑定, 但是此时子类对象还没构造完成),可能会出现一些隐藏的但是又极难发现的问题.
三、总结
多态是面向对象程序设计中比较难理解的部分. 重点是多态带来的编码上的好处.
另一方面,如果抛开 Java,多态其实是一个更广泛的概念,和 “继承” 这样的语法并没有必然的联系.
- C++ 中的 “动态多态” 和 Java 的多态类似. 但是 C++ 还有一种 “静态多态”(模板),就和继承体系没有关系了.
- Python 中的多态体现的是 “鸭子类型”,也和继承体系没有关系.
- Go 语言中没有 “继承” 这样的概念,同样也能表示多态.
无论是哪种编程语言,多态的核心都是让调用者不必关注对象的具体类型.
这是降低用户使用成本的一种重要方式.