Skip to content

多态 ​

S1-008必学入门约 90 分钟JDK 21编程语言基础 · 面向对象

1. 本节定位 ​

面向对象的三大特性是封装、继承、多态。封装管"数据怎么保护",继承管"代码怎么复用",多态管"代码怎么扩展"。

多态是这三个里最抽象、也最有价值的一个。它决定了你能不能写出"新增一种类型时,不需要修改已有代码"的程序。框架之所以能通过注解和接口把你写的类接进它们的流程,靠的也是多态。

2. 学习目标 ​

完成本节后,你应该能够:

  • 区分编译时类型与运行时类型,并说明它决定了什么;
  • 写出"父类引用指向子类对象"的代码,并解释方法调用的选择规则;
  • 用多态消除 if-else 类型分支;
  • 安全地进行向下转型,会用 instanceof 模式匹配;
  • 说出哪些成员没有多态(字段、静态方法、私有方法)。

3. 前置知识检查 ​

  • 会写 extends,知道 super 的含义吗?(《继承与重写》)
  • 知道 @Override 注解的作用吗?
  • 能说清"重写(override)"和"重载(overload)"的区别吗?

4. 为什么需要这个知识 ​

假设做一个支付功能,有三种渠道,每种手续费不同。不假思索的写法:

java
public long fee(String channel, long amount) {
    if ("alipay".equals(channel)) {
        return Math.round(amount * 0.006);
    } else if ("wechat".equals(channel)) {
        return Math.round(amount * 0.006);
    } else if ("bank".equals(channel)) {
        return Math.round(amount * 0.005);
    } else {
        throw new IllegalArgumentException("未知渠道:" + channel);
    }
}

这段代码能跑,但每加一个渠道就要回到这个方法里改一次:

  • 改漏了,就是线上事故;
  • 这个方法会随渠道数量增长,变成几百行的分支怪物;
  • 每个渠道的规则混在一起,测试、维护、交接都很痛苦。

多态把"哪种渠道"的判断,从方法内部移到创建对象时:

java
PaymentChannel channel = new AlipayChannel();   // 只有这里知道具体类型
long fee = channel.feeInCents(amount);          // 这里不再关心是哪种

判断只发生一次,扩展点集中在类的体系里。这是多态的核心价值。

5. 核心概念 ​

5.1 多态的三个前提 ​

同时满足这三个条件,多态才会发生:

  1. 有继承或实现关系(子类 extends 父类,或 implements 接口);
  2. 子类重写了父类的方法(@Override);
  3. 父类引用指向子类对象(PaymentChannel channel = new AlipayChannel();)。

缺任何一个,都退化成普通的方法调用。

5.2 编译时类型与运行时类型 ​

java
PaymentChannel channel = new AlipayChannel();
//    ↑ 编译时类型            ↑ 运行时类型
编译时类型运行时类型
由谁决定变量声明的类型new 出来的真实对象
决定什么能调用哪些方法实际执行哪个方法体
检查时机编译期运行期(动态绑定)

记住两句话:

  • 编译看左边:channel 只能用 PaymentChannel 声明过的方法,哪怕实际是支付宝渠道,也不能调支付宝特有的方法;
  • 运行看右边:真正执行的是 AlipayChannel 重写后的方法体。

5.3 向上转型与向下转型 ​

java
PaymentChannel channel = new AlipayChannel();   // 向上转型:自动、安全
AlipayChannel alipay = (AlipayChannel) channel; // 向下转型:需要强转,可能失败

向上转型永远安全,因为子类一定具备父类的所有能力。向下转型是"收回"能力,编译器无法保证对象真的是那个子类,转错了就在运行时抛 ClassCastException。

Java 16 之后用 instanceof 模式匹配更安全:

java
if (channel instanceof AlipayChannel alipay) {
    System.out.println(alipay.alipaySpecificMethod());   // 变量已经转好,直接可用
}

5.4 哪些成员没有多态 ​

这是最容易踩坑的部分,记牢四条例外:

成员是否多态说明
普通方法(被重写)是运行时看实际类型
字段(成员变量)否编译时看声明类型
static 方法否静态方法属于类,不存在"重写",只有隐藏
private / 构造方法否子类根本访问不到,谈不上重写

