3.4 子类和继承

前面定义的 Circle 是个简单的类,只通过半径区分不同的圆。假设我们要同时使用大小和位置表示圆。例如,在笛卡儿平面中,圆心在 (0, 0)、半径为 1.0 的圆,与圆心在 (1, 2)、半径为 1.0 的圆不同。为此,需要一个新类,我们称其为 PlaneCircle

我们想添加表示圆所在位置的功能,但不想失去 Circle 类的任何现有功能。为此,可以把 PlaneCircle 类定义为 Circle 类的子类,让 PlaneCircle 类继承超类 Circle 的字段和方法。通过定义子类或扩展超类向类中添加功能的能力,是面向对象编程范式的核心。

3.4.1 扩展类

示例 3-3 展示了如何把 PlaneCircle 类定义为 Circle 类的子类。

示例 3-3:扩展 Circle

  1. public class PlaneCircle extends Circle {
  2. // 自动继承了Circle类的字段和方法,
  3. // 因此只要在这里编写新代码
  4. // 新实例字段,存储圆心的位置
  5. private final double cx, cy;
  6. // 新构造方法,用于初始化新字段
  7. // 使用特殊的句法调用构造方法Circle()
  8. public PlaneCircle(double r, double x, double y) {
  9. super(r); // 调用超类的构造方法Circle()
  10. this.cx = x; // 初始化实例字段cx
  11. this.cy = y; // 初始化实例字段cy
  12. }
  13. public double getCentreX() {
  14. return cx;
  15. }
  16. public double getCentreY() {
  17. return cy;
  18. }
  19. // area()和circumference()方法继承自Circle类
  20. // 新实例方法,检查点是否在圆内
  21. // 注意,这个方法使用了继承的实例字段r
  22. public boolean isInside(double x, double y) {
  23. double dx = x - cx, dy = y - cy; // 到圆心的距离
  24. double distance = Math.sqrt(dx*dx + dy*dy); // 勾股定理
  25. return (distance < r); // 返回true或false
  26. }
  27. }

注意示例 3-3 第一行中使用的 extends 关键字。这个关键字告诉 Java,PlaneCircle 类扩展 Circle 类(或者说是 Circle 类的子类),这意味着 PlaneCircle 类会继承 Circle 类的字段和方法。

3.4 子类和继承 - 图1 有多种方式能表达新对象类型具有 Circle 的特征,而且有位置。这或许是最简单的方式,但不一定是最合适的方式,尤其是在大型系统中。

isInside() 方法的定义展示了字段继承:这个方法使用了字段 r(由 Circle 类定义),就像这个字段是在 PlaneCircle 中定义的一样。PlaneCircle 还继承了 Circle 的方法。因此,如果变量 pc 保存的值是一个 PlaneCircle 对象引用,那么可以编写如下代码:

  1. double ratio = pc.circumference() / pc.area();

这么做就好像 area()circumference() 两个方法是在 PlaneCircle 中定义的一样。

子类的另一个特性是,每个 PlaneCircle 对象都是完全合法的 Circle 对象。如果 pc 是一个 PlaneCircle 对象的引用,那么可以把这个引用赋值给 Circle 类型的变量,忽略它表示的位置:

  1. // 位置在原点的单位圆
  2. PlaneCircle pc = new PlaneCircle(1.0, 0.0, 0.0);
  3. Circle c = pc; // 无需校正,赋值给Circle类型的变量

PlaneCircle 对象赋值给 Circle 类型的变量时无需校正。第 2 章说过,这种转换完全合法。Circle 类型的变量 c 中保存的值仍然是有效的 PlaneCircle 对象,但编译器不确定这一点,因此不校正无法反向(缩小)转换:

  1. // 缩小转换需要校正(虚拟机还要做运行时检查)
  2. PlaneCircle pc2 = (PlaneCircle) c;
  3. boolean origininside = ((PlaneCircle) c).isInside(0.0, 0.0);

4.5 节介绍编译时和运行时对象类型的区别时会详细说明这两种转换之间的不同。

final

