[关闭]
@wjcper2008 2017-02-22T22:00:00.000000Z 字数 28421 阅读 1594

专题三 Java 面向对象编程*

Java 基础学习


来自:Java技术手册(第6版) 内容,由Teacher Chen整理,仅供内部教学使用!

介绍 Java 基本句法之后,可以开始介绍 Java 面向对象编程了。所有 Java 程序都使用对象,对象的类型由接口定义。每个 Java 程序都定义成类,而复杂的程序会定义很多类和接口。本章说明如何定义新类,以及如何使用类进行面向对象编程。本章还会介绍接口的概念,但接口和 Java 的类型系统的详细介绍将在专题四进行。

不过,如果你有面向对象编程的经验,要小心。“面向对象”在不同的语言中有不同的含义。不要认为 Java 对面向对象的实现和你最喜欢的面向对象语言一样(C++ 和 Python 程序员尤其要注意)。

这一章的内容很多,下面先简要介绍一些基本概念。

3.1 类简介

类是 Java 程序最基本的元素结构。编写 Java 代码不可能不定义类。所有 Java 语句都在类中,而且所有方法都在类中实现。

定义Point 类如下:

  1. /** 表示笛卡尔坐标系中的(x,y)点 */
  2. public class Point {
  3. // 点的坐标
  4. public double x, y;
  5. // 初始化字段的构造方法
  6. public Point(double x, double y) {
  7. this.x = x; this.y = y;
  8. }
  9. // 操作x和y字段的方法
  10. public double distanceFromOrigin() {
  11. return Math.sqrt(x*x + y*y);
  12. }
  13. }

3.1.1 面向对象的基本概念

1、类和对象

对象一般通过实例化类创建,方法是使用 new 关键字并调用构造方法(将在 3.3 节介绍),如下所示:

  1. Point p = new Point(1.0, 2.0);

2、类的签名

一个类的定义包含一个签名和一个主体
- 类的签名:定义类的名称,可能还会指定其他重要信息,例如public class Point
- 类的主体:是一些放在花括号里的成员。类的成员一般包含属性方法,也可以包含构造方法、初始化程序和嵌套类型。

成员可以是静态的,也可以是非静态的。静态成员属于类本身,而非静态成员关联在类的实例上(参见 3.2 节)。

常见的成员有四种:类属性和类方法(static);实例属性和实例方法。Java 的主要任务就是与这些成员交互。

类的签名可能会声明它扩展(extends)自其他类,例如:

  1. public class Circle extends Point {
  2. // 类实体
  3. }

被扩展的类叫作超类(或父类),扩展其他类的类叫作子类。比如Point为超类,Circle为子类。子类Circle继承超类Point的成员,而且Circle可以声明新成员,或者使用新的实现覆盖继承的方法。

类的成员可以使用访问修饰符1publicprotectedprivate。 这些修饰符指定成员使用方子类中是否可见以及能否访问。类通过这种方式控制对非公开 API 成员的访问。隐藏成员是一种面向对象设计技术,叫作数据封装(data encapsulation),3.5 节会介绍。

1稍后会见到默认的可见性,即在包中可见。

3.1.2 其他引用类型

类的签名可能还会声明类实现了一个或多个接口。接口是一种类似于类的引用类型,其中定义了方法签名,但一般没有实现方法的方法主体。

不过,从 Java 8 开始,接口可以使用关键字 default 指明其中的方法是可选的。如果方法是可选的,接口文件必须包含默认的实现(因此才选用 default 这个关键词);所有实现这个接口的类,如果没有实现可选的方法,就使用接口中默认的实现。

实现接口的类必须为接口的非默认方法提供主体。实现某个接口的类的实例,也是这个接口类型的实例。

类和接口是 Java 定义的五种基本引用类型中最重要的两个。另外三个基本引用类型是数组、枚举类型和注解类型(通常直接叫“注解”)。前面已经介绍过数组。枚举是特殊的类,注解是特殊的接口——专题四会介绍这两种类型,还会全面说明接口。

3.1.3 如何定义类

最简单的类定义方式是在关键字 class 后面放上类的名称,然后在花括号中放一些类的成员。
1. class 关键字前面可以放修饰符关键字或注解。
2. 如果类扩展其他类,类名后面要加上 extends 关键字和要扩展的类名。
3. 如果类实现一个或多个接口,类名或 extends 子句之后要加上 implements 关键字和用逗号分隔的接口名。例如:

  1. public class Integer extends Number implements Serializable, Comparable {
  2. // 这里是类的成员
  3. }

定义泛型类时还可以指定类型参数和通配符(参见专题四)。

类声明可以包含修饰符关键字。除访问控制修饰符(public, protected, private)之外,还可以使用:

3.2 什么是属性和方法

类可以看成是由一些数据(也叫状态)和操作这些状态的代码组成的。数据存储在属性中,操作数据的代码则组织在方法中。

本节介绍两种最重要的类成员:属性和方法。属性和方法有两种不同的类型:关联在类自身上的类成员(也叫静态成员),关联在类的单个实例(即对象)身上的实例成员。因此,成员分为四类:

程序 3-1 定义了一个简单的类 Circle,包含所有这四种成员类型。

