Skip to content

继承与重写 ​

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

1. 本节定位 ​

继承是复用代码最直观的手段:extends 一下,父类的方法就能用了。但它也是最容易用错的机制——用错的地方,往往不是"少写了代码",而是"改不动的代码"。

本节讲三件事:继承的语法与规则、重写的约束、以及什么时候不该用继承(组合优于继承)。最后一点在实际工作中比语法更重要。

2. 学习目标 ​

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

  • 写出父子类并正确使用 super 调用父类构造方法与成员;
  • 说清重写的四条约束(签名相同、访问权限不能更严、返回类型协变、final 不能重写);
  • 区分重写与重载;
  • 说出 final 类、final 方法、protected 的使用场景与风险;
  • 用"是一个(is-a)"标准判断该不该用继承,并知道什么时候该用组合替代。

3. 前置知识检查 ​

  • 会写带校验的类与私有字段吗?(《封装》)
  • 知道 @Override 注解的含义吗?(《多态》 会系统讲动态绑定)

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

继承解决的问题:三类账户(普通、储蓄、信用)都有"持有者、余额、存款"这些共性。不继承就要复制三遍,改一处要改三处。

继承引入的问题:子类与父类被绑死了。父类改一个方法的行为,所有子类都要重新验证——这叫"脆弱的基类问题"。

看一个真实的坑:父类用 protected 暴露了字段。

java
public class Account {
    protected long balanceInCents;      // 子类可以直接改
}

public class CreditAccount extends Account {
    void hack() {
        balanceInCents = -999999;       // 父类的校验全部被绕过
    }
}

protected 字段把 《封装》 辛苦建立的封装打开了缺口。所以继承的第一条纪律是:父类要给子类提供受保护的"方法"(钩子),而不是字段。

5. 核心概念 ​

5.1 基本语法与构造器链 ​

java
public class Account {
    private final String owner;

    public Account(String owner, long initialInCents) { ... }
}

public class SavingsAccount extends Account {
    public SavingsAccount(String owner, long initialInCents) {
        super(owner, initialInCents);       // 必须第一行调用父类构造方法
    }
}

规则:

  • 子类构造方法必须调用父类构造方法(显式 super(...),或父类有无参构造时隐式调用);
  • super(...) 必须是子类构造方法的第一条语句;
  • 父类没有无参构造、子类又没写 super(...) → 编译失败。

5.2 重写的四条约束 ​

java
public class Account {
    public long withdraw(long amountInCents) { ... }
}

public class SavingsAccount extends Account {
    @Override
    public long withdraw(long amountInCents) { ... }   // 重写
}
约束说明
方法签名相同方法名 + 参数列表完全一致
访问权限不能更严父类 public,子类不能改成 protected
返回类型可协变可以返回父类返回类型的子类型
final / static / private 不能重写final 编译报错;static 是隐藏不是重写;private 子类看不到

始终写 @Override:它让编译器帮你检查"这真的是在重写吗"。漏了注解时,一个拼写错误会变成"定义了一个新方法",而且不报错。

5.3 重写 vs 重载 ​

重写 Override重载 Overload
发生位置子类与父类之间同一个类内
签名相同不同(参数列表不同)
绑定时机运行时(看实际对象类型)编译时(看参数类型)
注解@Override无
java
class Parent {
    void print(int value) { System.out.println("父类 int"); }
}

class Child extends Parent {
    @Override
    void print(int value) { System.out.println("子类 int"); }   // 重写
    void print(String value) { System.out.println("子类 String"); }  // 重载(新方法)
}

5.4 final 与 protected ​

关键字作用什么时候用
final 类不允许被继承值对象、String、安全相关的类
final 方法不允许被重写不想让子类改变的关键逻辑
protected 方法只给子类用模板方法中的钩子(《抽象类与接口》)
protected 字段子类可直接读写尽量别用,会破坏封装

5.5 组合优于继承 ​

判断该不该继承,只问一个问题:子类"是一个"父类吗?

java
// 对:储蓄账户"是一个"账户
class SavingsAccount extends Account { }

// 错:订单"有一个"账户,不是"是一个"账户
class Order extends Account { }               // 语义错误

用继承复用代码是常见陷阱。看这个例子:

java
// 需求:需要一个"支持自动扩容的列表"
class MyList extends ArrayList<String> { }     // 看起来很方便

问题:ArrayList 的 addAll 内部会调用 add,如果你重写了 add 做统计,addAll 就会重复统计——父类的实现细节会影响你的行为。这就是"脆弱的基类"。

组合的写法:

java
public class MyList {
    private final List<String> inner = new ArrayList<>();   // 持有,而不是继承

    public void add(String value) { ... }
    public int size() { return inner.size(); }
}

经验法则:

