主题
界面
主题
继承是复用代码最直观的手段:extends 一下,父类的方法就能用了。但它也是最容易用错的机制——用错的地方,往往不是"少写了代码",而是"改不动的代码"。
本节讲三件事:继承的语法与规则、重写的约束、以及什么时候不该用继承(组合优于继承)。最后一点在实际工作中比语法更重要。
完成本节后,你应该能够:
super 调用父类构造方法与成员;final 不能重写);final 类、final 方法、protected 的使用场景与风险;继承解决的问题:三类账户(普通、储蓄、信用)都有"持有者、余额、存款"这些共性。不继承就要复制三遍,改一处要改三处。
继承引入的问题:子类与父类被绑死了。父类改一个方法的行为,所有子类都要重新验证——这叫"脆弱的基类问题"。
看一个真实的坑:父类用 protected 暴露了字段。
public class Account {
protected long balanceInCents; // 子类可以直接改
}
public class CreditAccount extends Account {
void hack() {
balanceInCents = -999999; // 父类的校验全部被绕过
}
}protected 字段把 《封装》 辛苦建立的封装打开了缺口。所以继承的第一条纪律是:父类要给子类提供受保护的"方法"(钩子),而不是字段。
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(...) → 编译失败。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:它让编译器帮你检查"这真的是在重写吗"。漏了注解时,一个拼写错误会变成"定义了一个新方法",而且不报错。
| 重写 Override | 重载 Overload | |
|---|---|---|
| 发生位置 | 子类与父类之间 | 同一个类内 |
| 签名 | 相同 | 不同(参数列表不同) |
| 绑定时机 | 运行时(看实际对象类型) | 编译时(看参数类型) |
| 注解 | @Override | 无 |
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"); } // 重载(新方法)
}| 关键字 | 作用 | 什么时候用 |
|---|---|---|
final 类 | 不允许被继承 | 值对象、String、安全相关的类 |
final 方法 | 不允许被重写 | 不想让子类改变的关键逻辑 |
protected 方法 | 只给子类用 | 模板方法中的钩子(《抽象类与接口》) |
protected 字段 | 子类可直接读写 | 尽量别用,会破坏封装 |
判断该不该继承,只问一个问题:子类"是一个"父类吗?
// 对:储蓄账户"是一个"账户
class SavingsAccount extends Account { }
// 错:订单"有一个"账户,不是"是一个"账户
class Order extends Account { } // 语义错误用继承复用代码是常见陷阱。看这个例子:
// 需求:需要一个"支持自动扩容的列表"
class MyList extends ArrayList<String> { } // 看起来很方便问题:ArrayList 的 addAll 内部会调用 add,如果你重写了 add 做统计,addAll 就会重复统计——父类的实现细节会影响你的行为。这就是"脆弱的基类"。
组合的写法:
public class MyList {
private final List<String> inner = new ArrayList<>(); // 持有,而不是继承
public void add(String value) { ... }
public int size() { return inner.size(); }
}经验法则:
| 用继承 | 用组合 |
|---|---|
| 语义上确实是"是一个" | 只是"想复用某个方法" |
| 父类是抽象类或专门为继承设计的 | 父类是具体类(尤其是集合类) |
| 子类需要被当作父类使用(多态) | 不需要替换父类的位置 |
public class Account {
public void deposit(long amountInCents) { ... }
}
public class SavingsAccount extends Account {
// 不写 deposit,直接继承父类实现
}| 账户类型 | 取款规则 |
|---|---|
| 普通账户 | 余额够就能取 |
| 储蓄账户 | 额外收 100 分手续费 |
| 信用账户 | 允许透支到信用额度 |
三个类的 withdraw 各自重写,调用方只写一次 account.withdraw(1000)——这就是多态的落地价值。
// 想复用"存款、取款",于是让订单继承账户
class Order extends Account { }
// 结果:
new Order("张三", 0).deposit(100); // 订单能"存款"?语义荒谬修法:订单持有一个账户(组合),或把"金额变动"抽成一个可复用的组件。
文件:labs/oop-basics/src/main/java/cn/kb/learn/oop/ 下的 Account.java、SavingsAccount.java、CreditAccount.java、InheritanceLesson.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 + "}";
}
}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;
}
}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;
}
}运行命令:
cd labs
./mvnw -pl oop-basics compile
java -cp oop-basics/target/classes cn.kb.learn.oop.InheritanceLesson预期输出:
普通账户 取出 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 方法上的应用。
account.withdraw(1_000); 处断点,对每个账户按 F7 进入 withdraw:第一个进入 Account.withdraw,第二、三个进入各自子类的实现——同一行代码,三种落点;SavingsAccount 的构造方法里断点,观察 super(...) 执行时 this 对象的字段从无到有的过程;getClass().getSimpleName() 处断点,观察 getClass() 返回的是运行时的实际类型,而不是变量声明类型;InheritanceLesson 里把 Account credit = accounts[2]; 改成 SavingsAccount credit = (SavingsAccount) accounts[2];,观察运行时的 ClassCastException——这是多态里向下转型失败的真实场景。public class SavingsAccount extends Account {
public SavingsAccount(String owner, long initialInCents) {
// 没有 super(...),而 Account 没有无参构造 → 编译错误
}
}修复:显式 super(owner, initialInCents); 放在第一行。
public class Child extends Parent {
public void print(long value) { } // 参数类型不同 → 这是重载,不是重写
}症状:多态场景下调用父类引用,走的还是父类实现。修复:加 @Override,编译器立刻报错提醒你签名不一致。
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!
}
}原因:父类构造方法先执行,此时子类的字段还没赋值。修复:不要在构造方法里调用可被重写的方法。
final 方法 public class Child extends Account {
@Override
public String owner() { } // 编译错误:owner() 是 final
}修复:final 是设计者刻意留下的约束(通常出于安全或一致性考虑),要改行为应该改设计,而不是绕过它。
目标:从零设计一个继承体系,并识别其中的风险点。
步骤:
FixedDepositAccount(定期账户):取款时如果未到期,扣 5% 违约金;InheritanceLesson 的数组里加上它,验证输出——注意你只需要加一行代码,循环体不用改;Account.balanceInCents 从 protected 改为 private,编译后观察哪些地方报错,并思考:子类该怎么获取余额?验收方式:能解释第 4 步"为什么把字段改为 private 更安全",以及第 3 步"为什么不用改循环体"。
扩展任务:用组合重写一次第 2 步——把"取款规则"抽成 WithdrawRule 接口,Account 持有它(Account 不再被子类继承,而是注入不同的规则)。对比两种设计的可扩展性。
基础题
private 方法。(对 / 错)代码阅读题
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());call() 在哪里定义、who() 执行的是哪个版本?编程题
Shape(《抽象类与接口》 会用抽象类,这里先用普通类):方法 area() 返回 0、name() 返回"图形";Circle 与 Square 分别重写它们;main 里用 Shape[] 数组遍历打印面积;Shape.name() 加上 final,观察子类编译报错。综合任务
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() 由子类实现——这就是模板方法(《抽象类与接口》 会正式讲)。
private 方法子类根本看不到,无法重写。子类里写一个同名同参的方法,也只是新定义了一个方法。super(...)、第一。输出 call-child。
call() 定义在 Parent 中,没有被重写,所以执行的是父类实现;call() 内部调用的 who() 是多态方法,按实际对象类型(Child)分发,返回 "child";"call-child"。这正是"父类定流程、子类填细节"的最小示例——也是 《抽象类与接口》 模板方法的雏形。
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 用错地方会让设计失去扩展性。
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;
}
}三个设计要点:
baseSalaryInCents 是 private,子类通过 protected final 的方法读取——封装没被破坏;payrollLine() 是 final,格式统一,子类不能改坏的输出格式;monthlyPayInCents() 是扩展点,子类只重写"算法",不重写"流程"。预期输出:
张三 本月薪资 8000.00 元
李四 本月薪资 15000.00 元
王五 本月薪资 15000.00 元
总支出 38000.00 元super;@Override;protected 字段的风险,会用 protected 方法替代。《抽象类与接口》:当父类的实现根本写不出来时,该用抽象类;当需要"一个类具备多种能力"时,该用接口。