程序 3-1:一个简单的类及其成员

  1. public class Circle {
  2. // 类属性
  3. public static final double PI= 3.14159;
  4. // 类方法:基于参数计算得到一个值
  5. public static double radiansToDegrees(double radians) {
  6. return radians * 180 / PI;
  7. }
  8. // 实例属性
  9. public double r; // 圆的半径
  10. // 两个实例方法:处理对象的实例属性
  11. public double area() { // 计算圆的面积
  12. return PI * r * r;
  13. }
  14. public double circumference() { // 计算圆的周长
  15. return 2 * PI * r;
  16. }
  17. // 半径r最好private,然后通过方法来访问r
  18. public double radius(){
  19. return r;
  20. }
  21. }

一般来说公开 r 属性并不好,最好把 r 声明为私有属性(private),然后提供 radius() 方法,获取它的值,这就是封装。原因在 3.5 节说明。现在,我们使用public属性只是为了演示如何处理实例属性。

随后的几节说明这四种成员。首先,介绍如何声明属性。声明方法将在 3.5 节介绍。

3.2.1 如何声明属性

声明属性和声明局部变量很像,不过声明属性时还可以使用修饰符。最简单的属性声明包含属性类型属性名

  1. 类型前面可以放零个或多个修饰符关键字,名称后面可以跟着一个等号和初始化表达式,提供属性的初始值。
  2. 如果两个或多个属性的类型和修饰符都相同,那么可以把一些用逗号分隔的属性名和初始化表达式放在类型后面。如下是一些有效的属性声明:
  1. int x = 1;
  2. private String name;
  3. public static final int DAYS_PER_WEEK = 7;
  4. String[] daynames = new String[DAYS_PER_WEEK];
  5. private int a = 17, b = 37, c = 53;

属性的修饰符由零个或多个下述关键字组成。

3.2.2 类属性(static)

类属性关联在定义它的类身上,而不是类的实例身上。下面这行代码声明一个类属性:

  1. public static final double PI = 3.14159;

这行代码声明了一个属性,类型为 double,名称为 PI,并且把值设为 3.14159。

  1. static 修饰符表明这个属性是类属性。因为使用了 static 修饰符,所以类属性有时也叫静态属性。
  2. final 修饰符表明这个属性的值不会改变。因为属性 PI 表示一个常量,而且声明时加上了 final,所以无法修改它的值(常量)。在 Java(以及很多其他语言)中,习惯使用大写字母命名常量,因此这个属性的名称是 PI,而不是 pi
  3. 类属性经常用来定义常量,也就是说,staticfinal 修饰符经常放在一起使用。然而,并不是所有类属性都是常量,因此属性可以声明为 static 但不声明为 final

    公开的静态属性要尽量声明为 final,因为多个线程都能修改属性的值,会导致极难调试的行为。

  4. 公开的静态属性其实就是全局变量。不过,类属性的名称会被定义它的类名限定,通过类名+类属性名进行访问,即Circle.PI。因此,如果不同的模块定义了同名的全局变量,Java 不会出现其他语言遇到的名称冲突问题。

关于类属性,有个重点要理解,即*该属性的值只有一个。属性关联在类自身上,而不是类的实例身上。看一下 Circle 类中的各个方法,它们都使用了同一个属性。在 Circle 类内部,可以直接使用 PI 引用这个属性。但是在Circle类外部,需要使用 Circle.PI 才能访问这个属性。

3.2.3 类方法(static)

和类属性一样,类方法也使用 static 修饰符声明:

  1. public static double radiansToDegrees(double rads) {
  2. return rads * 180 / PI;
  3. }

上述代码声明了一个类方法,名为 radiansToDegrees()。这个方法只有一个参数,类型为 double,而且会返回一个 double 类型的值。

和类属性一样,类方法也关联在类身上,而不是对象身上。在类的外部调用类方法时,既要指定类名也要指定方法名。例如:

  1. // 2.0弧度等于多少角度?
  2. double d = Circle.radiansToDegrees(2.0);
  1. 如果想在定义类方法的类中调用其他的类方法,则不用指定类名。
  2. 注意,Circle.radiansToDegrees() 方法的主体使用了类属性 PI。类方法可以使用所在类(或其他类)中的任何类属性和类方法。
  3. 但是,切记,类方法不能使用任何实例属性或实例方法,因为类方法不关联在类的实例身上。也就是说,虽然 radiansToDegrees() 方法在 Circle 类中定义,但它不能使用 Circle 对象的任何实例成员。

    可以这样理解:在任何实例中,总有一个 this 引用指向当前对象,但类方法不关联在具体的实例身上,所以没有 this 引用,因此不能访问实例属性。

  4. 前面说过,类属性其实就是全局变量。类似地,类方法是全局方法,或全局函数。虽然 radiansToDegrees() 方法不处理 Circle 对象,但还是在 Circle 类中定义,因为它是一个实用方法,处理 Circle 类时有时会被调用,因此可以把它和 Circle 类的其他功能放在一起。

3.2.4 实例属性

凡是,声明时没使用 static 修饰符的属性是实例属性

  1. public double r; // 圆的半径

实例属性关联在类的实例身上,所以创建的每个 Circle 对象都有属于自己的一个 double 类型 r 属性值。在这个例子中,r 表示某个圆的半径。每个 Circle 对象的半径和其他所有 Circle 对象的都不同

