跳到内容

第 1 卷 / 第 8 章 / 第 5 课

8.5 实验:继承、接口与多态 ​

前置知识:继承、重写、动态分派、抽象类和接口

预计时间:40 分钟

练习环境:JDK 17+ 与 Bash;所有文件写入临时目录

完成标志:能通过正向与失败测试证明子类型契约,而不只证明代码可以编译

bash
chapter8_oop_root=$(mktemp -d)
readonly chapter8_oop_root
mkdir -p "$chapter8_oop_root/out" "$chapter8_oop_root/broken"
cd "$chapter8_oop_root"

热身 1:动物叫声与多态 ​

保存为 Animal.java:

java
abstract class Animal {
    private final String name;

    Animal(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("blank name");
        }
        this.name = name;
    }

    final String name() { return name; }
    abstract String sound();
}

final class Dog extends Animal {
    Dog(String name) { super(name); }
    @Override String sound() { return "汪"; }
}

final class Cat extends Animal {
    Cat(String name) { super(name); }
    @Override String sound() { return "喵"; }
}

final class Zoo {
    static String introduce(Animal animal) {
        java.util.Objects.requireNonNull(animal, "animal");
        return animal.name() + ":" + animal.sound();
    }
}

测试 Zoo.introduce(new Dog("小黑")) 和 Cat,并确认空白名字、null 参数按契约失败。再增加一种动物,Zoo 不应修改。

热身 2:验证 @Override 和 final ​

把故意失败的最小源码保存为 BrokenOverride.java:

java
abstract class Animal {
    abstract String sound();
}

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

它同时缺少 sound 实现,且 sounds 没有可覆盖目标。实际编译并确认失败状态:

bash
broken_override_status=0
javac --release 17 -encoding UTF-8 -d broken BrokenOverride.java \
  > broken-override.log 2>&1 || broken_override_status=$?
test "$broken_override_status" -ne 0
sed -n '1,12p' broken-override.log

再把可重写方法设为 final,确认子类覆盖也得到编译错误。失败示例必须由 javac 验证,不能只靠注释猜测。

挑战:整数分支付契约 ​

接口返回结果,不让调用者解析打印文本。保存为 PaymentMethod.java:

java
interface PaymentMethod {
    PaymentResult pay(long cents);
}

final class PaymentResult {
    private final boolean successful;
    private final String reference;

    private PaymentResult(boolean successful, String reference) {
        this.successful = successful;
        this.reference = reference;
    }

    static PaymentResult success(String reference) {
        if (reference == null || reference.isBlank()) {
            throw new IllegalArgumentException("blank reference");
        }
        return new PaymentResult(true, reference);
    }

    static PaymentResult declined() {
        return new PaymentResult(false, "");
    }

    boolean successful() { return successful; }
    String reference() { return reference; }
}

实现一个只能支付现有余额以内金额的测试支付方式,保存为 TestWallet.java:

java
final class TestWallet implements PaymentMethod {
    private long balanceCents;

    TestWallet(long balanceCents) {
        if (balanceCents < 0) {
            throw new IllegalArgumentException("negative balance");
        }
        this.balanceCents = balanceCents;
    }

    long balanceCents() { return balanceCents; }

    @Override
    public PaymentResult pay(long cents) {
        if (cents <= 0) {
            throw new IllegalArgumentException("amount must be positive");
        }
        if (cents > balanceCents) {
            return PaymentResult.declined();
        }
        balanceCents -= cents;
        return PaymentResult.success("test-" + cents);
    }
}

final class Checkout {
    static PaymentResult charge(PaymentMethod method, long cents) {
        return java.util.Objects.requireNonNull(method, "method").pay(cents);
    }
}

测试成功、余额不足、0、负数和 null 方法,并检查失败调用没有扣款。test-金额 只是演示标签,多次同额支付会重复,不能当作唯一交易号。真实支付还需要幂等键、超时、重试边界、外部失败、审计和并发事务;多态只替换实现,不自动解决这些问题。

边界练习:模式匹配只放在适当位置 ​