用继承用组合
语义上确实是"是一个"只是"想复用某个方法"
父类是抽象类或专门为继承设计的父类是具体类(尤其是集合类)
子类需要被当作父类使用(多态)不需要替换父类的位置

6. 递进示例 ​

6.1 最小示例:继承与方法复用 ​

java
public class Account {
    public void deposit(long amountInCents) { ... }
}

public class SavingsAccount extends Account {
    // 不写 deposit,直接继承父类实现
}

6.2 业务示例:三种账户,三条规则 ​

账户类型取款规则
普通账户余额够就能取
储蓄账户额外收 100 分手续费
信用账户允许透支到信用额度

三个类的 withdraw 各自重写,调用方只写一次 account.withdraw(1000)——这就是多态的落地价值。

6.3 反例:用继承复用代码导致的问题 ​

java
// 想复用"存款、取款",于是让订单继承账户
class Order extends Account { }

// 结果:
new Order("张三", 0).deposit(100);    // 订单能"存款"?语义荒谬

修法:订单持有一个账户(组合),或把"金额变动"抽成一个可复用的组件。

7. 完整可运行代码 ​

文件:labs/oop-basics/src/main/java/cn/kb/learn/oop/ 下的 Account.java、SavingsAccount.java、CreditAccount.java、InheritanceLesson.java

java
package cn.kb.learn.oop;

public class Account {
    private final String owner;
    protected long balanceInCents;

    public Account(String owner, long initialInCents) {
        this.owner = owner;
        this.balanceInCents = initialInCents;
    }

    public void deposit(long amountInCents) {
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        balanceInCents += amountInCents;
    }

    /** 取款,返回实际扣款金额。子类可以改变手续费与透支规则。 */
    public long withdraw(long amountInCents) {
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        if (amountInCents > balanceInCents) {
            throw new IllegalStateException("余额不足");
        }
        balanceInCents -= amountInCents;
        return amountInCents;
    }

    public final String owner() {
        return owner;
    }

    public long balanceInCents() {
        return balanceInCents;
    }

    @Override
    public String toString() {
        return getClass().getSimpleName() + "{owner='" + owner + "', balance=" + balanceInCents + "}";
    }
}
java
package cn.kb.learn.oop;

public class SavingsAccount extends Account {
    private static final long FEE_IN_CENTS = 100L;

    public SavingsAccount(String owner, long initialInCents) {
        super(owner, initialInCents);
    }

    @Override
    public long withdraw(long amountInCents) {
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        long total = amountInCents + FEE_IN_CENTS;
        if (total > balanceInCents) {
            throw new IllegalStateException("余额不足(含手续费 " + FEE_IN_CENTS + " 分)");
        }
        balanceInCents -= total;
        return amountInCents;
    }
}
java
package cn.kb.learn.oop;

public class CreditAccount extends Account {
    private final long creditLimitInCents;

    public CreditAccount(String owner, long initialInCents, long creditLimitInCents) {
        super(owner, initialInCents);
        if (creditLimitInCents <= 0) {
            throw new IllegalArgumentException("信用额度必须大于 0");
        }
        this.creditLimitInCents = creditLimitInCents;
    }

    @Override
    public long withdraw(long amountInCents) {
        if (amountInCents <= 0) {
            throw new IllegalArgumentException("金额必须大于 0");
        }
        if (balanceInCents - amountInCents < -creditLimitInCents) {
            throw new IllegalStateException("超出信用额度 " + creditLimitInCents + " 分");
        }
        balanceInCents -= amountInCents;
        return amountInCents;
    }

    public long creditLimitInCents() {
        return creditLimitInCents;
    }
}

运行命令:

powershell
cd labs
./mvnw -pl oop-basics compile
java -cp oop-basics/target/classes cn.kb.learn.oop.InheritanceLesson

预期输出:

text
普通账户 取出 1000 分后余额 9000 分
储蓄账户 取出 1000 分后余额 8900 分
信用账户 取出 1000 分后余额 9000 分
账户信息: [Account{owner='普通账户', balance=9000}, SavingsAccount{owner='储蓄账户', balance=8900}, CreditAccount{owner='信用账户', balance=9000}]
信用账户透支后余额: -3000
预期内的失败: 超出信用额度 5000 分
信用额度: 5000

第 2 行是重写的直接证据:同样是"取出 1000 分",储蓄账户扣了 1100(含 100 分手续费)。

顺带一个细节:toString() 里用了 getClass().getSimpleName(),所以三个对象的输出前缀分别是 Account / SavingsAccount / CreditAccount——这是多态在 Object 方法上的应用。