在类定义内部,实例属性可通过属性名引用。在实例方法 circumference() 的主体中有一个例子。在类外部,实例属性名前面必须加上包含这个属性的对象的引用。例如,如果变量 c 保存的是一个 Circle 对象的引用,那么可以使用表达式 c.r 引用这个圆的半径:

  1. // 创建一个Circle对象,把引用存储在c中
  2. Circle c = new Circle();
  3. c.r = 2.0; // 把一个值赋值给实例属性r
  4. Circle d = new Circle(); // 再创建一个Circle对象
  5. d.r = c.r * 2; // 让这个圆是前一个的两倍大

实例属性是面向对象编程的关键。实例属性保存对象的状态,实例属性的值把两个对象区分开来。

3.2.5 实例方法

实例方法处理类的具体实例(对象),只要声明方法时没使用 static 关键字,这个方法默认就是实例方法。

实例方法这个特性让面向对象编程开始变得有趣。程序 3-1 中定义的 Circle 类包含两个实例方法,area()circumference(),分别计算指定 Circle 对象表示的圆的面积和周长。

若想在定义实例方法的类之外使用实例方法,必须在方法名前加上要处理的实例引用。例如:

  1. // 创建一个Circle对象,存储在变量c中
  2. Circle c = new Circle();
  3. c.r = 2.0; // 设定这个对象的实例属性
  4. double a = c.area(); // 调用这个对象的实例方法

这就是叫面向对象编程的原因,通过对象调用方法,这里对象是重点,而不是函数调用。前面说过,经常可以把对象理解为包含实际状态(通过对象的属性表示)和行为(处理状态的方法)的组合。

在实例方法内部,可以访问到属于该对象的实例属性。实现所有实例方法时都使用了一个隐式参数,方法签名时不需要指明这个参数。这个隐式参数是 this,它的值是调用这个方法的对象引用。在我们的例子中,是一个 Circle 对象。

area()circumference() 两个方法的主体都使用了类属性 PI。前面说过,类方法只能使用类属性和类方法,而不能使用实例属性或实例方法。实例方法没有这种限制,不管类中的成员有没有声明为 static,实例方法都可以使用。

3.2.6 方法和属性的使用总结

  1. 类方法可以使用类属性;
  2. 类方法不能使用实例属性和实例方法;
  3. 类方法中不能使用this
  4. 实例方法可以使用类属性和类方法,同时可以使用实例属性和其他方法;
  5. 为了避免类和实例混淆,建议类方法和属性使用类名.XX形式,而实例方法和属性使用对象.XX

3.2.7 this引用的工作方式

方法签名中不显示隐式参数 this,因为外部调用时this的赋值是通过.运算符传递的。另外,实例方法访问实例属性或调用其他实例方法时,都默认访问 this 参数指向的对象中的属性或方法,可以理解为“在当前对象上调用访问实例属性或实例方法”。

如果想明确表明方法访问的是自己(当前对象)的属性或方法,可以显式使用 this 关键字。例如,可以改写 area() 方法,显式使用 this 引用实例属性:

  1. public double area() {
  2. return Circle.PI * this.r * this.r;
  3. }

上述代码还显式使用类名引用类属性 PI。在这样简单的方法中,一般无需如此明确。然而,遇到复杂情况时,在不强制要求使用 this 的地方使用 this,有时可以让代码的意图更明确。

不过,有些情况下必须使用 this 关键字。例如,如果方法的参数或方法中的局部变量和类中的某个属性同名,那么就必须使用 this 引用这个属性,因为只使用属性名的话,引用的是方法的参数或局部变量。

例如,可以把下述方法添加到 Circle 类中:

  1. public void setRadius(double r) {
  2. // 把参数r的值赋值给属性this.r
  3. // 注意,不能写成r = r
  4. this.r = r;
  5. }

有些开发者会谨慎选择方法的参数名,避免和属性名冲突,因此可以最大限度地少使用 this

最后,注意,实例方法可以使用 this 关键字,但类方法不能使用。这是因为类方法不关联在单个对象身上。

3.3 创建和初始化对象

构造方法的作用是新建对象,并初始化实例中的属性。再看一下创建 Circle 对象的方式:

  1. Circle c = new Circle();

这行代码的意思是,调用看起来有点儿像new之后跟着一个方法,用来创建一个新 Circle 对象。其实,Circle() 是一种构造方法,是类中的一个特殊成员,和类同名,而且像方法一样,有主体。

构造方法的工作方式是这样的:
1. new 运算符表明我们想创建类的一个新实例;
2. 首先,分配内存存储新建的对象实例;
3. 然后,调用构造方法的主体,并传入指定的参数;
4. 最后,构造方法使用这些参数执行初始化新对象所需的一切操作。

Java 中的每个类都至少有一个构造方法,其作用是执行初始化新对象所需的操作。程序 3-1 定义的 Circle 类没有显式定义构造方法,因此 javac 编译器自动为我们提供了一个构造方法(叫作默认构造方法)。这个构造方法没有参数,而且不执行任何特殊的初始化操作。仅仅是把这对象创建出来,并使用默认的初始化。

3.3.1 定义构造方法