如果声明类时使用了 final 修饰符,那么这个类无法被扩展或定义子类。java.lang.Stringfinal 类的一个示例。把类声明为 final 可以避免不需要的类扩展:在 String 对象上调用方法时,就算 String 类来自某个未知的外部源,你也知道这个方法是在 String 类中定义的。

3.4.2 超类、对象和类层次结构

在这个示例中,PlaneCircleCircle 的子类,也可以说 CirclePlaneCircle 的超类。类的超类在 extends 子句中指定:

public class PlaneCircle extends Circle { … }

你定义的每个类都有超类。如果没使用 extends 子句指定超类,那么超类是 java.lang.ObjectObject 是特殊的类,原因有如下两个:

  • 它是 Java 中唯一一个没有超类的类;

  • 所有 Java 类都从 Object 类中继承方法。

因为每个类(除了 Object 类)都有超类,所以 Java 中的类组成一个类层次结构。这个体系可以使用一个根为 Object 类的树状图表示。

3.4 子类和继承 - 图2 Object 类没有超类,而且其他每个类都只有一个超类。子类扩展的超类不能超过一个。第 4 章会详细说明如何实现类似的效果。

图 3-1 展示的是类层次结构的一部分,包含我们定义的 CirclePlaneCircle 类,以及 Java API 中的一些标准类。

3.4 子类和继承 - 图3

图 3-1:类层次结构图

3.4.3 子类的构造方法

再看一下示例 3-3 中的 PlaneCircle() 构造方法:

  1. public PlaneCircle(double r, double x, double y) {
  2. super(r); // 调用超类的构造方法Circle()
  3. this.cx = x; // 初始化实例字段cx
  4. this.cy = y; // 初始化实例字段cy
  5. }

虽然这个构造方法显式初始化了 PlaneCircle 类中新定义的字段 cxcy,但仍使用超类的 Circle() 构造方法初始化继承的字段。为了调用超类的构造方法,这个构造方法调用了 super() 方法。

super 是 Java 的保留字。它的用法之一是,在子类的构造方法中调用超类的构造方法。这种用法和在一个构造方法中使用 this() 调用同一个类中的其他构造方法类似。使用 super() 调用构造方法和使用 this() 调用构造方法有同样的限制:

  • 只能在构造方法中像这样使用 super()

  • 必须在构造方法的第一个语句中调用超类的构造方法,甚至要放在局部变量声明之前。

传给 super() 的实参必须与超类构造方法的形参匹配。如果超类定义了多个构造方法,那么 super() 可以调用其中任何一个,具体是哪个,由传入的参数决定。

3.4.4 构造方法链和默认构造方法

创建类的实例时,Java 保证一定会调用这个类的构造方法;创建任何子类的实例时,Java 还保证一定会调用超类的构造方法。为了保证第二点,Java 必须确保每个构造方法都会调用超类的构造方法。

因此,如果构造方法的第一个语句没有使用 this()super() 显式调用另一个构造方法,javac 编译器会插入 super()(即调用超类的构造方法,而且不传入参数)。如果超类没有无需参数的可见构造方法,这种隐式调用会导致编译出错。

PlaneCircle 类为例,创建这个类的新实例时会发生下述事情:

  • 首先,调用 PlaneCircle 类的构造方法;

  • 这个构造方法显式调用了 super(r),调用 Circle 类的一个构造方法;

  • Circle() 构造方法会隐式调用 super(),调用 Circle 的超类 Object 的构造方法(Object 只有一个构造方法);

  • 此时,到达层次结构的顶端了,接下来开始运行构造方法;

  • 首先运行 Object 构造方法的主体;

  • 返回后,再运行 Circle() 构造方法的主体;

  • 最后,对 super(r) 的调用返回后,接着执行 PlaneCircle() 构造方法中余下的语句。

这个过程表明,构造方法链在一起调用;只要创建对象,就会调用一系列构造方法,从子类到超类,一直向上,直到类层次结构的顶端 Object 类为止。因为超类的构造方法始终在子类的构造方法的第一个语句中调用,所以 Object 类的构造方法的主体始终最先运行,然后运行 Object 的子类的构造方法,就这样沿着类层次结构一直向下,直到实例化的那个类为止。