java
static String describe(Object value) {
    if (value == null) return "null";
    if (value instanceof Animal animal) {
        return "animal:" + animal.name();
    }
    if (value instanceof String text) {
        return "text:" + text;
    }
    return value.getClass().getSimpleName();
}

这种代码适合日志或外部输入分类。若核心支付流程按 CreditCard、Wallet、Cash 不断增加 instanceof 分支,应把共同动作放回 PaymentMethod 契约。

Object 方法练习 ​

为一个不可变值类型实现 toString、equals 和 hashCode,验证:

  • 自反、对称、传递;
  • 相等对象 hashCode 相同;
  • null 不相等;
  • 放入 HashSet 后相等副本不增加大小。

只要对象仍作为哈希集合的元素或哈希映射的键,就不要修改参与 equals/hashCode 的字段;这不是学完集合后就能放宽的规则。

统一测试入口 ​

保存为 Oop2Lab.java。匿名类给小鸟提供一次性实现;Runnable 参数接收待执行的检查动作,后面的 Lambda 一讲会解释 () -> ...。入口先检查断言开关,防止漏写 -ea 却报告通过:

java
public class Oop2Lab {
    public static void main(String[] args) {
        boolean assertionsEnabled = false;
        assert assertionsEnabled = true;
        if (!assertionsEnabled) {
            throw new IllegalStateException("run with -ea");
        }
        assert Zoo.introduce(new Dog("小黑")).equals("小黑:汪");
        assert Zoo.introduce(new Cat("小白")).equals("小白:喵");
        expectIllegal(() -> new Dog(" "));
        expectNull(() -> Zoo.introduce(null));

        Animal bird = new Animal("小灰") {
            @Override String sound() { return "啾"; }
        };
        assert Zoo.introduce(bird).equals("小灰:啾");

        TestWallet wallet = new TestWallet(5_000);
        PaymentResult paid = Checkout.charge(wallet, 2_000);
        assert paid.successful();
        assert paid.reference().equals("test-2000");
        assert !Checkout.charge(wallet, 4_000).successful();
        expectIllegal(() -> Checkout.charge(wallet, 0));
        expectIllegal(() -> Checkout.charge(wallet, -1));
        expectNull(() -> Checkout.charge(null, 100));
        assert wallet.balanceCents() == 3_000;

        assert describe(null).equals("null");
        assert describe(new Dog("小黑")).equals("animal:小黑");
        assert describe("notice").equals("text:notice");

        System.out.println("inheritance lab passed");
    }

    static String describe(Object value) {
        if (value == null) return "null";
        if (value instanceof Animal animal) {
            return "animal:" + animal.name();
        }
        if (value instanceof String text) {
            return "text:" + text;
        }
        return value.getClass().getSimpleName();
    }

    private static void expectIllegal(Runnable action) {
        try {
            action.run();
            throw new AssertionError("expected IllegalArgumentException");
        } catch (IllegalArgumentException expected) {
            // 参数契约按预期拒绝输入。
        }
    }

    private static void expectNull(Runnable action) {
        try {
            action.run();
            throw new AssertionError("expected NullPointerException");
        } catch (NullPointerException expected) {
            // null 边界按预期失败。
        }
    }
}
bash
javac --release 17 -encoding UTF-8 -Xlint:all,-auxiliaryclass -d out \
  Animal.java PaymentMethod.java TestWallet.java Oop2Lab.java
java -ea -cp out Oop2Lab

这些教学文件为控制篇幅合并了数个包级辅助类型,因此命令只排除由文件布局产生的 auxiliaryclass lint,其余警告仍开启。预期输出为 inheritance lab passed。保存实验记录后清理:

bash
cd
ls -ld -- "$chapter8_oop_root"
rm -r -- "$chapter8_oop_root"

离开这一单元前 ​

新增 Dog、Cat 之外的动物时,Zoo 不应修改;错误的 @Override 与 final 覆盖必须由编译器确认。支付测试要分开记录成功、余额不足、非法金额和 null 方法,金额使用整数最小单位。多态能替换实现,却不会自动提供幂等、事务或线程安全。

完成对象单元后,再把短行为作为值传递。

→ 下一步:Lambda 入门

Built with VitePress | Software Systems Atlas