可是 Circle 对象显然要做些初始化操作,下面就来定义一个构造方法。程序 3-2 重新定义了 Circle 类,包含一个构造方法,指定新建 Circle 对象的半径。借此机会,我们还把 r 属性改成了受保护的(禁止对象随意访问)。

程序 3-2:为 Circle 类定义一个构造方法

  1. public class Circle {
  2. public static final double PI = 3.14159;
  3. // 实例属性,保存圆的半径
  4. protected double r;
  5. // 构造方法:初始化r属性
  6. public Circle(double r) { this.r = r; }
  7. // 实例方法:基于半径计算得到值
  8. public double circumference() { return 2 * PI * r; }
  9. public double area() { return PI * r*r; }
  10. public double radius() { return r; }
  11. }

注意,如果类定义中,给出了带参数的构造方法,那么创建类的时候,就不能使用默认的无参构造方法,即Circle c = new Circle()编译器将会报错。

如果仅仅依赖编译器提供的默认构造方法,就要编写如下的代码显式初始化半径:

  1. Circle c = new Circle();
  2. c.r = 0.25;

添加上述构造方法后,初始化变成创建对象过程的一部分:

  1. Circle c = new Circle(0.25);

下面是一些关于命名、声明和编写构造方法的基本注意事项。

3.3.2 定义多个构造方法(重载)

有时可能想在多个不同的构造方法中选择一个最便利的方式初始化对象。例如,我们可能想使用指定的值初始化圆的半径,或者使用一个合理的默认值初始化。为 Circle 类定义两个构造方法的方式如下:

  1. public Circle() { r = 1.0; }
  2. public Circle(double r) { this.r = r; }

Circle 类只有一个实例属性,由此并没有太多的初始化方式。不过在复杂的类中,经常会定义不同的构造方法。

只要构造方法的参数列表不同(即方法的不同签名方式),为一个类定义多个构造方法完全是合法的。编译器会根据提供的参数数量和类型判断你想使用的是哪个构造方法。定义多个构造方法和方法重载的原理类似。

3.3.3 在一个构造方法中调用另一个构造方法

如果类有多个构造方法,会用到 this 关键字的一种特殊用法。在一个构造方法中可以使用 this 关键字调用同一个类中的另一个构造方法。因此,前面 Circle 类的两个构造方法可以改写成:

  1. // 这是基本构造方法:初始化半径
  2. public Circle(double r) { this.r = r; }
  3. // 这个构造方法使用this()调用前一个构造方法
  4. public Circle() { this(1.0); }

如果一些构造方法共用大量的初始化代码,这种技术是有用的,因为能避免代码重复。如果构造方法执行很多初始化操作,在这种复杂的情况下,这种技术十分有用。

使用 this() 时有个重大的限制:只能出现在构造方法的第一个语句中。但是,调用这个方法后,可以执行构造方法所需的任何其他初始化操作。这个限制的原因涉及自动调用超类的构造方法,本章后面会说明。

3.3.4 属性的默认值和初始化程序

类中的属性不一定要初始化。如果没有指定初始值,属性自动使用默认值初始化:false\u000000.0null。具体使用哪个值,根据属性的类型而定(详情参见表 2-1)。这些默认值由 Java 语言规范规定,实例属性和类属性都适用。

如果属性的默认值不适合属性,可以显式提供其他的初始值。例如:

  1. public static final double PI = 3.14159;
  2. public double r = 1.0;
  1. 属性声明不是任何方法的一部分。Java 编译器会自动为属性生成初始化代码,然后把这些代码放在类的所有构造方法中。
  2. 这些初始化代码按照属性在源码中出现的顺序插入构造方法最开始之处,即调用new,首先调用构造方法,然后声明实例属性,最后在运行构造方法剩余的初始化操作。(可以通过单步调试观察过程)

例如下述代码片段是一个假设类,定义了一个构造方法和两个实例属性:

  1. public class SampleClass {
  2. public int len = 10;
  3. public int[] table = new int[len];
  4. public SampleClass() {
  5. for(int i = 0; i < len; i++) table[i] = i;
  6. }
  7. // 类余下的内容省略了……
  8. }

对这个例子来说,javac 生成的构造方法其实和下述代码等效:

  1. public SampleClass() {
  2. len = 10;
  3. table = new int[len];
  4. for(int i = 0; i < len; i++) table[i] = i;
  5. }

如果某个构造方法的开头使用 this() 调用其他构造方法,那么属性的初始化代码不会出现在这个构造方法中。此时,初始化由 this() 调用的构造方法处理。

3.3.5 类属性的初始化(选读)

既然实例属性在构造方法中初始化,那么类属性在哪初始化呢?就算从不创建类的实例,类属性也关联在类身上。这意味着,类属性要在调用构造方法之前初始化。

为此,javac 会为每个类自动生成一个类初始化方法。类属性在这个方法的主体中初始化。这个方法只在首次使用类之前调用一次(经常是在 Java 虚拟机首次加载类时)。

和实例属性的初始化一样,类属性的初始化表达式按照类属性在源码中的顺序插入类初始化方法。因此,类属性的初始化表达式可以使用在其之前声明的类属性。类初始化方法是内部方法,对 Java 程序员不可见。

初始化类属性——静态程序块