3.4 子类和继承 - 图4 调用构造方法时,超类中的字段也会被初始化。

默认构造方法

前面对构造方法链的说明漏了一点。如果构造方法没有调用超类的构造方法,Java 会隐式调用。那么,如果类没有声明构造方法呢?此时,Java 会为类隐式添加一个构造方法。这个默认的构造方法什么也不做,只是调用超类的构造方法。

例如,如果没为 PlaneCircle 类声明构造方法,那么 Java 会隐式插入下述构造方法:

  1. public PlaneCircle() { super(); }

如果超类 Circle 没有声明无参数的构造方法,那么在这个自动插入 PlaneCircle() 类的默认构造方法中调用 super() 会导致编译出错。一般来说,如果类没有定义无参数的构造方法,那么它的所有子类必须定义显式调用超类构造方法的构造方法,而且要传入所需的参数。

如果类没有定义任何构造方法,默认会为其提供一个无参数的构造方法。声明为 public 的类,提供的构造方法也声明为 public。提供给其他类的默认构造方法则不使用任何可见性修饰符,这些构造方法具有默认的可见性。(本章后面会说明指定可见性的方式。)

如果创建的 public 类不能公开实例化,就应该至少声明一个非 public 的构造方法,以此避免插入默认的 public 构造方法。从来不会实例化的类(例如 java.lang.Mathjava.lang.System),应该定义一个 private 构造方法。这种构造方法不能在类外部调用,但可以避免自动插入默认的构造方法。

3.4.5 遮盖超类的字段

假如 PlaneCircle 类需要知道圆心到原点 (0, 0) 的距离,我们可以再添加一个实例字段保存这个值:

  1. public double r;

在构造方法中添加下述代码可以算出这个字段的值:

  1. this.r = Math.sqrt(cx*cx + cy*cy); // 勾股定理

但是等一下,这个新添加的字段 r 和超类 Circle 中表示半径的字段 r 同名了。发生这种情况时,我们说,PlaneCircle 类的 r 字段遮盖Circle 类的 r 字段。(当然,这个例子是故意这么做的。新字段其实应该命名为 distanceFromOrigin。)

3.4 子类和继承 - 图5 在你编写的代码中,为字段命名时应该避免遮盖超类的字段。如果遮盖了,几乎就表明代码写得不好。

这样定义 PlaneCircle 类之后,表达式 rthis.r 都引用 PlaneCircle 类中的这个字段。那么,如何引用 Circle 类中保存圆的半径的 r 字段呢?有一种特殊的句法可以实现这个需求——使用 super 关键字:

  1. r // 引用PlaneCircle的字段
  2. this.r // 引用PlaneCircle的字段
  3. super.r // 引用Circle的字段

引用被遮盖的字段还有一种方式——把 this(或类的实例)校正为适当的超类,然后再访问字段:

  1. ((Circle) this).r // 引用Circle类的字段

如果想引用的遮盖字段不是在类的直接超类中定义的,这种校正技术特别有用。假如有三个类 ABC,它们都定义了一个名为 x 的字段,而且 CB 的子类,BA 的子类。那么,在 C 类的方法中可以按照下面的方式引用这些不同的字段:

  1. x // C类的x字段
  2. this.x // C类的x字段
  3. super.x // B类的x字段
  4. ((B)this).x // B类的x字段
  5. ((A)this).x // A类的x字段
  6. super.super.x // 非法,不能这样引用A类的x字段

3.4 子类和继承 - 图6 不能使用 super.super.x 引用超类的超类中的遮盖字段 x。这种句法不合法。

类似地,如果 cC 类的实例,那么可以像这样引用这三个字段:

  1. c.x // C类的x字段
  2. ((B)c).x // B类的x字段
  3. ((A)c).x // A类的x字段

目前为止,讨论的都是实例字段。类字段也能被遮盖。引用被遮盖的类字段中的值,可以使用相同的 super 句法,但没必要这么做,因为始终可以把类名放在类字段前引用这个字段。假如 PlaneCircle 的实现方觉得 Circle.PI 字段没有提供足够的小数位,那么他可以自己定义 PI 字段:

  1. public static final double PI = 3.14159265358979323846;