8. Debug 观察点 ​

  1. 在 account.withdraw(1_000); 处断点,对每个账户按 F7 进入 withdraw:第一个进入 Account.withdraw,第二、三个进入各自子类的实现——同一行代码,三种落点;
  2. 在 SavingsAccount 的构造方法里断点,观察 super(...) 执行时 this 对象的字段从无到有的过程;
  3. 在 getClass().getSimpleName() 处断点,观察 getClass() 返回的是运行时的实际类型,而不是变量声明类型;
  4. 在 InheritanceLesson 里把 Account credit = accounts[2]; 改成 SavingsAccount credit = (SavingsAccount) accounts[2];,观察运行时的 ClassCastException——这是多态里向下转型失败的真实场景。

9. 常见错误 ​

错误 1:忘记调用 super ​

java
public class SavingsAccount extends Account {
    public SavingsAccount(String owner, long initialInCents) {
        // 没有 super(...),而 Account 没有无参构造 → 编译错误
    }
}

修复:显式 super(owner, initialInCents); 放在第一行。

错误 2:把重载当成重写 ​

java
public class Child extends Parent {
    public void print(long value) { }    // 参数类型不同 → 这是重载,不是重写
}

症状:多态场景下调用父类引用,走的还是父类实现。修复:加 @Override,编译器立刻报错提醒你签名不一致。

错误 3:在构造方法里调用可重写方法 ​

java
public class Parent {
    public Parent() {
        init();                    // 危险:此时子类字段还没初始化
    }

    protected void init() { }
}

public class Child extends Parent {
    private final String value = "子类值";

    @Override
    protected void init() {
        System.out.println(value);  // 输出 null!
    }
}

原因:父类构造方法先执行,此时子类的字段还没赋值。修复:不要在构造方法里调用可被重写的方法。

错误 4:想重写 final 方法 ​

java
public class Child extends Account {
    @Override
    public String owner() { }      // 编译错误:owner() 是 final
}

修复:final 是设计者刻意留下的约束(通常出于安全或一致性考虑),要改行为应该改设计,而不是绕过它。

10. 动手实验 ​

目标:从零设计一个继承体系,并识别其中的风险点。

步骤:

  1. 运行第 7 节代码,核对输出(尤其是储蓄账户那行的 8900);
  2. 新增 FixedDepositAccount(定期账户):取款时如果未到期,扣 5% 违约金;
  3. 在 InheritanceLesson 的数组里加上它,验证输出——注意你只需要加一行代码,循环体不用改;
  4. 把 Account.balanceInCents 从 protected 改为 private,编译后观察哪些地方报错,并思考:子类该怎么获取余额?
  5. 写一段说明:如果将来要支持"账户类型可由配置文件动态决定",现在的设计够用吗?

验收方式:能解释第 4 步"为什么把字段改为 private 更安全",以及第 3 步"为什么不用改循环体"。

扩展任务:用组合重写一次第 2 步——把"取款规则"抽成 WithdrawRule 接口,Account 持有它(Account 不再被子类继承,而是注入不同的规则)。对比两种设计的可扩展性。

11. 练习题 ​

基础题

  1. 判断:子类可以重写父类的 private 方法。(对 / 错)
  2. 填空:子类构造方法必须通过 ______ 调用父类构造方法,且必须在第 ______ 行。
  3. 简答:重写与重载的三个区别是什么?

代码阅读题

java
class Parent {
    public String who() { return "parent"; }
    public String call() { return "call-" + who(); }
}

class Child extends Parent {
    @Override
    public String who() { return "child"; }
}

System.out.println(new Child().call());
  1. 输出是什么?call() 在哪里定义、who() 执行的是哪个版本?

编程题

  1. 设计一个图形体系:
    • 抽象类 Shape(《抽象类与接口》 会用抽象类,这里先用普通类):方法 area() 返回 0、name() 返回"图形";
    • Circle 与 Square 分别重写它们;
    • 在 main 里用 Shape[] 数组遍历打印面积;
    • 给 Shape.name() 加上 final,观察子类编译报错。

综合任务

  1. 设计一个"员工薪资"体系:
    • 父类 Employee:姓名、基本工资、monthlyPay() 返回基本工资;
    • SalesEmployee:额外有销售额与提成比例,monthlyPay() = 基本工资 + 销售额 × 比例;
    • Manager:额外有团队奖金,monthlyPay() = 基本工资 + 奖金;
    • 要求:父类不得暴露 protected 字段(用 private + protected 方法);
    • 在 main 里用一个数组打印所有人的薪资,并统计总支出。
分级提示

提示 1(方向):第 4 题的关键是 call() 没有被重写,但 call() 内部调用的 who() 是多态方法。

提示 2(关键点):第 6 题的父类要给子类一个"能读到基本工资"的 protected 方法(如 baseSalary()),而不是 protected 字段。

提示 3(接近答案):第 6 题的父类 monthlyPay() 可以用 final 固定"打印格式",把计算细节交给 protected long monthlyPayInCents() 由子类实现——这就是模板方法(《抽象类与接口》 会正式讲)。