至此,我们知道对象可以通过属性的初始化表达式和构造方法中的任何代码初始化。类有一个类初始化方法,这个方法和构造方法不一样,不能像构造方法那样显式定义主体。不过,Java 允许编写用于初始化类属性的代码,所用的结构叫静态初始化程序。静态初始化程序由 static 关键字及随后的花括号中的代码块组成(不是方法,而是代码块)。

在类定义中,静态初始化程序可以放在属性和方法定义能出现的任何位置。例如,下述代码为两个类属性执行一些重要的初始化操作:

  1. // 我们可以使用三角函数画出圆的轮廓
  2. // 不过,三角函数很慢,所以预先算出一些值
  3. public class TrigCircle {
  4. // 这是静态查找表和各自的初始化程序
  5. private static final int NUMPTS = 500;
  6. private static double sines[] = new double[NUMPTS];
  7. private static double cosines[] = new double[NUMPTS];
  8. // 这是一个静态初始化程序,填充上述数组
  9. static {
  10. double x = 0.0;
  11. double delta_x = (Circle.PI/2)/(NUMPTS-1);
  12. for(int i = 0, x = 0.0; i < NUMPTS; i++, x += delta_x) {
  13. sines[i] = Math.sin(x);
  14. cosines[i] = Math.cos(x);
  15. }
  16. }
  17. // 类余下的内容省略了……
  18. }

一个类可以有任意多个静态初始化程序。各个初始化程序块的主体会和所有静态属性的初始化表达式一起合并到类初始化方法中。静态初始化程序和类方法的相同点是,不能使用 this 关键字,也不能使用类中的任何实例属性或实例方法。

3.4 子类和继承

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

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

3.4.1 扩展类(extends)

程序 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 类的属性和方法。

有多种方式能表达新对象类型具有 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类型的变量
  4. Circle c = pc;

PlaneCircle 对象赋值给 Circle 类型的变量时无需强制转换(理解为自动转换)。而,Circle 类型的变量 c 中保存的值仍然是有效的 PlaneCircle 对象,但编译器不确定这一点,因此无法反向(超类向子类)转换:

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

final:如果声明类时使用了 final 修饰符,那么这个类无法被扩展或定义子类。java.lang.Stringfinal 类的一个程序。把类声明为 final 可以避免不需要的类扩展。

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

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

  1. public class PlaneCircle extends Circle {
  2. ...
  3. }

你定义的每个类都有超类。如果没使用 extends 子句指定超类,那么默认超类java.lang.ObjectObject 是特殊的类,原因有如下两个:
- 它是 Java 中唯一一个没有超类的类;
- 所有 Java 类都从 Object 类中继承方法。

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

Object 类没有超类,而且其他每个类都只有一个超类。子类继承的超类不能超过一个。

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

图 3-1:类层次结构图

3.4.3 子类的构造方法(super)

再看一下程序 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() 调用构造方法有同样的限制:

  1. 只能在构造方法中像这样使用 super()
  2. 必须在构造方法的第一个语句中调用超类的构造方法,甚至要放在局部变量声明之前。
  3. 传给 super() 的实参必须与超类构造方法的形参匹配。如果超类定义了多个构造方法,那么 super() 可以调用其中任何一个,具体是哪个,由传入的参数决定。

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

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

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

PlaneCircle 类为例,创建这个类的新实例时会发生下述事情:
1. 首先,调用 PlaneCircle 类的构造方法;
2. 这个构造方法显示调用了 super(r),调用 Circle 类的一个构造方法;
3. Circle() 构造方法会隐式调用 super(),调用 Circle 的超类 Object 的构造方法(Object 只有一个构造方法);
4. 此时,到达层次结构的顶端了,接下来开始运行构造方法;
5. 首先运行 Object 构造方法的主体;
6. 返回后,再运行 Circle() 构造方法的主体;
7. 最后,对 super(r) 的调用返回后,接着执行 PlaneCircle() 构造方法中余下的语句。

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

调用构造方法时,超类中的属性也会被初始化。

默认构造方法

前面对构造方法链的说明漏了一点。如果构造方法没有调用超类的构造方法,Java 会隐式调用无参super()那么,如果类没有声明构造方法呢?此时,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。)

在你编写的代码中,为属性命名时应该避免遮盖超类的属性。如果遮盖了,几乎就表明代码写得不好。

这样定义 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属性

不能使用 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)

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

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

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

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

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

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

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. Java虚拟技术定位方法覆盖

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

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

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

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 句法。

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

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

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

3.5 数据隐藏和封装

本章开头说过,类由一些数据和方法组成。目前,我们尚未说明的最重要的面向对象技术之一是,把数据隐藏在类中,只能通过方法获取。这种技术叫作封装(encapsulation),因为它把数据(和内部方法)安全地密封在类这个“容器”中,只能由可信的用户(即这个类中的方法)访问。

为什么要这么做呢?最重要的原因是,隐藏类的内部实现细节

你应该始终封装自己的代码。如果没有封装好,那么几乎无法预测并最终确认代码是否正确,尤其是在多线程环境中(而基本上所有 Java 程序都运行在多线程环境中)。

使用封装的另一个原因是保护类,避免有意或无意做了糊涂事。类中经常包含一些相互依赖的属性,而且这些属性的状态必须始终如一。如果允许程序员(包括你自己)直接操作这些属性,修改某个属性后可能不会修改重要的相关属性,那么类的状态就前后不一致了。然而,如果必须调用方法才能修改属性,那么这个方法可以做一切所需的措施,确保状态一致。类似地,如果类中定义的某些方法仅供内部使用,隐藏这些方法能避免这个类的用户调用这些方法。