现在,PlaneCircle 类中的代码可以通过表达式 PIPlaneCircle.PI 使用这个更精确的值,还可以使用表达式 super.PICircle.PI 引用精度不高的旧值。不过,PlaneCircle 继承的 area()circumference() 方法是在 Circle 类中定义的,所以,就算 Circle.PIPlaneCircle.PI 遮盖了,这两个方法还是会使用 Circle.PI 的值。

3.4.6 覆盖超类的方法

如果类中定义的某个实例方法和超类的某个方法有相同的名称、返回值类型和参数,那么这个方法会覆盖(override)超类中对应的方法。在这个类的对象上调用这个方法时,调用的是新定义的方法,而不是超类中定义的旧方法。

3.4 子类和继承 - 图7 覆盖方法的返回值类型可以是原方法返回值的子类(没必要一模一样)。这叫作协变返回(covariant return)。

方法覆盖是面向对象编程中一项重要且有用的技术。PlaneCircle 没有覆盖 Circle 类定义的任何方法,不过,假设我们要再定义一个 Circle 的子类,名为 Ellipse

此时,Ellipse 一定要覆盖 Circlearea()circumference() 方法,因为计算圆的面积和周长的公式不适用于椭圆。

下面针对方法覆盖的讨论只涉及实例方法。类方法的运作机制完全不同,无法覆盖。和字段一样,类方法也能被子类遮盖,但不能覆盖。本章前面说过,好的编程风格是调用类方法时始终在前面加上定义这个方法的类名。如果把类名当成方法名的一部分,那么这两个方法的名称就不一样,因此其实并没有遮盖什么。

在进一步讨论方法覆盖之前,要理解方法覆盖和方法重载之间的区别。第 2 章说过,方法重载指的是(在同一个类中)定义多个名称相同但参数列表不同的方法。这和方法覆盖十分不同,因此别混淆了。

1. 覆盖不是遮盖

虽然 Java 使用很多类似的方式对待字段和方法,但方法覆盖和字段遮盖一点儿都不一样。为了引用遮盖的字段,只需把对象校正成适当超类的实例,但不能使用这种技术调用覆盖的实例方法。下述代码展示了这个重要区别:

  1. class A { // 定义一个类,名为A
  2. int i = 1; // 一个实例字段
  3. int f() { return i; } // 一个实例方法
  4. static char g() { return 'A'; } // 一个类方法
  5. }
  6. class B extends A { // 定义A的一个子类
  7. int i = 2; // 遮盖A类的字段i
  8. int f() { return -i; } // 覆盖A类的方法f
  9. static char g() { return 'B'; } // 遮盖A类的类方法g()
  10. }
  11. public class OverrideTest {
  12. public static void main(String args[]) {
  13. B b = new B(); // 创建一个类型为B的新对象
  14. System.out.println(b.i); // 引用B.i,打印2
  15. System.out.println(b.f()); // 引用B.f(),打印-2
  16. System.out.println(b.g()); // 引用B.g(),打印B
  17. System.out.println(B.g()); // 调用B.g()更好的方式
  18. A a = (A) b; // 把b校正成A类的实例
  19. System.out.println(a.i); // 现在引用的是A.i,打印1
  20. System.out.println(a.f()); // 还是引用B.f(),打印-2
  21. System.out.println(a.g()); // 引用A.g(),打印A
  22. System.out.println(A.g()); // 调用A.g()更好的方式
  23. }
  24. }

初看起来,可能觉得方法覆盖和字段遮盖的这种区别有点奇怪,但稍微想想,确实有道理。

假设我们要处理一些 CircleEllipse 对象。为了记录这些圆和椭圆,我们把它们存储在一个 Circle[] 类型的数组中。这么做是可以的,因为 EllipseCircle 的子类,所以所有 Ellipse 对象都是合法的 Circle 对象。

遍历这个数组的元素时,不需要知道也无需关心元素是 Circle 对象还是 Ellipse 对象。不过,需要密切关注的是,在数组的元素上调用 area() 方法是否能得到正确的值。也就是说,如果是椭圆对象就不能使用计算圆面积的公式。

