上一篇的 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) 时,按这个顺序发生:

  1. 在堆上划出空间,把 整个对象 的字段都放进去——父类的 owner、balance,加子类自己的 rate。先全部设默认值:null、0、0.0。
  2. 进入 SavingsAccount 的构造器。第一句 super(who, start) 不是调用当前类的成员,而是转去执行父类的构造器。
  3. 父类构造器完整跑完:owner = who; balance = start;。如果 Account 上面还有父类(其实每个类最终都继承自 Object),这一层会继续往上,一路顶到底再往下走。
  4. 回到子类构造器,先执行子类字段的初始值(如果有 private double rate = 0.03; 这类写法),再执行 super(...) 之后的语句:rate = r;。
  5. 构造完成,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 让编译器把关;调用哪个版本由运行时对象的实际类型决定。