封装还可以这样理解:把类的数据都隐藏后,方法就是在这个类的对象上能执行的唯一一种可能的操作。

只要小心测试和调试方法,就可以认为类能按预期的方式运行。然而,如果类的所有属性都可以直接操作,那么要测试的可能性根本数不完。

这种想法可以得到一个非常重要的推论,5.5 节介绍 Java 程序的安全性时会说明(Java 程序的安全和 Java 编程语言的类型安全不是同一个概念)。

隐藏类的属性和方法还有一些次要的原因。

3.5.1 访问控制

Java 定义了一些访问控制规则,可以禁止类的成员在类外部使用。在本章的一些程序中,你已经见过属性和方法声明中使用的 public 修饰符。这个 public 关键字,连同 protectedprivate(还有一个特殊的),是访问控制修饰符,为属性或方法指定访问规则。

1. 访问包(了解)

Java 语言不直接支持包的访问控制。访问控制一般在类和类的成员这些层级完成。

已经加载的包始终可以被同一个包中的代码访问。一个包在其他包中是否能访问,取决于这个包在宿主系统中的部署方式。例如,如果组成包的类文件存储在一个目录中,那么用户必须能访问这个目录和其中的文件才能访问包。

2. 访问类

默认情况下,顶层类在定义它的包中可以访问。不过,如果顶层类声明为 public,那么在任何地方都能访问。

专题四会介绍嵌套类。嵌套类是定义为其他类的成员的类。因为这种内部类是某个类的成员,因此也遵守成员的访问控制规则。

3. 访问成员

类的成员在类的主体里始终可以访问。默认情况下,在定义这个类的包中也可以访问成员。这种默认的访问等级一般叫作包访问。这只是四个可用的访问等级中的一个。其他三个等级使用 publicprotectedprivate 修饰符定义。下面是使用这三个修饰符的示 例代码:

  1. public class Laundromat { // 所有人都可以使用这个类
  2. private Laundry[] dirty; // 只能在类内部使用,封装
  3. public void wash() { ... } // 可以使用这两个公开的方法
  4. public void dry() { ... } // 处理内部属性
  5. // 子类可能会想调整这个属性
  6. protected int temperature;
  7. }

下述访问规则适用于类的成员。

默认的访问规则比 protected 严格,因为默认规则不允许在包外部的子类中访问成员。

使用 protected 修饰的成员时要格外小心。假设 A 类使用 protected 声明了一个属性 x,而且在另一个包中定义的 B 类继承 A 类(重点是 B 类在另一包中定义)。因此,B 类继承了这个 protected 声明的属性 x,那么,在 B 类的代码中可以访问当前实例的这个属性,而且引用 B 类实例的代码也能访问这个属性。但是,这并不意味着在 B 类的代码中能读取任何一个 A 类实例的受保护属性。

下面通过代码讲解这个语言细节。A 类的定义如下:

  1. package javanut6.ch03;
  2. public class A {
  3. protected final String name;
  4. public A(String named) {
  5. name = named;
  6. }
  7. public String getName() {
  8. return name;
  9. }
  10. }

B 类的定义如下:

  1. package javanut6.ch03.different;
  2. import javanut6.ch03.A;
  3. public class B extends A {
  4. public B(String named) {
  5. super(named);
  6. }
  7. @Override
  8. public String getName() {
  9. return "B: " + name;
  10. }
  11. }

Java 的包不能“嵌套”,所以 javanut6.ch03.differentjavanut6.ch03 是不同的包。javanut6.ch03.different 不以任何方式包含在 javanut6.ch03 中,也和 javanut6.ch03 没有任何关系。

可是,如果我们试图把下面这个新方法添加到 B 类中,会导致编译出错,因为 B 类的实例无法访问任何一个 A 类的实例,只能是B的内部类访问A的成员

  1. public String examine(A a) {
  2. return "B sees: " + a.name;
  3. }

如果把这个方法改成:

  1. public String examine(B b) {
  2. return "B sees another B: " + b.name;
  3. }

就能编译通过,因为同一类型的多个实例可以访问各自的 protected 属性。当然,如果 B 类和 A 类在同一包中,那么任何一个 B 类的实例都能访问任何一个 A 类实例的全部受保护属性,因为使用 protected 声明的属性对同一个包中的每个类都可见。

4. 访问控制和继承

Java 规范规定:

不过,有些程序员会对“子类不继承超类中不可访问的属性和方法”感到困惑。这似乎暗示了,创建子类的实例时不会为超类中使用 private 声明的属性分配内存。然而,这不是上述规定想表述的。

其实,子类的每个实例都包含一个完整的超类实例,其中包括所有不可访问的属性和方法。

某些成员可能无法访问,这似乎和类的成员在类的主体中始终可以访问相矛盾。为了避免误解,我们要使用“继承的成员”表示那些可以访问的超类成员。

那么,关于成员访问性的正确表述应该是:“所有继承的成员和所有在类中定义的成员都是可以访问的。”这句话还可以换种方式说:

5. 成员访问规则总结

表 3-1 总结了成员的访问规则。