6. 递进示例 ​

6.1 最小示例 ​

java
public class Animal {
    public String speak() {
        return "...";
    }

    public static void main(String[] args) {
        Animal[] animals = {new Animal(), new Dog(), new Cat()};
        for (Animal animal : animals) {
            System.out.println(animal.speak());
        }
    }
}

class Dog extends Animal {
    @Override
    public String speak() {
        return "汪";
    }
}

class Cat extends Animal {
    @Override
    public String speak() {
        return "喵";
    }
}

输出是 ...、汪、喵。注意循环里只有一句 animal.speak(),没有任何 if 判断,却调到了三个不同的实现。

6.2 业务示例:支付渠道 ​

java
public class PaymentChannel {
    public String name() {
        return "通用渠道";
    }

    public long feeInCents(long amountInCents) {
        return 0L;
    }

    public long actualReceive(long amountInCents) {
        return amountInCents - feeInCents(amountInCents);
    }
}

class AlipayChannel extends PaymentChannel {
    @Override
    public String name() {
        return "支付宝";
    }

    @Override
    public long feeInCents(long amountInCents) {
        return Math.round(amountInCents * 0.006);
    }
}

关键点在 actualReceive:它写在父类里,调用的是 feeInCents(...)。由于动态绑定,父类的方法体里调用的重写方法,也会走到子类实现。这种"父类定流程、子类填细节"的写法,就是模板方法模式的雏形。

6.3 反例:字段与静态方法没有多态 ​

java
class Parent {
    String label = "parent";
    static String who() { return "Parent"; }
}

class Child extends Parent {
    String label = "child";
    static String who() { return "Child"; }
}

Parent reference = new Child();
System.out.println(reference.label);   // parent —— 字段看编译时类型
System.out.println(reference.who());   // Parent —— 静态方法看编译时类型

如果这里把 label 改成方法 label(),输出就会变成 child。这也是为什么 Java 编码规范建议字段用 private + getter 访问:方法有多态,字段没有。

7. 完整可运行代码 ​

文件:labs/polymorphism/src/main/java/cn/kb/learn/poly/PaymentRunner.javaJava 版本:21(javac --release 21) 依赖:无 运行命令:

powershell
javac --release 21 -d out PaymentChannel.java AlipayChannel.java WechatChannel.java BankCardChannel.java PaymentRunner.java
java -cp out cn.kb.learn.poly.PaymentRunner
java
package cn.kb.learn.poly;

public class PaymentRunner {
    public static void main(String[] args) {
        long amount = 10_000L;   // 100.00 元,单位为分

        PaymentChannel[] channels = {
                new AlipayChannel(),
                new WechatChannel(),
                new BankCardChannel()
        };

        for (PaymentChannel channel : channels) {
            printSettlement(channel, amount);
        }

        // 新增渠道不需要改动上面的循环,也不需要新增 if 分支
        PaymentChannel newChannel = new PaymentChannel();
        printSettlement(newChannel, amount);
    }

    private static void printSettlement(PaymentChannel channel, long amountInCents) {
        System.out.printf(
                "%s:手续费 %d 分,实收 %d 分%n",
                channel.name(),
                channel.feeInCents(amountInCents),
                channel.actualReceive(amountInCents)
        );
    }
}
java
package cn.kb.learn.poly;

public class PaymentChannel {
    public String name() {
        return "通用渠道";
    }

    public long feeInCents(long amountInCents) {
        return 0L;
    }

    public long actualReceive(long amountInCents) {
        return amountInCents - feeInCents(amountInCents);
    }
}
java
package cn.kb.learn.poly;

public class AlipayChannel extends PaymentChannel {
    @Override
    public String name() {
        return "支付宝";
    }

    @Override
    public long feeInCents(long amountInCents) {
        return Math.round(amountInCents * 0.006);
    }
}
java
package cn.kb.learn.poly;

public class WechatChannel extends PaymentChannel {
    @Override
    public String name() {
        return "微信支付";
    }

    @Override
    public long feeInCents(long amountInCents) {
        return Math.round(amountInCents * 0.006);
    }
}
java
package cn.kb.learn.poly;

public class BankCardChannel extends PaymentChannel {
    private static final long FEE_CAP_IN_CENTS = 2_500L;

