Java面向对象核心:多态、向上转型与向下转型
一、前言
学习完继承、方法重写、this/super之后,我们已经能够构建父子类的体系结构了。但你可能会遇到这样的困惑:当方法的参数可能是多种子类类型时,到底该如何处理?
如果为每种子类都写一个重载方法,代码会越来越臃肿,后期维护成本陡增。Java用一种优雅的机制解决了这个问题------它就是面向对象三大特性之一的多态。
本篇博客将带你从问题出发,理解多态的本质、向上转型与向下转型,并通过经典案例彻底掌握这一核心概念。
二、接口回顾
在进入多态之前,先回顾一下接口相关的知识,因为接口与多态关系密切。
2.1 接口与接口的关系
子类与父类之间是单继承,子类与父接口之间是多实现,而接口与接口之间是多继承。
这意味着一个接口可以同时继承多个父接口,语法格式如下:
java
【修饰符】 interface 子接口名 extends 父接口1, 父接口2, ... {
// 接口成员
}
下面是一个完整示例,展示了接口之间的多继承关系:
java
// 父接口A
public interface A {
void a();
void b(String s);
}
// 父接口B
public interface B {
void b();
}
// 子接口C继承A和B
public interface C extends A, B {
void c();
}
// 实现类D实现C,需要重写A、B、C中所有的抽象方法
public class D implements C {
@Override
public void c() { }
@Override
public void a() { }
@Override
public void b() { }
@Override
public void b(String s) { }
}
可以看到,实现类D在实现接口C时,必须重写从A、B继承来的所有抽象方法。这种设计让接口可以灵活组合,构建出强大的类型体系。
2.2 接口的冲突问题(了解)
当一个类既继承父类又实现接口,或实现多个接口时,可能会出现成员冲突。这部分了解即可。
1、成员变量冲突
父类的实例变量与接口的常量重名时,直接使用会产生歧义,需要明确指定来源:
java
public class Father {
int x = 1; // 实例变量
}
public interface DryDad {
int x = 2; // 接口常量,默认 public static final
}
public class Son extends Father implements DryDad {
public void method() {
// System.out.println(x); // 错误,有歧义
System.out.println("父类的x = " + super.x); // 输出1
System.out.println("父接口的x = " + DryDad.x); // 输出2
}
}
2、成员方法冲突
当父类方法与父接口默认方法冲突时,遵循"亲爹优先"原则,即父类优先:
java
public class Fu {
public void method() {
System.out.println("Fu.method");
}
}
public interface DryFu {
public default void method() {
System.out.println("DryFu.method");
}
}
public class Zi extends Fu implements DryFu {
// 不需要重写,自动选择父类Fu的method()
}
当两个父接口的默认方法冲突时,实现类必须做出选择,即必须重写该方法:
java
public interface DryA {
public default void method() {
System.out.println("DryA.method");
}
}
public interface DryB {
public default void method() {
System.out.println("DryB.method");
}
}
public class Sub implements DryA, DryB {
@Override
public void method() {
// 必须重写,否则编译报错
// 也可通过 DryA.super.method() 调用指定接口的默认方法
System.out.println("Sub.method");
}
}
三、为什么需要多态
理解一个概念,最好的方式是从问题出发。我们通过两个场景,看看不用多态会带来什么麻烦。
3.1 参数可能是多种子类类型
假设有Animal父类,以及Dog、Cat、Pig等子类:
java
public class Animal {
public void eat() {
System.out.println("吃东西");
}
}
public class Dog extends Animal {
@Override
public void eat() {
System.out.println("吃骨头");
}
public void watchHouse() {
System.out.println("看家");
}
}
public class Cat extends Animal {
@Override
public void eat() {
System.out.println("吃鱼");
}
public void catchMouse() {
System.out.println("抓老鼠");
}
}
现在需要定义一个look方法,参数可能是Dog、Cat或Pig。不用多态的写法是这样的:
java
public class AnimalTest {
// 为每种动物写一个重载方法
public static void look(Dog dog) { dog.eat(); }
public static void look(Cat cat) { cat.eat(); }
public static void look(Pig pig) { pig.eat(); }
// 问题:有几个子类就要写几个方法
}
这样的代码有明显缺陷:每增加一个子类就要新增一个方法,每删除一个子类又要删掉一个方法,扩展性和可维护性都很差。
3.2 数组中存储多种类型对象
另一个场景是希望用一个数组统一管理Animal的所有子类对象:
java
public class AnimalTest2 {
public static void main(String[] args) {
Dog[] dogs = new Dog[3];
dogs[0] = new Dog();
// dogs[1] = new Cat(); // 错误,类型不匹配
// dogs[2] = new Pig(); // 错误,类型不匹配
}
}
数组一旦声明为Dog\[\]类型,就只能存Dog对象,无法存放Cat或Pig。这种限制让我们无法统一管理同类对象。
四、多态的定义与核心思想
Java中的多态分为编译时多态和运行时多态。
- 编译时多态:指方法的重载,一个方法有多种形式。
- 运行时多态:指父类变量指向子类对象,调用的是子类重写的方法体。
我们通常说的多态,指的是运行时多态。
多态的核心语法:
java
父类类型 变量名 = 子类对象;
例如:
java
Animal animal = new Dog(); // 多态引用
animal.eat(); // 执行Dog重写的eat(),输出"吃骨头"
多态的本质:编译时看左边(变量声明的类型),运行时看右边(new的实际类型)。
这意味着,编译阶段只能调用父类中声明的方法,而运行阶段执行的是子类重写后的方法体。
五、多态的前提条件
要实现多态,必须同时满足三个条件:
- 继承或实现关系:必须有父子类或接口与实现类的关系。
- 方法重写:子类必须重写父类的方法,否则多态没有意义。
- 父类引用指向子类对象:用父类类型的变量接收子类对象。
java
// 条件1:继承关系
class Animal {
public void eat() { System.out.println("吃东西"); }
}
class Dog extends Animal {
// 条件2:方法重写
@Override
public void eat() { System.out.println("吃骨头"); }
}
public class Test {
public static void main(String[] args) {
// 条件3:父类引用指向子类对象
Animal animal = new Dog();
animal.eat(); // 输出"吃骨头"
}
}
小贴士:如果子类没有重写父类方法,多态调用时仍执行父类的方法体,多态就失去了意义。
六、向上转型(Upcasting)
向上转型是指子类类型转换为父类类型,这与基本数据类型的自动类型提升类似。
java
// 基本数据类型:小转大,自动提升
double d = 1;
// 引用数据类型:子转父,自动向上转型
Animal a = new Dog();
向上转型的特点:
- 只要发生多态引用,编译时就自动完成向上转型。
- 向上转型是安全的,因为子类一定具备父类的所有成员。
- 向上转型后,只能调用父类中声明的方法,不能调用子类扩展的方法。
java
Animal animal = new Dog(); // 向上转型
animal.eat(); // 正确,调用Dog重写的eat()
// animal.watchHouse(); // 错误,编译时看左边,Animal没有watchHouse方法
七、多态的好处
多态是面向对象三大核心特性之一,它带来的好处主要体现在以下几个方面:
- 代码复用性增强:通过父类引用调用子类重写的方法,统一接口的不同实现,减少重复代码。
- 程序扩展性强:在不修改已有代码的前提下,新增子类即可扩展功能,符合开闭原则。
- 可维护性和灵活性提高:程序根据实际对象类型动态绑定方法,系统更灵活。
- 接口统一:上层逻辑可以面向接口或抽象类编程,屏蔽实现细节。
- 支持运行时动态绑定:JVM在运行时根据对象实际类型决定调用哪个方法。
回到前面的look方法,使用多态后只需一个方法即可:
java
public class AnimalTestAnswer {
// 形参是父类类型,实参是子类对象,称为多态参数
public static void look(Animal animal) {
animal.eat(); // 运行时执行子类重写的eat()
}
public static void main(String[] args) {
look(new Dog()); // 输出"吃骨头"
look(new Cat()); // 输出"吃鱼"
look(new Pig()); // 输出"啥都吃"
}
}
数组管理多种子类对象的问题也迎刃而解:
java
public class AnimalTest2Answer {
public static void main(String[] args) {
// 用父类数组统一管理所有子类对象
Animal[] animals = new Animal[3];
animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = new Pig();
for (Animal a : animals) {
a.eat(); // 执行各个子类重写的eat()
}
}
}
面试题:面向对象三大核心特征是什么?多态的好处和意义是什么?这就是标准答案。
八、核心规则:编译看左边,运行看右边
这是多态最核心的规则,务必牢记:
编译时看左边 :看变量声明的类型(编译时类型),只能调用该类型中声明的方法。
运行时看右边:看new的实际类型(运行时类型),执行该类型重写的方法体。
一个多态引用的变量有两个类型:
- 编译时类型:声明变量时使用的类型,决定了能调用哪些方法。
- 运行时类型:实际new出来的类型,决定了方法执行哪段代码。
java
Animal animal = new Dog();
// animal的编译时类型是Animal,运行时类型是Dog
animal.eat(); // 编译时找Animal的eat(),运行时执行Dog的eat()
九、多态中成员访问特点
不同成员在多态下的访问规则不同,这是面试和开发中的高频考点。
9.1 成员变量:编译看左边,运行看左边
成员变量没有多态,访问规则是"编译看左边,运行看左边"。
java
class Father {
int a = 1;
int b = 2;
}
class Son extends Father {
int a = 10; // 与父类同名
}
public class TestSon {
public static void main(String[] args) {
Father f = new Son(); // 多态引用
System.out.println(f.a); // 输出1,看左边Father
System.out.println(f.b); // 输出2,看左边Father
Son s = new Son(); // 非多态引用
System.out.println(s.a); // 输出10,看左边Son
System.out.println(((Father)s).a); // 输出1,强转后看Father
}
}
注意:成员变量没有多态!访问成员变量只看编译时类型(左边)。
9.2 成员方法:编译看左边,运行看右边
成员方法是多态的核心体现,访问规则是"编译看左边,运行看右边"。
java
class Animal {
public void eat() {
System.out.println("吃东西");
}
}
class Dog extends Animal {
@Override
public void eat() {
System.out.println("吃骨头");
}
}
public class Test {
public static void main(String[] args) {
Animal a = new Dog();
// 编译时:找Animal的eat(),能找到,编译通过
// 运行时:执行Dog重写的eat(),输出"吃骨头"
a.eat();
}
}
这种运行时根据对象实际类型决定执行哪个方法体的机制,称为动态绑定。
9.3 静态方法:编译看左边,运行看左边
静态方法属于类,不属于对象,因此没有多态。访问规则是"编译看左边,运行看左边"。
java
class Animal {
public static void staticMethod() {
System.out.println("Animal的静态方法");
}
}
class Dog extends Animal {
public static void staticMethod() {
System.out.println("Dog的静态方法");
}
}
public class Test {
public static void main(String[] args) {
Animal a = new Dog();
a.staticMethod(); // 输出"Animal的静态方法",看左边
}
}
小贴士:静态方法不能被重写,只能被隐藏。调用静态方法时,看引用变量的编译时类型。
十、向下转型与instanceof
10.1 向下转型(Downcasting)
向上转型后,我们无法调用子类扩展的方法。如果确实需要调用子类特有方法,就必须向下转型。
向下转型是父类类型转换为子类类型,必须强制进行,类似于基本数据类型的强转:
java
// 基本数据类型:大转小,强制转换
double a = 1.0;
int b = (int) a;
// 引用数据类型:父转子,强制转换
Animal animal = new Dog();
Dog d = (Dog) animal; // 向下转型
d.watchHouse(); // 调用Dog特有的方法
10.2 向下转型的风险:ClassCastException
向下转型有风险,如果转换的实际类型不匹配,运行时会抛出ClassCastException:
java
Animal a = new Dog(); // 向上转型,a实际是Dog对象
a.eat();
Dog d = (Dog) a; // 正确,a确实是Dog
d.watchHouse();
Cat c = (Cat) a; // 编译通过,但运行时报错!
// ClassCastException:Dog无法转换为Cat
c.catchMouse();
为什么编译能通过?因为编译器只看a的编译时类型是Animal,而Animal是Cat的父类,允许向下转型。但运行时JVM发现a实际是Dog,Dog和Cat没有父子关系,无法转换。
注意:向下转型前,务必用instanceof判断,避免ClassCastException!
10.3 instanceof关键字
instanceof用于判断对象是否属于某个类型,返回boolean值:
java
对象 instanceof 类型 // 返回true或false
instance代表实例,of代表"xx的",整体意思是"对象是不是某类型的实例"。
java
public class ClassCastTest2 {
public static void main(String[] args) {
Animal a = new Dog(); // 向上转型
a.eat();
// 传统写法
if (a instanceof Dog) {
Dog d = (Dog) a;
d.watchHouse();
}
if (a instanceof Cat) {
Cat c = (Cat) a;
c.catchMouse();
}
// JDK16+模式匹配写法,更简洁
if (a instanceof Dog d) {
d.watchHouse(); // 直接使用d
}
if (a instanceof Cat c) {
c.catchMouse(); // 直接使用c
}
}
}
小贴士:JDK 16开始支持instanceof模式匹配,判断通过后可直接使用变量,无需再强转。
十一、经典案例练习
11.1 图形案例
定义图形父类Graphic,以及Circle、Rectangle、Triangle三个子类,用多态统一管理并按面积排序。
父类Graphic:
java
public abstract class Graphic {
// 抽象方法:求面积
public abstract double area();
// 抽象方法:求周长
public abstract double perimeter();
// 重写toString,这里调用的area()和perimeter()会执行子类重写的
@Override
public String toString() {
return "面积:" + area() + ",周长:" + perimeter();
}
}
子类Circle:
java
public class Circle extends Graphic {
private double radius;
public Circle() { }
public Circle(double radius) {
this.radius = radius;
}
public double getRadius() { return radius; }
public void setRadius(double radius) { this.radius = radius; }
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public double perimeter() {
return 2 * Math.PI * radius;
}
@Override
public String toString() {
return "半径:" + radius + "," + super.toString();
}
}
子类Rectangle:
java
public class Rectangle extends Graphic {
private double length;
private double width;
public Rectangle() { }
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
public double getLength() { return length; }
public void setLength(double length) { this.length = length; }
public double getWidth() { return width; }
public void setWidth(double width) { this.width = width; }
@Override
public double area() {
return length * width;
}
@Override
public double perimeter() {
return 2 * (length + width);
}
@Override
public String toString() {
return "长:" + length + ",宽:" + width + "," + super.toString();
}
}
测试类,使用多态管理并排序:
java
public class GraphicTest {
public static void main(String[] args) {
// 父类数组存放子类对象
Graphic[] arr = new Graphic[3];
arr[0] = new Circle(2.5);
arr[1] = new Rectangle(2, 1);
arr[2] = new Triangle(1, 1, 1);
// 遍历输出
for (Graphic g : arr) {
System.out.println(g); // 运行时调用子类toString()
}
// 按面积从小到大排序(直接选择排序)
for (int lun = 0; lun < arr.length - 1; lun++) {
int index = lun;
for (int j = index + 1; j < arr.length; j++) {
// 编译看Graphic,运行执行子类area()
if (arr[j].area() < arr[index].area()) {
Graphic temp = arr[j];
arr[j] = arr[index];
arr[index] = temp;
}
}
}
System.out.println("按面积排序后:");
for (Graphic g : arr) {
System.out.println(g);
}
}
}
小贴士:Graphic的toString()中调用area(),虽然area()是抽象方法,但实际执行时调用的是子类重写的area()------这就是多态的威力。
11.2 员工案例
定义员工体系:Employee为抽象父类,SalaryEmployee为正式工,HourlyEmployee为小时工,Manager为经理(继承SalaryEmployee)。
父类Employee:
java
public abstract class Employee {
private String name;
public Employee() { }
public Employee(String name) { this.name = name; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
// 抽象方法:实发工资
public abstract double earning();
// 抽象方法:基本信息
public abstract String getBasicInfo();
// 用final修饰,防止子类修改模板逻辑
@Override
public final String toString() {
return "基本信息:" + getBasicInfo() + ",实发工资:" + earning();
}
}
子类SalaryEmployee:
java
public class SalaryEmployee extends Employee {
private double salary;
public SalaryEmployee() { }
public SalaryEmployee(String name, double salary) {
super(name);
this.salary = salary;
}
public double getSalary() { return salary; }
public void setSalary(double salary) { this.salary = salary; }
@Override
public double earning() {
return salary;
}
@Override
public String getBasicInfo() {
return "姓名:" + getName() + ",月薪:" + salary;
}
}
子类HourlyEmployee:
java
public class HourlyEmployee extends Employee {
private double hours;
private double wage;
public HourlyEmployee() { }
public HourlyEmployee(String name, double hours, double wage) {
super(name);
this.hours = hours;
this.wage = wage;
}
public double getHours() { return hours; }
public void setHours(double hours) { this.hours = hours; }
public double getWage() { return wage; }
public void setWage(double wage) { this.wage = wage; }
@Override
public double earning() {
return wage * hours;
}
@Override
public String getBasicInfo() {
return "姓名:" + getName() + ",时薪:" + wage + ",工作小时数:" + hours;
}
}
子类Manager(继承SalaryEmployee):
java
public class Manager extends SalaryEmployee {
private double bonusRatio;
public Manager() { }
public Manager(String name, double salary, double bonusRatio) {
super(name, salary);
this.bonusRatio = bonusRatio;
}
public double getBonusRatio() { return bonusRatio; }
public void setBonusRatio(double bonusRatio) { this.bonusRatio = bonusRatio; }
@Override
public double earning() {
return (1 + bonusRatio) * getSalary();
}
@Override
public String getBasicInfo() {
return "职位:经理," + super.getBasicInfo() + ",奖金比例:" + bonusRatio;
}
}
测试类,结合instanceof实现不同员工的差异化处理:
java
public class EmployeeTest {
public static void main(String[] args) {
// 父类数组存储各类员工
Employee[] all = new Employee[3];
all[0] = new SalaryEmployee("张三", 15000);
all[1] = new HourlyEmployee("李四", 100, 50);
all[2] = new Manager("王五", 16000, 0.1);
double sum = 0.0;
for (Employee employee : all) {
System.out.println(employee); // 多态调用toString()
sum += employee.earning(); // 多态调用earning()
// 用instanceof判断具体类型,做差异化处理
if (employee instanceof HourlyEmployee) {
System.out.println("5号领取现金");
} else if (employee instanceof SalaryEmployee) {
// Manager也是SalaryEmployee的子类,会匹配
System.out.println("下个月15号团建");
}
}
System.out.println("总工资:" + sum);
}
}
小贴士:instanceof判断时,子类对象instanceof父类也返回true。Manager instanceof SalaryEmployee结果为true。
十二、模板设计模式
设计模式是编程工作中解决某些问题的经验总结,常见有23种。模板方法模式是其中一种行为型模式。
模板方法模式定义了一个算法的骨架,将一些步骤的具体实现延迟到子类中完成。父类封装公共逻辑,子类在不修改算法结构的前提下重定义某些步骤。
主要特点:
- 抽象类定义算法骨架:在抽象类中定义抽象方法和一个模板方法。
- 模板方法:通常用final修饰,防止子类修改算法流程。
- 钩子方法:父类中可选实现的方法,子类可选择是否覆盖。
前面的Employee类就是一个典型的模板方法模式示例:
java
public abstract class Employee {
private String name;
public abstract double earning(); // 钩子方法,由子类实现
public abstract String getBasicInfo(); // 钩子方法,由子类实现
// 模板方法,用final修饰防止子类修改
@Override
public final String toString() {
return "基本信息:" + getBasicInfo() + ",实发工资:" + earning();
}
}
toString()就是模板方法,它定义了"基本信息+实发工资"这个算法骨架,而getBasicInfo()和earning()的具体实现交给子类完成。这就是多态在模板模式中的应用。
十三、多态的误解:成员变量没有多态
这是一个常见的误区,必须澄清。
成员变量没有多态,访问成员变量只看编译时类型(左边),不存在"运行看右边"的情况。
java
class Father {
int a = 1;
int b = 2;
}
class Son extends Father {
int a = 10; // 与父类同名,这是隐藏,不是重写
}
public class TestSon {
public static void main(String[] args) {
Father f = new Son(); // 多态引用
System.out.println(f.a); // 输出1,看左边Father
System.out.println(f.b); // 输出2
System.out.println(((Son) f).a); // 输出10,强转后看Son
Son s = new Son(); // 非多态引用
System.out.println(s.a); // 输出10,看左边Son
System.out.println(((Father) s).a); // 输出1,强转后看Father
}
}
注意:创建子类对象时,会同时包含子类和父类声明的所有属性值。访问哪个属性,取决于引用变量的编译时类型。
十四、虚方法深入理解
虚方法是指可以被子类重写的方法。虚方法的调用分析分为两步:
第一步:编译时静态分派(看左边)
先看对象的编译时类型,在该类型中找到能匹配的方法。匹配原则:
- 找最匹配:实参的编译时类型 = 方法形参的类型
- 找兼容:实参的编译时类型 < 方法形参的类型(即实参是形参的子类)
第二步:运行时动态绑定(看右边)
再看对象的运行时类型,如果运行时类型重写了刚匹配到的方法,就执行重写的;否则执行编译时类型中的方法。
来看一个综合示例:
java
class MyClass {
public void method(Father f) {
System.out.println("father");
}
public void method(Son s) {
System.out.println("son");
}
}
class MySub extends MyClass {
@Override
public void method(Father d) {
System.out.println("sub--father");
}
public void method(Daughter d) {
System.out.println("daughter");
}
}
class Father { }
class Son extends Father { }
class Daughter extends Father { }
测试分析:
java
public class TestVirtualMethod {
public static void main(String[] args) {
Father f = new Father();
Son s = new Son();
Daughter d = new Daughter();
Father f2 = new Son(); // 多态引用
MyClass my = new MySub(); // 多态引用
my.method(f); // 输出"sub--father"
}
}
对my.method(f)的分析:
- 编译时:my的左边是MyClass,在MyClass中找匹配方法。实参f是Father类型,匹配
method(Father f)。 - 运行时:my的右边是MySub,MySub重写了
method(Father),执行重写的sub--father。
再看my.method(s):
- 编译时:实参s是Son类型,匹配
method(Son s)。 - 运行时:MySub没有重写
method(Son),执行父类的son。
再看my.method(d):
- 编译时:实参d是Daughter类型,MyClass没有
method(Daughter),兼容匹配method(Father)。 - 运行时:MySub重写了
method(Father),执行sub--father。
小贴士:方法匹配时,实参看编译时类型而非运行时类型。这是初学者最容易混淆的点。
十五、小结
本篇博客从问题出发,系统讲解了Java多态的核心知识。总结如下:
-
接口与接口是多继承,类与接口是多实现,冲突时父类方法优先于接口默认方法。
-
多态的三要素:继承/实现、方法重写、父类引用指向子类对象,缺一不可。
-
向上转型是子转父,自动完成,安全但会丢失子类特有方法的访问能力。
-
核心规则:编译时看左边(调用看声明类型),运行时看右边(执行看实际类型)。
-
成员访问特点:成员变量看左边,成员方法编译看左边运行看右边,静态方法看左边。
-
向下转型是父转子,必须强转,有ClassCastException风险,需先用instanceof判断。
-
成员变量没有多态,这是常见误区,访问成员变量只看编译时类型。
-
虚方法是可以被重写的方法,调用时分静态分派和动态绑定两步。
-
模板设计模式利用多态,在父类定义算法骨架,子类实现具体步骤。
多态是Java面向对象的精髓,理解并熟练运用它,能让你的代码更灵活、更易扩展、更易维护。建议结合图形案例和员工案例多动手实践,真正体会多态的威力。