我们真正希望的是,计算面积时对象能“做正确的事”:Circle 对象使用自己的方式计算,Ellipse 对象使用对椭圆来说正确的方式计算。

这样理解,就不会对 Java 使用不同的方式处理方法覆盖和字段遮盖感到奇怪了。

2. 虚拟方法查找

如果一个 Circle[] 类型的数组保存的是 CircleEllipse 对象,那么编译器怎么知道要在具体的元素上调用 Circle 类还是 Ellipse 类的 area() 方法呢?事实上,源码编译器在编译时并不知道要调用哪个方法。

不过,javac 生成的字节码会在运行时使用“虚拟方法查找”(virtual method lookup)。解释器运行代码时,会查找适用于数组中各个对象的 area() 方法。即,解释器解释表达式 o.area() 时,会检查变量 o 引用的对象的真正运行时类型,然后找到适用于这个类型的 area() 方法。

3.4 子类和继承 - 图8 某些其他语言(例如 C# 和 C++)默认不使用虚拟查找,如果程序员想在子类中覆盖方法,要显式使用 virtual 关键字。

JVM 不会直接使用关联在变量 o 表示的静态类型身上的 area() 方法,如果这么做,前面详述的方法覆盖机制就不成立了。Java 的实例方法默认使用虚拟查找。第 4 章会详细介绍编译时和运行时类型,以及它们对虚拟方法查找的影响。

3. 调用被覆盖的方法

我们已经说明了方法覆盖和字段遮盖之间的重要区别。然而,调用被覆盖的方法的 Java 句法和访问被遮盖的字段的句法十分类似——都使用 super 关键字。如下述代码所示:

  1. class A {
  2. int i = 1; // 被子类B遮盖的实例字段
  3. int f() { return i; } // 被子类B覆盖的实例方法
  4. }
  5. class B extends A {
  6. int i; // 这个字段遮盖A类的字段i
  7. int f() { // 这个方法覆盖A类的方法f()
  8. i = super.i + 1; // 可以像这样读取A.i的值
  9. return super.f() + i; // 可以像这样调用A.f()
  10. }
  11. }

前面说过,使用 super 引用被遮盖的字段时,相当于把 this 校正为超类类型,然后通过超类类型访问字段。不过,使用 super 调用被覆盖的方法和校正 this 引用不是一回事。也就是说,在上述代码中,表达式 super.f()((A)this).f() 的作用不一样。

解释器使用 super 句法调用实例方法时,会执行一种修改过的虚拟方法查找。第一步和常规的虚拟方法查找一样,确定调用方法的对象属于哪个类。正常情况下,运行时会在这个类中寻找对应的方法定义。但是,使用 super 句法调用方法时,先在这个类的超类中查找。如果超类直接实现了这个方法,那就调用这个方法。如果超类继承了这个方法,那就调用继承的方法。

注意,super 关键字调用的是方法的直接覆盖版本。假设 A 类有个子类 BB 类有个子类 C,而且这三个类都定义了同一个方法 f()。在 C.f() 方法中使用 super.f() 可以调用方法 B.f(),因为 C.f() 直接覆盖了 B.f()。但是,C.f() 不能直接调用 A.f(),因为 super.super.f() 不是合法的 Java 句法。当然,如果 C.f() 调用了 B.f(),有合理的理由认为,B.f() 可能会调用 A.f()

使用被覆盖的方法时,这种链式调用相当常见。覆盖方法是增强方法功能,但不完全取代这个方法的一种方式。

3.4 子类和继承 - 图9 别把调用被覆盖方法的 super 和构造方法中调用超类构造方法的 super() 搞混了。虽然二者使用的关键字相同,但却是两种完全不同的句法。具体而言,可以在类中的任何位置使用 super 调用超类中被覆盖的方法,但是只能在构造方法的第一个语句中使用 super() 调用超类的构造方法。

还有一点很重要,即记住,只能在覆盖某个方法的类内部使用 super 调用被覆盖的方法。假如 e 引用的是一个 Ellipse 对象,那么无法在 e 上调用 Circle 类中定义的 area() 方法。