    @Override
    public String name() {
        return "银行卡";
    }

    @Override
    public long feeInCents(long amountInCents) {
        long fee = Math.round(amountInCents * 0.005);
        return Math.min(fee, FEE_CAP_IN_CENTS);
    }
}

预期输出:

text
支付宝:手续费 60 分,实收 9940 分
微信支付:手续费 60 分,实收 9940 分
银行卡:手续费 50 分,实收 9950 分
通用渠道:手续费 0 分,实收 10000 分

8. Debug 观察点 ​

  1. 在 printSettlement(channel, amount); 打断点,展开 channel,看 静态类型是 PaymentChannel,但 实际对象显示为 AlipayChannel@xxx;
  2. 按 Step Into(F7)进入 printSettlement,再 Step Into 到 channel.name() —— 观察编辑器跳到的是 AlipayChannel.name();
  3. 继续 Step Into channel.actualReceive(...),你会发现它先进入父类 PaymentChannel.actualReceive(因为没有重写),再在里面 Step Into 到子类的 feeInCents;
  4. 把 BankCardChannel 的 feeInCents 参数改成 10_000_000L,观察手续费被 FEE_CAP_IN_CENTS 限制在 2500 分。

第 3 步是整个多态最值得亲手体验一次的调用链。

9. 常见错误 ​

错误 1:ClassCastException ​

java
PaymentChannel channel = new WechatChannel();
AlipayChannel alipay = (AlipayChannel) channel;   // 运行时报错

修复:转型前用 instanceof 判断,或优先用 instanceof 模式匹配:

java
if (channel instanceof AlipayChannel alipay) {
    // 安全使用
}

错误 2:以为字段有多态 ​

java
Parent reference = new Child();
System.out.println(reference.label);   // 输出 parent,不是 child

修复:不要在父类暴露可变字段;用 getter 方法(方法有多态)替代直接字段访问。

错误 3:把静态方法当成重写 ​

java
class Parent { static String who() { return "Parent"; } }
class Child extends Parent { static String who() { return "Child"; } }

Parent.who();   // Parent
Child.who();    // Child

看起来像多态,其实是两个独立的方法各自绑定到自己的类。@Override 写在静态方法上会直接编译报错——这算是编译器帮你兜住了。

10. 动手实验 ​

目标:用一个方法消除类型分支。

步骤:

  1. 复制第 7 节的五个类到本地,编译运行,确认输出一致;
  2. 新增 ApplePayChannel,手续费为 0.004、单笔手续费上限 100 分;
  3. 在 main 的数组里加上它,除这一行外不改任何代码,确认输出正确;
  4. 尝试写 System.out.println(channel.alipayOnly()); —— 观察编译错误,并解释为什么编译不通过。

验收方式:能解释第 3 步"为什么新增类型不用改循环",以及第 4 步"为什么编译失败"。

扩展任务:给 PaymentChannel 加一个 supportsRefund() 方法,默认返回 false,让某两个渠道返回 true;在 main 里统计支持退款的渠道数量。

11. 练习题 ​

基础题

  1. 判断:字段(成员变量)具有多态性。(对 / 错)
  2. 填空:多态的三个前提是继承关系、、。
  3. 简答:编译时类型决定什么?运行时类型决定什么?

代码阅读题

java
class Base {
    public String describe() { return "base"; }
    public String call() { return "call-" + describe(); }
}

class Sub extends Base {
    @Override
    public String describe() { return "sub"; }
}

public class Quiz {
    public static void main(String[] args) {
        Base base = new Sub();
        System.out.println(base.call());
    }
}
  1. 输出是什么?说明调用链。

编程题

  1. 设计形状体系:Shape 父类提供 area() 与 name();Circle、Rectangle、Triangle 分别重写。在 main 中用一个 Shape[] 数组遍历打印每种形状的名称与面积,并按面积降序输出。

综合任务

  1. 有一个 Notification 体系(邮件、短信、站内信),父类提供 send(String content)。要求:
    • 创建一个发送服务类 NotificationService,它的方法签名是 int broadcast(List<Notification> notifications, String content),返回成功发送的数量;
    • 邮件和短信发送失败时抛异常,服务类要捕获异常并继续发送其余通知;
    • 用 instanceof 模式匹配,统计每种具体类型的发送数量。