表3-1:类中成员的可访问性

下面是一些使用可见性修饰符的经验法则。

如果不确定该使用 protected、包还是 private 可见性,那么先使用 private。如果太过严格,可以稍微放松访问限制(如果是属性的话,还可以提供访问器方法)。

设计 API 时这么做尤其重要,因为提高访问限制是不向后兼容的改动,可能会破坏依赖成员访问性的代码。

3.5.2 数据访问器方法

Circle 类那个程序中,我们使用 public 声明表示圆半径的属性。Circle 类可能有很好的理由让这个属性可以公开访问;这个类很简单,属性之间不相互依赖。但是,当前实现的 Circle 类允许对象的半径为负数,而半径为负数的圆肯定不存在。可是,只要半径存储在声明为 public 的属性中,任何程序员都能把这个属性的值设为任何想要的值,而不管这个值有多么不合理。唯一的办法是限制程序员,不让他们直接访问这个属性,然后定义 public 方法,间接访问这个属性。提供 public 方法读写属性和把属性本身声明为 public 不是一回事。目前而言,二者的区别是,方法可以检查错误。

例如,我们或许不想让 Circle 对象的半径使用负数——负数显然不合理,但目前的实现没有阻止这么做。程序 3-4 展示了如何修改 Circle 类的定义,避免把半径设为负数。

Circle 类的这个版本使用 protected 声明 r 属性,还定义了访问器方法 getRadius()setRadius(),用于读写这个属性的值,而且限制半径不能为负数。r 属性使用 protected 声明,所以可以在子类中直接(且高效地)访问。

程序 3-4:使用数据隐藏和封装技术定义的 Circle

  1. package shapes; // 为这个类指定一个包
  2. public class Circle { // 这个类还使用public声明
  3. // 这是通用的常量,所以要保证声明为public
  4. public static final double PI = 3.14159;
  5. protected double r; // 半径被隐藏了,但在子类中可见
  6. // 限制半径取值的方法
  7. // 这是子类可能感兴趣的实现细节
  8. protected void checkRadius(double radius) {
  9. if (radius < 0.0)
  10. throw new IllegalArgumentException("radius may not be negative.");
  11. }
  12. // 非默认的构造方法
  13. public Circle(double r) {
  14. checkRadius(r);
  15. this.r = r;
  16. }
  17. // 公开的数据访问器方法
  18. public double getRadius() { return r; }
  19. public void setRadius(double r) {
  20. checkRadius(r);
  21. this.r = r;
  22. }
  23. // 操作实例属性的方法
  24. public double area() { return PI * r * r; }
  25. public double circumference() { return 2 * PI * r; }
  26. }

我们在一个名为 shapes 的包中定义 Circle 类。因为 r 属性使用 protected 声明,所以 shapes 包中的任何其他类都能直接访问这个属性,而且能把它设为任何值。这里假设 shapes 包中的所有类都由同一个作者或者协作的多个作者编写,而且包中的类相互信任,不会滥用拥有的访问权限影响彼此的实现细节。

最后,限制半径不能使用负数的代码在一个使用 protected 声明的方法中,这个方法是 checkRadius()。虽然 Circle 类的用户无法调用这个方法,但这个类的子类可以调用,而且如果想修改对半径的限制,还可以覆盖这个方法。

在 Java 中,数据访问器方法的命名有个通用约定,即以“get”和“set”开头。但是,如果要访问的属性是 boolean 类型,那么读取属性的方法使用的名称可能会以“is”开头。例如,名为 readableboolean 类型属性对应的访问器方法是 isReadable() 而不是 getReadable()

3.6 抽象类和方法

在程序 3-4 中,我们把 Circle 类声明为 shapes 包的一部分。假设我们计划实现多个表示形状的类:Rectangle, Square, Ellipse, Triangle 等。我们可以在这些表示形状的类中定义两个基本方法:area()circumference()。那么,为了能方便处理由形状组成的数组,这些表示形状的类最好有个共同的超类 Shape。这样组织类层次结构的话,每个形状对象,不管具体表示的是什么形状,都能赋予类型为 Shape 的变量、属性或数组元素。我们想在 Shape 类中封装所有形状共用的功能(例如, area()circumference() 方法)。但是,通用的 Shape 类不表示任何类型的形状,所以不能为这些方法定义有用的实现。Java 使用抽象方法解决这种问题。

Java 允许使用 abstract 修饰符声明方法,此时只定义方法但不实现方法。abstract 修饰的方法没有主体,只有一个签名和一个分号。2 以下是 abstract 方法和这些方法所在的 abstract 类相关的规则。

2Java 中的抽象方法和 C++ 中的纯虚拟函数(即声明为 = 0 的虚拟函数)有点像。在 C++ 中,包含纯虚拟函数的类是抽象类,不能实例化。包含抽象方法的 Java 类也一样不能实例化。

下面通过一个程序说明这些规则的运作方式。如果定义 Shape 类时把 area()circumference() 声明为 abstract 方法,那么 Shape 的子类必须实现这两个方法才能实例化。也就是说,每个 Shape 对象都要确保实现了这两个方法。程序 3-5 展示了如何编写代码。在这段代码中,定义了一个抽象的 Shape 类和两个具体子类。