12. 参考答案 ​

基础题
  1. 错。private 方法子类根本看不到,无法重写。子类里写一个同名同参的方法,也只是新定义了一个方法。
  2. super(...)、第一。
  3. ① 位置:重载在同一类内,重写在子类与父类之间;② 签名:重载参数列表不同,重写签名相同;③ 绑定时机:重载在编译期决定,重写在运行期按实际对象类型决定。
代码阅读题(第 4 题)

输出 call-child。

  • call() 定义在 Parent 中,没有被重写,所以执行的是父类实现;
  • call() 内部调用的 who() 是多态方法,按实际对象类型(Child)分发,返回 "child";
  • 拼接得到 "call-child"。

这正是"父类定流程、子类填细节"的最小示例——也是 《抽象类与接口》 模板方法的雏形。

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

public class Shape {
    public final String name() {          // final:不允许子类改名
        return "图形";
    }

    public double area() {
        return 0;
    }

    public static void main(String[] args) {
        Shape[] shapes = {new Circle(2), new Square(3)};
        for (Shape shape : shapes) {
            System.out.printf("%s 面积 %.2f%n", shape.name(), shape.area());
        }
    }
}

class Circle extends Shape {
    private final double radius;

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

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

class Square extends Shape {
    private final double side;

    Square(double side) {
        this.side = side;
    }

    @Override
    public double area() {
        return side * side;
    }
}

发现的问题:name() 被 final 后,子类无法重写它,所以所有图形都叫"图形"——这暴露了设计缺陷。正确的做法是让 name() 可重写,或用 protected 模板方法提供默认实现。

这个练习的意义就在于让你亲手体验:final 用错地方会让设计失去扩展性。

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

public class Employee {
    private final String name;
    private final long baseSalaryInCents;

    public Employee(String name, long baseSalaryInCents) {
        this.name = name;
        this.baseSalaryInCents = baseSalaryInCents;
    }

    public final String name() {
        return name;
    }

    /** 只给子类读,不给它们改 */
    protected final long baseSalaryInCents() {
        return baseSalaryInCents;
    }

    public long monthlyPayInCents() {
        return baseSalaryInCents;
    }

    public final String payrollLine() {
        return String.format("%s 本月薪资 %.2f 元", name(), monthlyPayInCents() / 100.0);
    }

    public static void main(String[] args) {
        Employee[] employees = {
                new Employee("张三", 800_000L),
                new SalesEmployee("李四", 500_000L, 2_000_000L, 0.05),
                new Manager("王五", 1_200_000L, 300_000L)
        };

        long total = 0;
        for (Employee employee : employees) {
            System.out.println(employee.payrollLine());
            total += employee.monthlyPayInCents();
        }
        System.out.printf("总支出 %.2f 元%n", total / 100.0);
    }
}

class SalesEmployee extends Employee {
    private final long salesInCents;
    private final double commissionRate;

    SalesEmployee(String name, long baseSalaryInCents, long salesInCents, double commissionRate) {
        super(name, baseSalaryInCents);
        this.salesInCents = salesInCents;
        this.commissionRate = commissionRate;
    }

    @Override
    public long monthlyPayInCents() {
        return baseSalaryInCents() + Math.round(salesInCents * commissionRate);
    }
}

class Manager extends Employee {
    private final long bonusInCents;

    Manager(String name, long baseSalaryInCents, long bonusInCents) {
        super(name, baseSalaryInCents);
        this.bonusInCents = bonusInCents;
    }

    @Override
    public long monthlyPayInCents() {
        return baseSalaryInCents() + bonusInCents;
    }
}

三个设计要点:

  1. baseSalaryInCents 是 private,子类通过 protected final 的方法读取——封装没被破坏;
  2. payrollLine() 是 final,格式统一,子类不能改坏的输出格式;
  3. monthlyPayInCents() 是扩展点,子类只重写"算法",不重写"流程"。

预期输出:

text
张三 本月薪资 8000.00 元
李四 本月薪资 15000.00 元
王五 本月薪资 15000.00 元
总支出 38000.00 元

13. 掌握标准 ​

  • [ ] 能写出父子类并正确使用 super;
  • [ ] 能说出重写的四条约束,并解释为什么必须写 @Override;
  • [ ] 能区分重写与重载;
  • [ ] 能用"是一个"标准判断该不该继承,并在不合适时改用组合;
  • [ ] 知道 protected 字段的风险,会用 protected 方法替代。

14. 扩展资源 ​

15. 下一步学习 ​

《抽象类与接口》:当父类的实现根本写不出来时,该用抽象类;当需要"一个类具备多种能力"时,该用接口。

前置知识

后续知识

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

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