分级提示

提示 1(方向):第 5 题的面积计算里,圆的面积用 Math.PI * r * r,注意返回类型选 double。

提示 2(关键点):第 6 题的 broadcast 参数声明为 List<Notification>,所以方法内部只看得到父类方法;这正是封装变化的好处。

提示 3(接近答案):排序用 Arrays.sort(shapes, (a, b) -> Double.compare(b.area(), a.area()));instanceof 模式匹配写成 if (notification instanceof EmailNotification email) { ... }。

12. 参考答案 ​

基础题
  1. 错。字段在编译时绑定,看变量声明类型,不具有多态性。
  2. 子类重写父类方法、父类引用指向子类对象。
  3. 编译时类型决定"能调用哪些方法"(编译期检查);运行时类型决定"实际执行哪个方法体"(动态绑定)。
代码阅读题(第 4 题)

输出 call-sub。

调用链:base.call() 没有被子类重写,所以进入 Base.call();call() 内部调用的 describe() 是多态方法,按运行时类型 Sub 分发,返回 "sub",最终拼接成 "call-sub"。

这正是第 6.2 节讲的"父类定流程、子类填细节"。

编程题(第 5 题)
java
package cn.kb.learn.poly;

public abstract class Shape {
    public abstract String name();

    public abstract double area();
}
java
package cn.kb.learn.poly;

public class Circle extends Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public String name() {
        return "圆形";
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}
java
package cn.kb.learn.poly;

public class Rectangle extends Shape {
    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public String name() {
        return "矩形";
    }

    @Override
    public double area() {
        return width * height;
    }
}
java
package cn.kb.learn.poly;

import java.util.Arrays;

public class ShapeRunner {
    public static void main(String[] args) {
        Shape[] shapes = {new Circle(2), new Rectangle(3, 4), new Circle(1.5)};
        Arrays.sort(shapes, (a, b) -> Double.compare(b.area(), a.area()));
        for (Shape shape : shapes) {
            System.out.printf("%s 面积 %.2f%n", shape.name(), shape.area());
        }
    }
}

说明

这里用了 abstract(抽象类),它是 《抽象类与接口》 的内容。你现在可以先照抄运行,学完 《抽象类与接口》 就会明白为什么父类要设计成"不能被直接实例化"。

:::

综合任务(第 6 题)
java
package cn.kb.learn.poly;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class NotificationService {
    public int broadcast(List<Notification> notifications, String content) {
        int success = 0;
        Map<String, Integer> counters = new HashMap<>();

        for (Notification notification : notifications) {
            try {
                notification.send(content);
                success++;
                counters.merge(notification.getClass().getSimpleName(), 1, Integer::sum);
            } catch (RuntimeException exception) {
                System.out.println("发送失败:" + exception.getMessage());
            }
        }

        for (Map.Entry<String, Integer> entry : counters.entrySet()) {
            System.out.println(entry.getKey() + " 成功 " + entry.getValue() + " 条");
        }
        return success;
    }

    public static void main(String[] args) {
        List<Notification> notifications = new ArrayList<>();
        notifications.add(new EmailNotification());
        notifications.add(new SmsNotification());
        notifications.add(new EmailNotification());

        int success = new NotificationService().broadcast(notifications, "今晚发布新版本");
        System.out.println("总成功数:" + success);
    }
}

要点:broadcast 只依赖父类 Notification,新增通知类型时它完全不用改;用 getClass().getSimpleName() 或 instanceof 模式匹配都能统计具体类型,前者更简洁,后者在需要强转时更安全。

13. 掌握标准 ​

  • [ ] 能写出"父类引用指向子类对象"并解释方法调用的选择过程;
  • [ ] 能用多态把一段 if-else 类型分支改写成类的体系;
  • [ ] 能说出字段、静态方法为什么没有多态;
  • [ ] 会安全地向下转型(instanceof 模式匹配)。

14. 扩展资源 ​

15. 下一步学习 ​

《抽象类与接口》 会把本节用到的 abstract 讲透:既然父类的方法体可能永远用不上,为什么还要写它?抽象类与接口该怎么选?

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引