上一篇的 Account 只有户名和余额。现在要加一种“储蓄账户”:在普通账户的基础上多一个年利率字段,多一个按利率算利息的方法。
最直白的做法是把 Account 整个文件复制一份,改名 SavingsAccount,再补上利率那几行。能跑。但余额的存取逻辑就被抄成了两份:以后 Account 改了规则,两个文件都得改,很容易漏掉一个。
继承(inheritance)解决的就是这种“我大部分和另一个类相同,只多一点自己的特殊性”的需求。这一篇讲清楚三件事:extends 到底给了子类什么、没给什么;new 一个子类对象时构造器按什么顺序跑;子类怎样把父类的某个行为换成自己的。
extends 让子类得到什么
1public class SavingsAccount extends Account {
2 // 这里只写你自己新增的东西
3}SavingsAccount extends Account 读作“SavingsAccount 继承 Account”。SavingsAccount 是 子类(subclass),Account 是它的 父类(superclass)。子类自动获得父类里所有非 private 的字段和方法,用起来跟自己写的一模一样。所以 SavingsAccount 的代码里可以直接调用继承来的 deposit(...)、getBalance(),尽管这两个方法一个字也没出现在它自己的文件里 1。
“继承来的字段”要理解准确:每个 SavingsAccount 对象都自带一份 owner、一份 balance,就像它自带自己的 rate 一样。父类的字段不是两边共享的公共变量,而是每个子类对象各有一份。
子类拿不到什么
先把 Account 的两个字段声明成 private。private 的意思是“只有本类内部的代码能访问”,它和 public 一样是访问修饰符。
1public class Account {
2 private String owner;
3 private int balance;
4
5 public Account(String who, int start) {
6 owner = who;
7 balance = start;
8 }
9
10 public void deposit(int amount) {
11 balance = balance + amount;
12 }
13
14 public int getBalance() {
15 return balance;
16 }
17
18 public String describe() {
19 return owner + " 的余额:" + balance;
20 }
21}有两样东西不会原样交到子类手上。
第一,private 成员。在 SavingsAccount 里直接写 balance = 0; 会编译错误:编译器根本不认识这个名字。但要注意“拿不到 ≠ 不存在”。对象在内存里照样带着 owner、balance 这两块空间,只是子类源码不能按名字碰它。想读想改,得走父类提供的公开方法:getBalance()、deposit(...)。这是访问控制,不是数据没了 1。(另有一档 protected,介于 public 和 private 之间,专门为子类准备;你现在用 public 和 private 就够。)
第二,构造器不被继承。构造器不属于类的“成员”,所以根本谈不上继承 1。new SavingsAccount("张三", 1000, 0.03) 之所以能这么写,不是因为它从 Account 那儿继承了 Account(String, int),而是因为你在子类里自己写了一个构造器——这是下面要讲的。
new 一个子类对象时,构造器怎么排队
1public class SavingsAccount extends Account {
2 private double rate;
3
4 public SavingsAccount(String who, int start, double r) {
5 super(who, start); // 第一句:转去调父类构造器
6 rate = r;
7 }
8
9 public double getRate() {
10 return rate;
11 }
12}执行 new SavingsAccount("张三", 1000, 0.03) 时,按这个顺序发生:
- 在堆上划出空间,把 整个对象 的字段都放进去——父类的
owner、balance,加子类自己的rate。先全部设默认值:null、0、0.0。 - 进入
SavingsAccount的构造器。第一句super(who, start)不是调用当前类的成员,而是转去执行父类的构造器。 - 父类构造器完整跑完:
owner = who; balance = start;。如果Account上面还有父类(其实每个类最终都继承自Object),这一层会继续往上,一路顶到底再往下走。 - 回到子类构造器,先执行子类字段的初始值(如果有
private double rate = 0.03;这类写法),再执行super(...)之后的语句:rate = r;。 - 构造完成,
new把引用交回来。
一句话概括:构造器链从最顶上的父类一路往下,每层先把自己那一层安置好。子类构造器里 super(...) 后面的语句,一定在父类构造器跑完之后才轮到 2、5。
有两条硬规则:
super(...)必须是子类构造器的 第一条语句。你不写,编译器就自动补一句super(),也就是调用父类的无参构造器 2。所以如果父类只写了带参构造器、没留无参的那个,而子类又没显式写super(...),程序编译不过——编译器补的那句找不到目标。- 正因为父类构造器先跑,子类代码可以放心假设父类字段已经安顿好了。
有个副作用值得留意:如果父类构造器里调用了某个方法,而子类正好重写了它,那么第 3 步执行的已经是 子类的版本——此时子类自己的字段还停在默认值 5。
重写:换掉从父类继承来的行为
Account.describe() 输出户名和余额。SavingsAccount 想让输出多一段利率,于是在自己文件里再写一个同名的:
1@Override
2public String describe() {
3 return super.describe() + ",年利率 " + rate;
4}这就叫 重写(override):子类写一个和父类 签名相同 的实例方法——名字一样,参数个数和类型一样,把父类那份实现替换掉。之后通过 SavingsAccount 对象调 describe(),走的是子类这一版 3。
判定条件要说准:
- 名字和参数列表必须完全一致,返回类型也必须一样(或是它的子类型)。名字一样但参数不同,那不是重写,而是另一个新方法。
- 访问权限不能收窄。父类是
public,子类就不能缩成private。 - 不能把实例方法改成
static,反过来也不行,都会编译错误。 - 加上
@Override是好习惯:它让编译器替你核对,你以为在重写、其实参数写岔了,它会当场报错 3。
子类里想复用父类那份实现,写 super.describe()。super 表示“父类那一层”,this 表示“当前这个对象”;super 不能单独拿来当对象用。
编译时看左边,运行时看右边
1Account a = new SavingsAccount("张三", 1000, 0.03);
2System.out.println(a.describe());变量 a 的 声明类型 是 Account,但它指向的对象是 SavingsAccount。这行会打印 子类 那一版的输出。
为什么?编译时,编译器只检查 a 的类型 Account 里有没有 describe()——有,通过。至于运行时真正执行哪一份实现,JVM 不看变量的声明类型,而是看 a 指向的那个对象 实际是什么类,Oracle 把这个行为叫做 virtual method invocation 4。这也是上一篇“变量里装的是门牌号”的直接延伸:门牌号指向什么房子,就执行什么房子的方法。
由此得出一条判断:要把子类对象当父类用,父类里必须声明过那个方法。子类新增的方法(比如 getRate())通过声明为 Account 的变量是调不到的——编译器只认声明类型上有的东西。
小结
extends让子类获得父类非 private 的字段和方法;每个子类对象各带一份父类字段。- private 成员不被继承:子类不能按名字直接访问,但仍可经由继承来的公开方法间接读写。
- 构造器不被继承。
new子类对象时,构造器链从最顶层父类往下依次执行,super(...)必须是子类构造器的第一句;不写则自动补super()。 - 重写要求签名相同、返回类型兼容、访问权限不收窄,
@Override让编译器把关;调用哪个版本由运行时对象的实际类型决定。