程序 3-5:一个抽象类和两个具体子类

  1. public abstract class Shape {
  2. public abstract double area(); // 两个抽象方法
  3. public abstract double circumference(); //注意,没有主体,只有分号
  4. }
  5. class Circle extends Shape {
  6. public static final double PI = 3.14159265358979323846;
  7. protected double r; // 实例属性
  8. public Circle(double r) { this.r = r; } // 构造方法
  9. public double getRadius() { return r; } // 访问器
  10. public double area() { return PI*r*r; } // 实现超类中的
  11. public double circumference() { return 2*PI*r; } // 两个抽象方法
  12. }
  13. class Rectangle extends Shape {
  14. protected double w, h; // 实例属性
  15. public Rectangle(double w, double h) { // 构造方法
  16. this.w = w; this.h = h;
  17. }
  18. public double getWidth() { return w; } // 访问器方法
  19. public double getHeight() { return h; } // 另一个访问器
  20. public double area() { return w*h; } // 实现超类中的
  21. public double circumference() { return 2*(w + h); } // 两个抽象方法
  22. }

Shape 类中每个抽象方法的括号后面都是分号,没有花括号,也没定义方法的主体。使用程序 3-5 中定义的这几个类可以编写如下的代码:

  1. Shape[] shapes = new Shape[3]; // 创建一个保存形状的数组
  2. shapes[0] = new Circle(2.0); // 填充这个数组
  3. shapes[1] = new Rectangle(1.0, 3.0);
  4. shapes[2] = new Rectangle(4.0, 2.0);
  5. double totalArea = 0;
  6. for(int i = 0; i < shapes.length; i++)
  7. totalArea += shapes[i].area(); // 计算这些形状的面积

有两点要注意。

转换引用类型

对象可以在不同的引用类型之间转换。和基本类型一样,引用类型转换可以是放大转换(编译器自动完成),也可以是需要校正的缩小转换(或许运行时还要检查)。要想理解引用类型的转换,必须理解引用类型组成的层次结构,这个体系叫作类层次结构

每个 Java 引用类型都扩展其他类型,被扩展的类型是这个类型的超类。类型继承超类的属性和方法,然后定义属于自己的一些额外的属性和方法。在 Java 中,类层次结构的根是一个特殊的类,名为 Object。所有 Java 类都直接或间接地扩展 Object 类。Object 类定义了一些特殊的方法,所有对象都能继承(或覆盖)这些方法。

预定义的 String 类和本章前面定义的 Point 类都扩展 Object 类。因此,可以说,所有 String 对象也都是 Object 对象。也可以说,所有 Point 对象都是 Object 对象。但是,反过来说就不对了。我们不能说每个 Object 对象都是 String 对象,因为如前所示,有些 Object 对象是 Point 对象。

简单理解类层次结构之后,我们可以定义引用类型的转换规则了。

  1. // 把String对象放大转换成Object类型
  2. Object o = "string";
  3. // 程序后面再把这个Object对象缩小转换成String类型
  4. String s = (String) o;

数组是对象,而且有自己的一套转换规则。首先,任何数组都能放大转换成 Object 对象。带校正的缩小转换能把这个对象转换回数组。下面是一个程序:

  1. // 把数组放大转换成Object对象
  2. Object o = new int[] {1,2,3};
  3. // 程序后面……
  4. // 缩小转换回数组类型,但是不能转double类型
  5. int[] a = (int[]) o;

除了能把数组转换成对象之外,如果两个数组的“基类型”是可以相互转换的引用类型,那么数组还能转换成另一个类型的数组。例如:

  1. // 这是一个字符串数组
  2. String[] strings = new String[] { "hi", "there" };
  3. // 可以放大转换成CharSequence[]类型
  4. // 因为String类型可以放大转换成CharSequence类型
  5. CharSequence[] sequences = strings;
  6. // 缩小转换回String[]类型需要校正
  7. strings = (String[]) sequences;
  8. // 这是一个由字符串数组组成的数组
  9. String[][] s = new String[][] { strings };
  10. // 不能转换成CharSequence[]类型,因为String[]类型
  11. // 不能转换成CharSequence类型:维数不匹配
  12. sequences = s; // 不会编译这行代码
  13. // s可以转换成Object类型或Object[]类型,因为所有数组类型
  14. // (包括String[]和String[][]类型)都能转换成Object类型
  15. Object[] objects = s;

注意,这些数组转换规则只适用于由对象或数组组成的数组。基本类型的数组不能转换为任何其他数组类型,就算基本基类型之间能相互转换也不行:

  1. // 就算int类型能放大转换成double类型
  2. // 也不能把int[]类型转换成double[]类型
  3. // 这行代码会导致编译出错
  4. double[] data = new int[] {1,2,3};
  5. // 但是,这行代码是合法的,因为int[]类型能转换成Object类型
  6. Object[] objects = new int[][] {{1,2},{3,4}};

3.7 修饰符总结

如前所示,类、接口和它们的成员都能使用一个或多个修饰符声明——这些修饰符是 publicstaticfinal 等关键字。下面对本章做个总结,列出所有 Java 修饰符,说明各自能修饰的 Java 结构种类和作用。详情如表 3-2 所示。还可以参阅 3.1 节、3.2.1 节和 2.6.2 节。

表3-2:Java修饰符

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注