面向-Android-开发的-Java-学习手册-二-

65 阅读1小时+

面向 Android 开发的 Java 学习手册(二)

原文:Learn Java for Android development

协议:CC BY-NC-SA 4.0

四、探索继承、多态和接口

基于对象的语言是一种封装了对象的状态和行为的语言。Java 对封装的支持(在第三章中讨论)使它成为一种基于对象的语言。然而,Java 也是一种面向对象语言,因为它支持继承和多态(以及封装)。(面向对象语言是基于对象语言的子集。)在第四章中,我将向你介绍 Java 支持继承和多态的语言特性。此外,我还将向您介绍接口,这是 Java 的终极抽象类型机制。

构建类层次结构

我们倾向于用“汽车是交通工具”或“储蓄账户是银行账户”这样的话来对事物进行分类通过做出这些陈述,我们实际上是在说(从软件开发的角度来看)汽车继承了车辆状态(例如,品牌和颜色)和行为(例如,停放和显示里程),储蓄账户继承了银行账户状态(例如,余额)和行为(例如,存款和取款)。汽车、车辆、储蓄账户和银行账户是真实世界实体类别的示例,而继承是相似实体类别之间的层次关系,其中一个类别从至少一个其他实体类别继承状态和行为。从单一类别继承是单一继承,从至少两个类别继承是多重继承

Java 支持单继承和多继承以方便代码重用——为什么要多此一举?Java 支持类上下文中的单一继承,其中一个类通过类扩展从另一个类继承状态和行为。因为涉及到类,Java 把这种继承称为实现继承

Java 还支持接口上下文中的单继承和多继承,在接口上下文中,类通过接口实现从一个或多个接口继承行为模板,或者接口通过接口扩展从一个或多个接口继承行为模板。因为涉及到接口,Java 把这种继承称为接口继承。(我将在本章后面讨论接口。)

注意你通过小心地扩展类,实现接口,扩展接口来重用代码。你从接近你想要的东西开始,延伸它以达到你的目标。你不能通过简单的复制和粘贴来重用代码。复制和粘贴通常会导致冗余(即不可重用)和错误代码。

在这一节中,我首先通过关注类扩展向您介绍 Java 对实现继承的支持。然后我向您介绍一个特殊的类,它位于 Java 类层次结构的顶端。在向您介绍了组合(重用代码的实现继承的替代方法)之后,我将向您展示如何使用组合来克服实现继承的问题。

扩展类

Java 提供了保留字扩展,用于指定两个类之间的层次关系。例如,假设您有一个车辆类,并想引入一个汽车类作为一种车辆。清单 4-1 使用扩展来巩固这种关系。

清单 4-1 。通过扩展关联两个类

class Vehicle
{
   // member declarations
}

class Car extends Vehicle
{
   // member declarations
}

清单 4-1 编纂了一个被称为“是-是”关系的关系:汽车是一种交通工具。在这个关系中, Vehicle 被称为基类父类超类;而汽车又被称为的派生类的子类,或者的子类

注意你不能扩展一个最终类。例如,如果您将车辆声明为最终类车辆,编译器会在遇到类车辆扩展车辆时报告一个错误。当开发人员不希望这些类被扩展(出于安全或其他原因)时,他们会将自己的类声明为 final。

除了能够提供自己的成员声明, Car 还能够从它的 Vehicle 超类继承成员声明。如清单 4-2 所示,非私有继承的成员可以被 Car 类的成员访问。

清单 4-2 。继承成员

class Vehicle
{
   private String make;
   private String model;
   private int year;

   Vehicle(String make, String model, int year)
   {
      this.make = make;
      this.model = model;
      this.year = year;
   }

   String getMake()
   {
      return make;
   }

   String getModel()
   {
      return model;
   }

   int getYear()
   {
      return year;
   }
}

public class Car extends Vehicle
{
   private int numWheels;

  Car(String make, String model, int year, int numWheels)
   {
      super(make, model, year);
      this.numWheels = numWheels;
   }

   public static void main(String[] args)
   {
      Car car = new Car("Ford", "Fiesta", 2009, 4);
      System.out.println("Make = " + car.getMake());
      System.out.println("Model = " + car.getModel());
      System.out.println("Year = " + car.getYear());
      // Normally, you cannot access a private field via an object
      // reference. However, numWheels is being accessed from
      // within a method (main()) that is part of the Car class.
      System.out.println("Number of wheels = " + car.numWheels);
   }
}

清单 4-2 的车辆类声明了私有字段,用于存储车辆的品牌、型号和年份;将这些字段初始化为传递的参数的构造函数;和 getter 方法来检索这些字段的值。

Car 子类提供了一个私有的 numWheels 字段,一个初始化 Car 对象的 Vehicle 和 Car 层的构造函数,以及一个用于测试该类的 main() 类方法。

汽车的构造函数使用保留字超级用面向汽车的参数调用汽车的构造函数,然后初始化汽车的 numWheels 实例字段。 super() 调用类似于指定 this() 调用同一个类中的另一个构造函数,但是调用的是超类构造函数。

注意super()调用只能出现在构造函数中。此外,它必须是构造函数中指定的第一个代码。如果没有指定 super() ,并且超类没有无参数构造函数,编译器会报错,因为当 super() 不存在时,子类构造函数必须调用无参数超类构造函数。

Car 的 main() 方法创建一个 Car 对象,将该对象初始化为特定的品牌、型号、年份和车轮数量。四个 System.out.println() 方法调用随后输出该信息。

前三个 System.out.println() 方法调用通过调用 Car 实例继承的 getMake() 、 getModel() 和 getYear() 方法来检索它们的信息片段。最后的 System.out.println() 方法调用直接访问实例的 numWheels 字段。尽管直接访问一个实例字段通常不是一个好主意(这样做违反了信息隐藏),但是提供这种访问的 Car 的 main() 方法只是为了测试这个类,并不存在于使用这个类的实际应用中。

因为汽车被声明为公共类,清单 4-2 将被存储在一个名为 Car.java 的文件中。因此,执行 javac Car.java 将该源代码编译成 Vehicle.class 和 Car.class 。然后执行 java Car 来测试 Car 类。该执行会产生以下输出:

Make = Ford
Model = Fiesta
Year = 2009
Number of wheels = 4

注意实例不能被修改的类被称为不可变类。车就是一个例子。如果 Car 的 main() 方法(可以直接读取或写入 numWheels )不存在, Car 也将是不可变类的一个例子。同样,一个类不能继承构造函数,也不能继承私有的字段和方法。例如,汽车不继承车辆的构造者,也不继承车辆的私有制造、型号、年份字段。

子类可以覆盖(替换)一个继承的方法,这样子类的方法版本被调用。清单 4-3 向您展示了覆盖方法必须指定与被覆盖方法相同的名称、参数列表和返回类型。

清单 4-3 。重写方法

class Vehicle
{
   private String make;
   private String model;
   private int year;

   Vehicle(String make, String model, int year)
   {
      this.make = make;
      this.model = model;
      this.year = year;
   }

   void describe()
   {
      System.out.println(year + " " + make + " " + model);
   }
}

public class Car extends Vehicle
{
   private int numWheels;

   Car(String make, String model, int year, int numWheels)
   {
      super(make, model, year);
   }

   void describe()
   {
      System.out.print("This car is a "); // Print without newline – see Chapter 1.
      super.describe();
   }

   public static void main(String[] args)
   {
      Car car = new Car("Ford", "Fiesta", 2009, 4);
      car.describe();
   }
}

清单 4-3 的汽车类声明了一个 describe() 方法 ,该方法覆盖了汽车的 describe() 方法,以输出一个面向汽车的描述。该方法使用保留字 super 通过 super.describe()调用车辆的 describe() 方法;。

注意通过在方法名前加上保留字 super 和成员访问操作符,从覆盖子类方法中调用超类方法。如果不这样做,最终会递归调用子类的覆盖方法。使用超级和成员访问操作符从子类中访问非私有超类字段,通过声明同名字段来屏蔽这些字段。

如果您要编译清单 4-3 ( 贾瓦克 Car.java)并运行汽车应用( java 汽车),您会发现汽车的覆盖 describe() 方法代替汽车的覆盖 describe() 方法执行,并输出这辆汽车是 2009 款福特嘉年华。

注意你不能覆盖一个最终方法。例如,如果 Vehicle 的 describe() 方法被声明为 final void describe() ,当遇到试图在 Car 类中覆盖该方法时,编译器会报告一个错误。当开发人员不希望这些方法被覆盖(出于安全或其他原因)时,他们将方法声明为 final 。此外,您不能使重写方法的可访问性低于它所重写的方法。例如,如果 Car 的 describe() 方法被声明为 private void describe() ,编译器会报告一个错误,因为私有访问比默认的包访问更难访问。然而, describe() 可以通过将它声明为 public 而变得更容易访问,比如在 public void describe() 中。

假设您碰巧用下面显示的方法替换了清单 4-3 中的的 describe() 方法:

void describe(String owner)
{
   System.out.print("This car, which is owned by " + owner + ", is a ");
   super.describe();
}

修改后的 Car 类现在有两个 describe() 方法,前面明确声明的方法和从 Vehicle 继承的方法。void describe(String owner)方法不会覆盖 Vehicle 的 describe() 方法。相反,它重载此方法。

Java 编译器通过让您在子类的方法头前加上 @Override 注释来帮助您检测在编译时重载而不是覆盖方法的企图,如以下代码所示(我将在第六章的中讨论注释):

@Override
void describe()
{
   System.out.print("This car is a ");
   super.describe();
}

指定 @Override 告诉编译器这个方法覆盖了另一个方法。如果改为重载方法,编译器会报告错误。如果没有这个注释,编译器不会报告错误,因为方法重载是一个有效的特性。

提示养成用 @Override 注释作为覆盖方法前缀的习惯。这个习惯将帮助你更快地发现重载错误。

在第三章中,我讨论了类和对象的初始化顺序,在这里你学到了类成员总是首先被初始化,并且是自顶向下的顺序(同样的顺序也适用于实例成员)。实现继承增加了几个细节:

  • 超类的类初始化器总是在子类的类初始化器之前执行。
  • 子类的构造函数总是调用超类构造函数来初始化对象的超类层,然后初始化子类层。

Java 对实现继承的支持只允许你扩展一个类。您不能扩展多个类,因为这样做会导致问题。例如,假设 Java 支持多重实现继承,你决定通过清单 4-4 所示的类结构来建模一匹飞马(来自希腊神话)。

清单 4-4 。多重实现继承的虚拟演示

class Bird
{
   void describe()
   {
      // code that outputs a description of a bird's appearance and behaviors
   }
}

class Horse
{
   void describe()
   {
      // code that outputs a description of a horse's appearance and behaviors
   }
}

public class FlyingHorse extends Bird, Horse
{
   public static void main(String[] args)
   {
      FlyingHorse pegasus = new FlyingHorse();
      pegasus.describe();
   }
}

清单 4-4 的类结构揭示了由于鸟和马声明了一个描述()方法而产生的歧义。 FlyingHorse 继承了这些方法中的哪一个?一个相关的歧义来自于同名字段,可能是不同的类型。哪个字段是继承的?

终极超类

一个没有显式扩展另一个类的类隐式扩展了 Java 的对象类(位于 java.lang 包中——我会在下一章讨论包)。例如,清单 4-1 的车辆类扩展了对象,而车辆扩展了车辆。

对象是 Java 的终极超类,因为它是所有其他类的祖先,但它本身并不扩展任何其他类。对象提供了一组其他类继承的公共方法。表 4-1 描述了这些方法。

表 4-1 。对象的方法

方法描述
对象克隆()创建并返回当前对象的副本。
布尔等于(对象对象)确定当前对象是否等于由 obj 标识的对象。
void finalize()完成当前对象。
阶级<?> getClass()返回当前对象的类对象。
int hashCode()返回当前对象的哈希代码。
作废通知()唤醒一个正在等待当前对象的监视器的线程。
见通知 All()唤醒所有等待当前对象监视器的线程。
字符串 toString()返回当前对象的字符串表示形式。
void wait()使当前线程等待当前对象的监视器,直到它通过 notify() 或 notifyAll() 被唤醒。
无效等待(长超时)使当前线程在当前对象的监视器上等待,直到它通过 notify() 或 notifyAll() 被唤醒,或者直到指定的超时值(以毫秒为单位)已经过去,以先到者为准。
void wait(长超时,int nanos)使当前线程在当前对象的监视器上等待,直到它通过 notify() 或 notifyAll() 被唤醒,或者直到指定的超时值(以毫秒为单位)加上毫微秒值(以纳秒为单位)已经过去,以先到者为准。

我稍后将讨论 clone() 、 equals() 、 finalize() 、 hashCode() 和 toString() 方法,但是将 notify() 、 notifyAll() 和 wait() 方法的讨论推迟到第八章进行。

克隆

clone() 方法克隆(复制)一个对象而不调用构造函数。它将每个原语或引用字段的值复制到它在克隆中的对应物,这个任务被称为浅复制浅克隆 。清单 4-5 展示了这种行为。

清单 4-5 。浅克隆一个雇员对象

public class Employee implements Cloneable
{
   String name;
   int age;

   Employee(String name, int age)
   {
      this.name = name;
      this.age = age;
   }

   public static void main(String[] args) throws CloneNotSupportedException
   {
      Employee e1 = new Employee("John Doe", 46);
      Employee e2 = (Employee) e1.clone();
      System.out.println(e1 == e2); // Output: false
      System.out.println(e1.name == e2.name); // Output: true
   }
}

清单 4-5 声明了一个雇员类,带有姓名和年龄实例字段以及一个用于初始化这些字段的构造函数。 main() 方法使用此构造函数将一个新的 Employee 对象的这些字段的副本初始化为 John Doe 和 46 。

注意一个类必须实现 java.lang.Cloneable 接口,否则它的实例不能通过 Object 的 clone() 方法进行浅层克隆——该方法执行运行时检查,查看该类是否实现了 Cloneable 。(我将在本章后面讨论接口。)如果一个类没有实现 Cloneable , clone() 抛出 Java . lang . clonenotsupportedexception。(因为 CloneNotSupportedException 是一个被检查的异常,所以清单 4-5 需要通过将 throws CloneNotSupportedException 附加到 main() 方法的头来满足编译器的要求。我将在下一章讨论异常。)java.lang.String 类就是一个没有实现 Cloneable 的类的例子;因此,字符串对象不能被浅克隆。

将 Employee 对象的引用赋给局部变量 e1 后, main() 调用该变量上的 clone() 方法复制该对象,然后将结果引用赋给变量 e2 。因为 clone() 返回对象,所以需要 (Employee) 转换。

为了证明引用被分配给 e1 和 e2 的对象是不同的, main() 接下来通过 == 比较这些引用,并输出布尔结果,结果恰好为假。为了证明 Employee 对象是浅克隆的, main() 接下来通过 == 比较两个 Employee 对象的 name 字段中的引用,并输出布尔结果,结果恰好为真。

注意 对象的 clone() 方法最初被指定为 public 方法,这意味着可以从任何地方克隆任何对象。出于安全原因,这个访问后来被更改为 protected ,这意味着只有与要调用其 clone() 方法的类在同一个包中的代码,或者这个类的子类中的代码(不考虑包),才能调用 clone() 。

浅层克隆并不总是可取的,因为原始对象及其克隆通过它们的等效引用字段引用同一个对象。例如,清单 4-5 的两个雇员对象中的每一个都通过其名称字段引用同一个字符串对象。

虽然对于实例不可变的字符串来说不是问题,但是通过克隆的引用字段改变可变对象会导致原始(非克隆)对象通过其引用字段看到相同的改变。例如,假设您向雇员添加了一个名为雇佣日期的引用字段。该字段的类型为日期,具有年、月和日实例字段。因为日期是可变的,所以您可以在分配给雇佣日期的日期实例中更改这些字段的内容。

现在,假设您计划更改克隆的日期,但希望保留原始的雇员对象的日期。使用浅层克隆无法做到这一点,因为原始的雇员对象也可以看到这一变化。要解决这个问题,您必须修改克隆操作,以便它为雇员克隆的雇佣日期字段分配一个新的日期引用。这个任务被称为深度复制深度克隆 ,在清单 4-6 中演示。

清单 4-6 。深度克隆雇员对象

class Date
{
   int year, month, day;

   Date(int year, int month, int day)
   {
      this.year = year;
      this.month = month;
      this.day = day;
   }
}

public class Employee implements Cloneable
{
   String name;
   int age;
   Date hireDate;

   Employee(String name, int age, Date hireDate)
   {
      this.name = name;
      this.age = age;
      this.hireDate = hireDate;
   }

   @Override
   protected Object clone() throws CloneNotSupportedException
   {
      Employee emp = (Employee) super.clone();
      if (hireDate != null) // no point cloning a null object (one that doesn't exist)
         emp.hireDate = new Date(hireDate.year, hireDate.month, hireDate.day);
      return emp;
   }

   public static void main(String[] args) throws CloneNotSupportedException
   {
      Employee e1 = new Employee("John Doe", 46, new Date(2000, 1, 20));
      Employee e2 = (Employee) e1.clone();
      System.out.println(e1 == e2); // Output: false
      System.out.println(e1.name == e2.name); // Output: true
      System.out.println(e1.hireDate == e2.hireDate); // Output: false
      System.out.println(e2.hireDate.year + " " + e2.hireDate.month + " " +
                         e2.hireDate.day); // Output: 2000 1 20
   }
}

清单 4-6 声明日期和员工班次。 Date 类声明了 year 、 month 和 day 字段以及一个构造函数。(您可以在一行中声明一个逗号分隔的变量列表,前提是这些变量共享相同的类型,在本例中是 int 。)

Employee 覆盖 clone() 方法来深度克隆 hireDate 字段。该方法首先调用对象的 clone() 方法来浅克隆当前 Employee 对象的实例字段,然后将新对象的引用存储在 emp 中。假设 hireDate 不包含空引用,它接下来将新的 Date 对象的引用分配给 emp 的 hireDate 字段;该对象的字段被初始化为与原始 Employee 对象的 hireDate 实例相同的值。

此时,您有了一个雇员克隆,它具有浅克隆的姓名和年龄字段以及深克隆的雇佣日期字段。 clone() 方法通过返回这个雇员克隆来结束。

注意如果你没有从一个被覆盖的 clone() 方法中调用对象的 clone() 方法(因为你更喜欢深度克隆引用字段并自己对非引用字段进行浅层复制),那么包含被覆盖的 clone() 方法的类就没有必要实现可克隆的,但是为了一致性,它应该实现这个接口。字符串没有覆盖 clone() ,所以字符串对象不能被深度克隆。

相等

== 和!= 运算符比较两个原始值(如整数)是否相等( == )或不相等()!= )。这些操作符还比较两个引用,看它们是否引用同一个对象。后一种比较被称为 身份检查

不能使用 == 和!= 判断两个对象在逻辑上是否相同(或不相同)。例如,具有相同字段值的两个汽车对象在逻辑上是等价的。但是, == 报告它们不相等,因为它们的引用不同。

因为 == 和!= 尽可能快地执行比较,因为需要快速执行字符串比较(尤其是在对大量字符串进行排序时),所以 String 类包含特殊支持,允许通过 == 和比较文字字符串和字符串值常量表达式!= 。(我将在第七章的中介绍字符串时讨论这种支持。)以下语句演示了这些比较:

System.out.println("abc" == "abc"); // Output: true
System.out.println("abc" == "a" + "bc"); // Output: true
System.out.println("abc" == "Abc"); // Output: false
System.out.println("abc" != "def"); // Output: true
System.out.println("abc" == new String("abc")); // Output: false

认识到除了引用相等还需要支持逻辑相等,Java 在对象类中提供了一个 equals() 方法。因为这个方法默认比较引用,所以您需要覆盖 equals() 来比较对象内容。

在覆盖 equals() 之前,确保这是必要的。例如,Java 的 java.lang.StringBuffer 类不会覆盖 equals() 。也许这个类的设计者认为没有必要确定两个 StringBuffer 对象在逻辑上是否等价。

您不能用任意代码覆盖 equals() 。这样做可能会给应用带来灾难性的后果。相反,您需要遵守 Java 文档中为该方法指定的契约,这将在下面介绍。

equals() 方法实现了非空对象引用的等价关系:

  • 它是自反的:对于任何非空的参考值 xx 。equals( x ) 返回 true。
  • 对称:对于任意非空参考值 xyx 。equals(y)返回 true 当且仅当 y 。equals(x)返回 true。
  • 是过渡的:对于任意非空参考值 xyz ,if x 。equals(y)返回 true, y 。equals(z)返回 true,则 x 。equals(z)返回 true。
  • 一致:对于任意非空参考值 xy ,多次调用x。equals(y)始终返回 true 或始终返回 false,前提是在对象的 equals() 比较中使用的信息没有被修改。
  • 对于任意非空参考值 xx 、。equals(null) 返回 false。

尽管这份合同看起来有点吓人,但满足它并不难。为了证明,看看清单 4-7 的点类中 equals() 方法的实现。

清单 4-7 。逻辑上比较点对象

public class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX()
   {
      return x;
   }

   int getY()
   {
      return y;
   }

   @Override
   public boolean equals(Object o)
   {
      if (!(o instanceof Point))
         return false;
      Point p = (Point) o;
      return p.x == x && p.y == y;
   }

   public static void main(String[] args)
   {
      Point p1 = new Point(10, 20);
      Point p2 = new Point(20, 30);
      Point p3 = new Point(10, 20);
      // Test reflexivity
      System.out.println(p1.equals(p1)); // Output: true
      // Test symmetry
      System.out.println(p1.equals(p2)); // Output: false
      System.out.println(p2.equals(p1)); // Output: false
      // Test transitivity
      System.out.println(p2.equals(p3)); // Output: false
      System.out.println(p1.equals(p3)); // Output: true
      // Test nullability
      System.out.println(p1.equals(null)); // Output: false
      // Extra test to further prove the instanceof operator's usefulness.
      System.out.println(p1.equals("abc")); // Output: false
   }
}

清单 4-7 的覆盖 equals() 方法以 if 语句开始,该语句使用操作符的实例来确定传递给参数 o 的变量是否是 Point 类的实例。如果不是,If 语句执行返回 false。

点表达式的 o 实例满足契约的最后一部分:对于任意非空参考值 xx 。equals(null) 返回 false。因为空引用不是任何类的实例,所以将该值传递给 equals() 会导致表达式计算为 false。

Point 表达式的 o instance 还可以防止在向 equals() 传递除了 Point 对象之外的对象时,通过表达式 (Point) o 抛出 Java . lang . classcastexception 实例。(我将在下一章讨论异常。)

在转换之后,通过表达式 p.x == x & & p.y == y ,仅允许点与其他点进行比较,从而满足契约的自反性、对称性和传递性要求。

通过确保 equals() 方法是确定性的,满足了最终的契约要求,即一致性。换句话说,这个方法不依赖于任何可能随方法调用而改变的字段值。

提示通过首先使用 == 来确定 o 的引用是否标识当前对象,可以优化耗时的 equals() 方法的性能。只需指定 if (o == this)返回 true 作为 equals() 方法的第一条语句。这种优化在清单 4-7 的 equals() 方法中是不必要的,该方法具有令人满意的性能。

在重写等于()时,务必重写 hashCode() 方法 。在清单 4-7 中我没有这样做,因为我还没有正式引入 hashCode() 。

最终确定

终结是指通过 finalize() 方法进行清理,该方法被称为终结器。 finalize() 方法的 Java 文档声明 finalize() 是“当垃圾收集器确定不再有对对象的引用时,由垃圾收集器在对象上调用的”。一个子类覆盖了 finalize() 方法来释放系统资源或执行其他清理。

对象的版本 finalize() 什么都不做;您必须用任何需要的清理代码重写此方法。因为在应用终止之前,虚拟机可能永远不会调用 finalize() ,所以您应该提供一个显式的清理方法,并让 finalize() 调用这个方法,作为安全网,以防这个方法没有被调用。

注意永远不要依赖 finalize() 来释放有限的资源,比如文件描述符。例如,如果一个应用对象打开文件,期望它的 finalize() 方法将关闭它们,当一个缓慢的虚拟机调用 finalize() 很慢时,应用可能发现自己无法打开额外的文件。让这个问题变得更糟的是, finalize() 可能会在另一个虚拟机上被更频繁地调用,导致这个太多打开文件的问题没有暴露出来。开发人员可能会错误地认为应用在不同的虚拟机上表现一致。

如果你决定覆盖 finalize() ,你的对象的子类层必须给它的超类层一个执行终结的机会。您可以通过指定 super.finalize()来完成这项任务;作为方法中的最后一条语句,如下例所示:

protected void finalize() throws Throwable
{
   try
   {
      // Perform subclass cleanup.
   }
   finally
   {
      super.finalize();
   }
}

该示例的 finalize() 声明将 throws Throwable 附加到方法头,因为清理代码可能会抛出异常。如果抛出异常,执行离开方法,在没有 try-finally 的情况下,super . finalize();从不执行。(我将在第五章的中讨论异常并最终尝试。)

为了防止这种可能性,子类的清理代码在保留字 try 后面的块中执行。如果抛出异常,Java 的异常处理逻辑会执行跟在 finally 保留字和 super.finalize()后面的块;执行超类的 finalize() 方法。

注意finalize()方法经常被用来执行复活(使一个未被引用的对象被引用)以实现对象池,当这些对象创建起来很昂贵(时间方面)时,这些对象池回收相同的对象(数据库连接对象就是一个例子)。

当您将此(对当前对象的引用)赋给类或实例字段(或另一个长期变量)时,就会发生复活。例如,您可以指定 r = this;在 finalize() 内将标识为 this 的未引用对象分配给名为 r 的类字段。

由于复活的可能性,对于覆盖了 finalize() 的对象的垃圾收集会有严重的性能损失。

不能再次调用复活的对象的终结器。

散列码

hashCode() 方法返回一个 32 位整数,标识当前对象的散列码,一个对潜在的大量数据应用数学函数得到的小值。这个值的计算被称为哈希

当覆盖 equals() 时,您必须覆盖 hashCode() ,并且根据下面的契约,在 hashCode() 的 Java 文档中指定:

  • 在 Java 应用的执行过程中,只要对同一对象多次调用, hashCode() 方法必须始终返回相同的整数,前提是在对象的 equals(Object) 比较中使用的信息没有被修改。这个整数不需要从一个应用的一次执行到同一应用的另一次执行保持一致。
  • 如果根据 equals(Object) 方法,两个对象相等,那么在这两个对象上调用 hashCode() 方法必须产生相同的整数结果。
  • 根据 equals(Object) 方法,如果两个对象不相等,那么在这两个对象上调用 hashCode() 方法必须产生不同的整数结果。但是,程序员应该知道,为不相等的对象生成不同的整数结果可能会提高哈希表的性能。

不遵守这个契约,你的类的实例将不能与 Java 的基于散列的集合框架类一起正常工作,比如 java.util.HashMap 。(我会在第九章讨论 HashMap 和其他集合框架类。)

如果你覆盖了 equals() 而没有覆盖 hashCode() ,那么最重要的是违反了契约中的第二条:相等对象的 hash 码也必须相等。这种违反可能会导致严重的后果,如下例所示:

java.util.Map<Point, String> map = new java.util.HashMap<Point, String>();
map.put(p1, "first point");
System.out.println(map.get(p1)); // Output: first point
System.out.println(map.get(new Point(10, 20))); // Output: null

假设这个例子的语句被附加到清单 4-7 的 main() 方法中——Java . util .前缀和 < Point,String > 与包和泛型有关,我将在第五章和第六章中讨论。

在 main() 创建其 Point 对象并调用其 System.out.println() 方法后,它执行该示例的语句,这些语句执行以下任务:

  • 第一条语句实例化了 HashMap ,它位于 java.util 包中。
  • 第二条语句调用 HashMap 的 put() 方法来存储清单 4-7 的 p1 对象键和 HashMap 中的“第一点”值。
  • 第三条语句通过 hashmap 的 get() 方法检索其 Point key 逻辑上等于 p1 的 HashMap 条目的值。
  • 第四条语句相当于第三条语句,但返回空引用,而不是“第一点”。

虽然对象 p1 和 Point(10,20) 在逻辑上是等价的,但是这些对象具有不同的哈希代码,导致每个对象引用哈希表中不同的条目。如果一个对象没有存储(通过 put() )在那个条目中, get() 返回 null。

纠正这个问题需要覆盖 hashCode() 来为逻辑上等价的对象返回相同的整数值。当我在第九章的中讨论散列表时,我会告诉你如何完成这个任务。

字符串表示

方法返回当前对象的基于字符串的表示。这种表示默认为对象的类名,后面跟有 @ 符号,后面跟有对象散列码的十六进制表示。

例如,如果您要执行 system . out . println(P1);输出清单 4-7 的 p1 对象,你会看到一行类似于 Point@3e25a5 的输出。( System.out.println() 在后台调用 p1 继承的 toString() 方法。)

您应该努力覆盖 toString() 以便它返回一个简洁但有意义的对象描述。例如,你可以在清单 4-7 的点类中声明一个 toString() 方法,如下所示:

@Override
public String toString()
{
   return "(" + x + ", " + y + ")";
}

这次执行 system . out . println(P1);产生更有意义的输出,比如 (10,20) 。

构图

实现继承和组合提供了两种不同的重用代码的方法。如您所知,实现继承涉及用新类扩展一个类,这是基于它们之间的“是-a”关系:例如,一辆汽车是一辆汽车。

另一方面, composition 关注于从其他类中合成类,这是基于它们之间的“has-a”关系。例如,汽车有一个发动机、车轮 s 和一个转向轮。

在这一章中你已经看到了作文的例子。例如,清单 4-2 的汽车类包括串制造和串型号字段。清单 4-8 的 Car 类提供了另一个组合的例子。

清单 4-8 。一个汽车类,它的实例由其他对象组成

class Car extends Vehicle
{
   private Engine engine; // bicycles don't have engines
   private Wheel[] wheels; // boats don't have wheels
   private SteeringWheel steeringWheel; // hang gliders don't have steering wheels
}

清单 4-8 展示了组合和实现继承并不互相排斥。虽然没有显示,汽车继承了其汽车超类的各种成员,此外还提供了自己的引擎、车轮和方向盘字段。

实现继承的问题是

实现继承有潜在的危险,尤其是当开发人员对超类没有完全的控制权,或者超类没有考虑到扩展而设计和记录的时候。

问题是实现继承破坏了封装。子类依赖于超类中的实现细节。如果这些细节在超类的新版本中发生变化,子类可能会被破坏,即使子类没有被改变。

例如,假设您已经购买了一个 Java 类库,其中一个类描述了一个约会日历。虽然您没有访问这个类的源代码的权限,但是假设清单 4-9 描述了它的部分代码。

清单 4-9 。约会日历类

public class ApptCalendar
{
   private final static int MAX_APPT = 1000;
   private Appt[] appts;
   private int size;

   public ApptCalendar()
   {
      appts = new Appt[MAX_APPT];
      size = 0; // redundant because field automatically initialized to 0
                // adds clarity, however
   }

   public void addAppt(Appt appt)
   {
      if (size == appts.length)
         return; // array is full
      appts[size++] = appt;
   }

   public void addAppts(Appt[] appts)
   {
      for (int i = 0; i < appts.length; i++)
         addAppt(appts[i]);
   }
}

清单 4-9 的 ApptCalendar 类存储了一个约会数组,每个约会由一个 Appt 实例描述。对于这个讨论来说, Appt 的细节无关紧要。它可能像类 Appt {} 一样微不足道。

假设您想在一个文件中记录每个约会。因为没有提供日志记录功能,所以您用清单 4-10 的 LoggingApptCalendar 类扩展了 ApptCalendar ,该类在重写 addAppt() 和 add apput()方法时添加了日志记录行为。

清单 4-10 。扩展约会日历类

public class LoggingApptCalendar extends ApptCalendar
{
   // A constructor is not necessary because the Java compiler will add a
   // noargument constructor that calls the superclass's noargument
   // constructor by default.

   @Override
   public void addAppt(Appt appt)
   {
      Logger.log(appt.toString());
      super.addAppt(appt);
   }

   @Override
   public void addAppts(Appt[] appts)
   {
      for (int i = 0; i < appts.length; i++)
         Logger.log(appts[i].toString());
      super.addAppts(appts);
   }
}

清单 4-10 的 LoggingApptCalendar 类依赖于一个 Logger 类,它的 void log(String msg) 类方法将一个字符串记录到一个文件中(细节并不重要)。请注意使用 toString() 将 Appt 对象转换为 String 对象,然后将其传递给 log() 。

虽然这个类看起来还可以,但它并不像您预期的那样工作。假设您实例化了这个类,并通过 add apput()向该实例添加了几个 Appt 实例,如下所示:

LoggingApptCalendar lapptc = new LoggingApptCalendar();
lapptc.addAppts(new Appt[] { new Appt(), new Appt(), new Appt() });

如果还加一个 system . out . println(msg);方法调用记录器的 log(String msg) 方法,输出这个方法的参数,你会发现 log() 一共输出了六条消息;预期的三条消息(每个 Appt 对象一条)都是重复的。

当调用 LoggingApptCalendar 的 add apput()方法时,它首先为传递给 add apput()的 apput 数组中的每个 Appt 实例调用 Logger.log() 。该方法然后通过 super . add appendar(appapps)调用 ApptCalendar 的 add apput()方法;。

ApptCalendar 的 add apput()方法在其 apput 数组参数中为每个 Appt 实例调用 LoggingApptCalendar 的覆盖 addAppt() 方法。 addAppt() 执行 logger . log(appt . tostring());来记录它的 appt 参数的字符串表示,最后会有三条额外的记录消息。

如果您没有覆盖 add apparatus()方法,这个问题就会消失。然而,子类将被绑定到一个实现细节: ApptCalendar 的 add apput()方法调用 addAppt() 。

当细节没有被记录时,依赖于实现细节并不是一个好主意。(我之前说过,你无权访问 ApptCalendar 的源代码。)当一个细节没有被记录时,它可以在类的新版本中改变。

因为一个基类的改变会破坏一个子类,这个问题被称为脆弱基类问题 。脆弱性的一个相关原因也与重写方法有关,它发生在新方法被添加到后续版本的超类中时。

例如,假设一个新版本的库在 ApptCalendar 类中引入了一个新的 public void addAppt(Appt appt,boolean unique) 方法。当 unique 为 false 时,该方法将 appt 实例添加到日历中;并且,当 unique 为真时,它只在之前没有添加的情况下添加 appt 实例。

因为这个方法是在 LoggingApptCalendar 类创建之后添加的, LoggingApptCalendar 不会通过调用 Logger.log() 来覆盖新的 addAppt() 方法。因此,传递给新的 addAppt() 方法的 Appt 实例不会被记录。

这里还有另一个问题:你在子类中引入了一个不在超类中的方法。超类的新版本提供了一个匹配子类方法签名和返回类型的新方法。你的子类方法现在覆盖了超类方法,并且可能不满足超类方法的契约。

有一种方法可以让这些问题消失。不要扩展超类,而是在新类中创建一个私有字段,并让这个字段引用超类的一个实例。这个任务演示了组合,因为您正在新类和超类之间形成一个“has-a”关系。

此外,让每个新类的实例方法通过保存在私有字段中的超类实例调用相应的超类方法,并返回被调用方法的返回值。这个任务被称为转发,新方法被称为转发方法

清单 4-11 展示了一个改进的 LoggingApptCalendar 类,它使用组合和转发来永远消除脆弱的基类问题和未预料到的方法覆盖的额外问题。

清单 4-11 。合成的日志约会日历类

public class LoggingApptCalendar
{
   private ApptCalendar apptCal;

   public LoggingApptCalendar(ApptCalendar apptCal)
   {
      this.apptCal = apptCal;
   }

   public void addAppt(Appt appt)
   {
      Logger.log(appt.toString());
      apptCal.addAppt(appt);
   }

   public void addAppts(Appt[] appts)
   {
      for (int i = 0; i < appts.length; i++)
         Logger.log(appts[i].toString());
      apptCal.addAppts(appts);
   }
}

清单 4-11 的 LoggingApptCalendar 类不依赖于 ApptCalendar 类的实现细节。您可以向 ApptCalendar 添加新方法,它们不会破坏 LoggingApptCalendar 。

注意 LoggingApptCalendar 是一个包装类的例子,该类的实例包装其他实例。每个 LoggingApptCalendar 实例包装一个 ApptCalendar 实例。 LoggingApptCalendar 也是装饰设计模式的一个例子,在 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides (Addison-Wesley,1995;ISBN: 0201633612)。

什么时候应该扩展一个类,什么时候应该使用包装类?当超类和子类之间存在“is-a”关系时,扩展一个类,并且要么你对超类有控制权,要么超类已经为类扩展而设计和记录。否则,使用包装类。

“类扩展的设计和文档”是什么意思?设计意味着提供受保护的方法,这些方法与类的内部工作挂钩(以支持编写高效的子类),并确保构造函数和 clone() 方法永远不会调用可重写的方法。文档意味着清楚地陈述重写方法的影响。

注意包装类不应该在回调框架中使用,在一个对象框架中,一个对象将自己的引用传递给另一个对象(通过 this ),这样后一个对象可以在以后调用前一个对象的方法。这种“回调前一个对象的方法”被称为回调。因为被包装的对象不知道它的包装类,所以它只传递它的引用(通过 this ),结果回调不涉及包装类的方法。

改变形式

一些现实世界的实体可以改变它们的形态。例如,水(相对于星际空间而言,在地球上)天然是液体,但冷冻时会变成固体,加热到沸点时会变成气体。像蝴蝶这样经历蜕变的昆虫是另一个例子。

这种改变形式的能力被称为多态性,对于用编程语言建模非常有用。例如,绘制任意形状的代码可以通过引入单个形状类及其 draw() 方法,并通过为每个圆形实例、矩形实例以及存储在数组中的其他形状实例调用该方法来更简洁地表达。当形状的 draw() 方法被数组实例调用时,被调用的是圆形、矩形或其他形状实例的 draw() 方法。形的画()法有很多种形式。换句话说,这个方法是多态的。

Java 支持四种多态性 :

  • 强制:一个操作通过隐式的类型转换服务于多种类型。例如,除法可以让您将一个整数除以另一个整数,或将一个浮点值除以另一个浮点值。如果一个操作数是整数,另一个操作数是浮点值,编译器会强制(隐式转换)整数为浮点值,以防止类型错误。(没有支持整数操作数和浮点操作数的除法运算。)将子类对象引用传递给方法的超类参数是强制多态的另一个例子。编译器将子类类型强制为超类类型,以限制对超类的操作。
  • 重载:相同的操作符或方法名可以在不同的上下文中使用。例如, + 可用于执行整数加法、浮点加法或字符串连接,具体取决于其操作数的类型。同样,多个同名的方法可以出现在一个类中(通过声明和/或继承)。
  • 参数化:在一个类声明中,一个字段名可以关联不同的类型,一个方法名可以关联不同的参数和返回类型。然后,字段和方法可以在每个类实例中采用不同的类型。例如,一个字段可能是类型 java.lang.Integer 并且一个方法可能在一个类实例中返回一个整数,并且同一个字段可能是类型字符串并且同一个方法可能在另一个类实例中返回一个字符串。Java 通过泛型支持参数多态,我将在第六章中讨论。
  • 子类型:一个类型可以作为另一个类型的子类型。当子类型实例出现在超类型上下文中时,对子类型实例执行超类型操作会导致该操作的子类型版本执行。例如,假设圆是点的子类,并且两个类都包含一个 draw() 方法。将 Circle 实例分配给 Point 类型的变量,然后通过该变量调用 draw() 方法,导致 Circle 的 draw() 方法被调用。

许多开发人员不认为强制和重载是有效的多态类型。他们认为强制和重载只不过是类型转换和语法糖。相反,参数和子类型被认为是有效的多态类型。

在这一节中,我将通过首先检查向上转换和后期绑定来关注子类型多态性。然后,我将向您介绍抽象类和抽象方法、向下转换和运行时类型标识,以及协变返回类型。

上传和后期绑定

清单 4-7 的点类将一个点表示为一个 x-y 对。因为圆(在本例中)是一个表示其中心的 x-y 对,并且半径表示其范围,所以您可以用一个引入了半径字段的圆类来扩展点。查看清单 4-12 。

清单 4-12 。一个圆类扩展了点类

class Circle extends Point
{
   private int radius;

   Circle(int x, int y, int radius)
   {
      super(x, y);
      this.radius = radius;
   }

   int getRadius()
   {
      return radius;
   }

   @Override
   public String toString()
   {
      return "" + radius;
   }
}

清单 4-12 的圆类将一个圆描述为一个点和一个半径,这意味着你可以将一个圆实例视为一个点实例。通过将 Circle 实例分配给 Point 变量来完成此任务,如下所示:

Circle c = new Circle(10, 20, 30);
Point p = c;

cast 操作符不需要从圆转换到点,因为通过点的接口访问圆实例是合法的。毕竟一个圆至少是一个点。这种赋值被称为向上转换,因为你隐式地向上转换类型层次结构(从 Circle 子类到 Point 超类)。这也是协方差的一个例子,一个具有更大范围值的类型(圆)被转换成一个具有更小范围值的类型(点)。

将 Circle 上升到 Point 后,不能调用 Circle 的 getRadius() 方法,因为该方法不是 Point 接口的一部分。在将子类型缩小为超类之后,失去对子类型特性的访问似乎没有什么用处,但对于实现子类型多态性却是必要的。

除了将子类实例向上转换为超类类型的变量之外,子类型多态性还包括在超类中声明一个方法,并在子类中覆盖这个方法。例如,假设点和圆是图形应用的一部分,您需要在每个类中引入一个 draw() 方法来分别绘制一个点和一个圆。你以清单 4-13 中的所示的类结构结束。

清单 4-13 。声明图形应用的点和圆类

class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX()
   {
      return x;
   }

   int getY()
   {
      return y;
   }

   @Override
   public String toString()
   {
      return "(" + x + ", " + y + ")";
   }

   void draw()
   {
      System.out.println("Point drawn at " + toString());
   }
}

class Circle extends Point
{
   private int radius;

   Circle(int x, int y, int radius)
   {
      super(x, y);
      this.radius = radius;
   }

   int getRadius()
   {
      return radius;
   }

   @Override
   public String toString()
   {
      return "" + radius;
   }

   @Override
   void draw()
   {
      System.out.println("Circle drawn at " + super.toString() +
                         " with radius " + toString());
   }
}

清单 4-13 的 draw() 方法将最终绘制图形形状,但是通过 System.out.println() 方法调用模拟它们的行为在图形应用的早期测试阶段已经足够了。

现在您已经暂时完成了点和圆,您将想要在图形应用的模拟版本中测试它们的 draw() 方法。为了实现这个目标,你编写清单 4-14 中的的图形类。

清单 4-14 。一个图形类用于测试点的和圆的 draw() 方法

public class Graphics
{
   public static void main(String[] args)
   {
      Point[] points = new Point[] { new Point(10, 20), new Circle(10, 20, 30) };
      for (int i = 0; i < points.length; i++)
         points[i].draw();
   }
}

清单 4-14 的 main() 方法首先声明一个 Point s 的数组。向上造型是通过首先让数组的初始化器实例化 Circle 类,然后将这个实例的引用分配给 points 数组中的第二个元素来演示的。

继续, main() 使用 for 循环调用每个点元素的 draw() 方法。因为第一次迭代调用了 Point 的 draw() 方法,而第二次迭代调用了 Circle 的 draw() 方法,所以您会观察到以下输出:

Point drawn at (10, 20)
Circle drawn at (10, 20) with radius 30

Java 如何“知道”在第二次循环迭代时必须调用 Circle 的 draw() 方法?难道不应该调用点的 draw() 方法,因为圆由于向上转换而被视为点?

在编译时,编译器不知道调用哪个方法。它所能做的就是验证超类中存在一个方法,并验证方法调用的参数列表和返回类型与超类的方法声明相匹配。

编译器在编译后的代码中插入一条指令,在运行时获取并使用 points[i] 中的任何引用来调用正确的 draw() 方法,而不是知道调用哪个方法。这个任务被称为后期绑定

后期绑定用于调用非 final 实例方法。对于所有其他方法调用,编译器知道要调用哪个方法,并在编译后的代码中插入一条指令,该指令调用与变量的类型(而不是其值)相关联的方法。这个任务被称为早期绑定

如果要向上转换的数组是另一个数组的子类型,也可以从一个数组向上转换到另一个数组。考虑清单 4-15 中的。

清单 4-15 。演示阵列向上投射

class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX() { return x; }
   int getY() { return y; }
}

class ColoredPoint extends Point
{
   private int color;

   ColoredPoint(int x, int y, int color)
   {
      super(x, y);
      this.color = color;
   }

   int getColor() { return color; }
}

public class UpcastArrayDemo
{
   public static void main(String[] args)
   {
      ColoredPoint[] cptArray = new ColoredPoint[1];
      cptArray[0] = new ColoredPoint(10, 20, 5);
      Point[] ptArray = cptArray;
      System.out.println(ptArray[0].getX()); // Output: 10
      System.out.println(ptArray[0].getY()); // Output: 20
 //      System.out.println(ptArray[0].getColor()); // Illegal
   }
}

清单 4-15 的 main() 方法首先创建一个由一个元素组成的 ColoredPoint 数组。然后实例化这个类,并将对象的引用分配给这个元素。因为 ColoredPoint[] 是 Point[] 的一个子类型, main() 能够将 cptArray 的 ColoredPoint[] 类型向上转换为 Point[] ,并将其引用分配给 p array。

main() 然后通过 ptArray[0] 调用 ColoredPoint 实例的 getX() 和 getY() 方法。它不能调用 getColor() ,因为 p array 的范围比 cptArray 窄。换句话说, getColor() 不是 Point 接口的一部分。

抽象类和抽象方法

假设新的需求要求您的图形应用必须包含一个矩形类。此外,该类必须包括一个 draw() 方法,并且该方法必须以类似于清单 4-14 的 Graphics 应用类中所示的方式进行测试。

与作为具有半径的点的圆相反,将矩形视为具有宽度和高度的点是没有意义的。更确切地说,一个矩形实例可能由一个表示其原点的点实例和一个表示其宽度和高度范围的点实例组成。

因为圆、点和矩形都是形状的例子,所以用自己的 draw() 方法声明一个 Shape 类比指定类 Rectangle extends Point 更有意义。清单 4-16 展示了形状的声明。

清单 4-16 。宣告一个形状类

class Shape
{
   void draw()
   {
   }
}

清单 4-16 的形状类声明了一个空的 draw() 方法,该方法的存在只是为了被覆盖和演示子类型多态性。

你现在可以重构清单 4-13 的点类来扩展清单 4-16 的形状类,保持圆形不变,并引入一个矩形类来扩展形状。然后你可以重构清单 4-14 的的 Graphics 类的 main() 方法来考虑形状。清单 4-17 展示了生成的图形类。

清单 4-17 。一个带有新的 main() 方法的图形类,该方法考虑了形状

public class Graphics
{
   public static void main(String[] args)
   {
      Shape[] shapes = new Shape[] { new Point(10, 20), new Circle(10, 20, 30),
                                     new Rectangle(20, 30, 15, 25) };
      for (int i = 0; i < shapes.length; i++)
         shapes[i].draw();
   }
}

因为点和矩形直接延伸形状,又因为圆通过延伸点间接延伸形状,清单 4-17 的 main() 方法会调用相应子类的 draw() 方法来响应形状【I】。draw();。

虽然形状让代码更加灵活,但是有一个问题。如何阻止开发人员实例化形状并将这个无意义的实例添加到形状数组中,如下所示?

Shape[] shapes = new Shape[] { new Point(10, 20), new Circle(10, 20, 30),
                               new Rectangle(20, 30, 15, 25), new Shape() };

实例化形状是什么意思?因为这个类描述的是一个抽象的概念,画一个通用的形状是什么意思?幸运的是,Java 为这个问题提供了一个解决方案,如清单 4-18 所示。

清单 4-18 。抽象出形状类

abstract class Shape
{
   abstract void draw(); // semicolon is required
}

清单 4-18 使用 Java 的抽象保留字声明一个不能实例化的类。当您尝试实例化该类时,编译器会报告一个错误。

提示养成声明描述通用类别(如形状、动物、车辆和账户)的类的习惯摘要。这样,您就不会无意中实例化它们。

抽象保留字也用于声明没有主体的方法。 draw() 方法不需要实体,因为它不能绘制抽象的形状。

当你试图声明一个既抽象又最终的类时,编译器会报告一个错误。例如,抽象最终类形状是一个错误,因为抽象类不能被实例化,最终类不能被扩展。当您将一个方法声明为抽象方法,但没有将其类声明为抽象方法时,编译器也会报告错误。例如,从清单 4-18 中的形状类的头中删除抽象会导致错误。这种移除是错误的,因为当非抽象(具体)类包含抽象方法时,它不能被实例化。最后,当你扩展一个抽象类时,扩展类必须覆盖抽象类的所有抽象方法,否则扩展类本身必须被声明为抽象的;否则,编译器将报告错误。

除了抽象方法之外,一个抽象类还可以包含非抽象方法。例如,清单 4-2 中的 Vehicle 类可以被声明为抽象。构造函数仍然存在,用于初始化私有字段,即使您不能实例化结果类。

向下转换和运行时类型识别

通过向上转换在类型层次结构中向上移动会导致无法访问子类型特征。例如,将一个 Circle 实例赋给 Point 变量 p 意味着不能使用 p 调用 Circle 的 getRadius() 方法。

但是,可以通过执行显式强制转换操作,再次访问 Circle 实例的 getRadius() 方法,例如,Circle c =(Circle)p;。这种赋值被称为向下转换,因为你是显式地向下移动类型层次结构(从点超类到圆子类)。这也是逆变的一个例子,具有较窄取值范围的类型(点)被转换为具有较宽取值范围的类型(圈)。

虽然向上转换总是安全的(超类的接口是子类接口的子集),但是向下转换就不一样了。清单 4-19 向你展示了当向下转换使用不当时,你会陷入什么样的麻烦。

清单 4-19 。向下抛掷的问题是

class A
{
}

class B extends A
{
   void m()
   {
   }
}

public class DowncastDemo
{
   public static void main(String[] args)
   {
      A a = new A();
      B b = (B) a;
      b.m();
   }
}

清单 4-19 展示了一个由名为 A 的超类和名为 B 的子类组成的类层次结构。虽然 A 没有声明任何成员,但是 B 声明了一个单独的 m() 方法。

第三个名为 DowncastDemo 的类提供了一个 main() 方法,该方法首先实例化 A ,然后尝试将该实例向下转换为 B ,并将结果赋给变量 b 。编译器不会抱怨,因为在同一类型层次结构中从超类向下转换到子类是合法的。

但是,如果允许赋值,应用在试图执行 b.m()时无疑会崩溃;。崩溃的发生是因为虚拟机试图调用一个不存在的方法——类 A 没有 m() 方法。

幸运的是,这种情况永远不会发生,因为虚拟机验证强制转换是合法的。因为它检测到 A 没有 m() 方法,所以它不允许通过抛出 ClassCastException 类的实例进行强制转换。

虚拟机的 cast 验证说明了运行时类型标识(或简称 RTTI )。强制转换验证通过检查强制转换运算符的操作数类型来执行 RTTI,以确定是否允许强制转换。显然,演员不应该被允许。

第二种形式的 RTTI 涉及到操作符的实例。该运算符检查左操作数是否是右操作数的实例,如果是,则返回 true。下面的例子将的实例引入到清单 4-19 中,以防止类抛出异常:

if(a instanceof B)
{
   B b = (B) a;
   b.m();
}

操作符的 instance 检测到变量 a 的实例不是从 B 创建的,并返回 false 以表明这一事实。因此,执行非法强制转换的代码将不会执行。(过度使用的实例可能表明糟糕的软件设计。)

因为子类型是一种超类型,所以当其左操作数是其右操作数超类型的子类型实例或超类型实例时,的 instance 将返回 true。以下示例演示了:

A a = new A();
B b = new B();
System.out.println(b instanceof A); // Output: true
System.out.println(a instanceof A); // Output: true

这个例子假设了清单 4-19 中所示的类结构,并实例化了超类 A 和子类 B 。第一个 System.out.println() 方法调用输出 true ,因为 b 的引用标识了 B 的实例,是 A 的子类;第二个 System.out.println() 方法调用输出 true ,因为 a 的引用标识了超类 A 的一个实例。

还可以从一个数组向下转换到另一个数组,前提是被向下转换的数组是另一个数组的超类型,并且它的元素类型是子类型的元素类型。考虑清单 4-20 中的。

清单 4-20 。演示数组向下转换

class Point
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX() { return x; }
   int getY() { return y; }
}

class ColoredPoint extends Point
{
   private int color;

   ColoredPoint(int x, int y, int color)
   {
      super(x, y);
      this.color = color;
   }

   int getColor() { return color; }
}

public class DowncastArrayDemo
{
   public static void main(String[] args)
   {
      ColoredPoint[] cptArray = new ColoredPoint[1];
      cptArray[0] = new ColoredPoint(10, 20, 5);
      Point[] ptArray = cptArray;
      System.out.println(ptArray[0].getX()); // Output: 10
      System.out.println(ptArray[0].getY()); // Output: 20
 //      System.out.println(ptArray[0].getColor()); // Illegal
      if (ptArray instanceof ColoredPoint[])
      {
         ColoredPoint cp = (ColoredPoint) ptArray[0];
         System.out.println(cp.getColor());
      }
   }
}

清单 4-20 类似于清单 4-15 ,除了它也演示了向下转换。注意它使用的实例来验证 p 数组的引用对象是类型 ColoredPoint[] 。如果该运算符返回 true,则可以安全地将 p array[0]从点向下转换到 ColoredPoint ,并将引用分配给 ColoredPoint 。

共变返回类型

协变返回类型是一种方法返回类型,在超类的方法声明中,它是子类的覆盖方法声明中返回类型的超类型。清单 4-21 展示了这种语言的特性。

清单 4-21 。协变返回类型的演示

class SuperReturnType
{
   @Override
   public String toString()
   {
      return "superclass return type";
   }
}

class SubReturnType extends SuperReturnType
{
   @Override
   public String toString()
   {
      return "subclass return type";
   }
}

class Superclass
{
   SuperReturnType createReturnType()
   {
      return new SuperReturnType();
   }
}

class Subclass extends Superclass
{
   @Override
   SubReturnType createReturnType()
   {
      return new SubReturnType ();
   }
}

public class CovarDemo
{
   public static void main(String[] args)
   {
      SuperReturnType suprt = new Superclass().createReturnType();
      System.out.println(suprt); // Output: superclass return type
      SubReturnType subrt = new Subclass().createReturnType();
      System.out.println(subrt); // Output: subclass return type
   }
}

清单 4-21 声明了 SuperReturnType 和超类超类和子 ReturnType 和子类子类;每个超类和子类都声明了一个 createReturnType() 方法。超类的方法将其返回类型设置为 SuperReturnType ,而子类的覆盖方法将其返回类型设置为 SubReturnType ,后者是 SuperReturnType 的子类。

协变返回类型最小化了向上转换和向下转换。例如,子类的 createReturnType() 方法不需要将其 SubReturnType 实例向上转换为其 SubReturnType 返回类型。此外,在给变量 subrt 赋值时,这个实例不需要向下转换为 SubReturnType 。

在没有协变返回类型的情况下,您会以清单 4-22 中的结束。

清单 4-22 。缺少协变返回类型时的向上转换和向下转换

class SuperReturnType
{
   @Override
   public String toString()
   {
      return "superclass return type";
   }
}

class SubReturnType extends SuperReturnType
{
   @Override
   public String toString()
   {
      return "subclass return type";
   }
}

class Superclass
{
   SuperReturnType createReturnType()
   {
      return new SuperReturnType();
   }
}

class Subclass extends Superclass
{
   @Override
   SuperReturnType createReturnType()
   {
      return new SubReturnType ();
   }
}

public class CovarDemo
{
   public static void main(String[] args)
   {
      SuperReturnType suprt = new Superclass().createReturnType();
      System.out.println(suprt); // Output: superclass return type
      SubReturnType subrt = (SubReturnType) new Subclass().createReturnType();
      System.out.println(subrt); // Output: subclass return type
   }
}

在清单 4-22 中,第一个加粗的代码显示了从子返回类型到超返回类型的向上转换,第二个加粗的代码使用所需的(子返回类型)转换运算符,在将子返回类型赋值之前,从超返回类型向下转换到子返回类型。

形式化类接口

在我对信息隐藏的介绍中(参见第三章,我提到每个类 X 都公开了一个接口(一个由构造函数、方法和【可能】字段组成的协议,它们对从其他类创建的对象可用,用于创建和与 X 的对象通信)。

Java 通过提供保留字接口将接口概念形式化,接口用于引入一个没有实现的类型。Java 还提供了声明、实现和扩展接口的语言特性。在本节中查看了接口声明、实现和扩展之后,我将解释使用接口的基本原理。

声明接口

一个接口声明由一个标题和一个主体组成。至少,报头由保留字 interface 和标识接口的名称组成。正文以左大括号字符开始,以右大括号结束。夹在这些分隔符之间的是常量和方法头声明。考虑清单 4-23 。

清单 4-23 。声明一个可绘制的界面

interface Drawable
{
   int RED = 1;   // For simplicity, integer constants are used. These constants are
   int GREEN = 2; // not that descriptive, as you will see.
   int BLUE = 3;
   int BLACK = 4;
   void draw(int color);
}

清单 4-23 声明了一个名为 Drawable 的接口。按照惯例,接口的名称以大写字母开头。此外,多词界面名称中每个后续词的第一个字母都要大写。

注意许多接口名称以 able 后缀结尾。例如,标准类库包括名为可调用、可比、可克隆、可迭代、可运行和可序列化的接口。使用这个后缀不是强制性的;标准类库还提供了名为 CharSequence 、 Collection 、 Executor 、 Future 、 Iterator 、 List 、 Map 、 Set 的接口。

Drawable 声明了四个标识颜色常数的字段。 Drawable 还声明了一个 draw() 方法,必须用这些常量之一来调用该方法,以指定用于绘制某物的颜色。

注意你可以在接口之前加上公共接口,以使你的接口可以被其包之外的代码访问。(我将在下一章讨论包)。否则,该接口只能由其包中的其他类型访问。你也可以在接口前面加上抽象来强调一个接口是抽象的。因为接口已经是抽象的,所以在接口的声明中指定抽象的是多余的。接口的字段被隐式声明为 public 、 static 和 final 。因此,用这些保留字来声明它们是多余的。因为这些字段是常量,所以必须显式初始化;否则,编译器会报告错误。最后,接口的方法被隐式声明为公共和抽象。因此,用这些保留字来声明它们是多余的。因为这些方法必须是实例方法,所以不要将它们声明为静态的,否则编译器会报错。

Drawable 标识一个类型,指定做什么(画一些东西)但不指定如何做。它将实现细节留给实现该接口的类。这些类的实例被称为 drawables ,因为它们知道如何绘制自己。

注意没有声明成员的接口被称为标记接口标记接口。它将元数据与类相关联。例如,可克隆的标记/标签接口的存在意味着它的实现类的实例可以被简单地克隆。RTTI 用于检测对象的类是否实现了标记/标签接口。例如,当对象的 clone() 方法通过 RTTI 检测到调用实例的类实现了 Cloneable 时,它会浅克隆该对象。

实现接口

接口本身是没有用的。为了让应用受益,接口需要由一个类来实现。Java 为此任务提供了实现保留字。这个保留字在清单 4-24 中有演示。

清单 4-24 。实现可绘制接口

class Point implements Drawable
{
   private int x, y;

   Point(int x, int y)
   {
      this.x = x;
      this.y = y;
   }

   int getX()
   {
      return x;
   }

   int getY()
   {
      return y;
   }

   @Override
   public String toString()
   {
      return "(" + x + ", " + y + ")";
   }

   @Override
   public void draw(int color)
   {
      System.out.println("Point drawn at " + toString() + " in color " + color);
   }
}

class Circle extends Point implements Drawable
{
   private int radius;

   Circle(int x, int y, int radius)
   {
      super(x, y);
      this.radius = radius;
   }

   int getRadius()
   {
      return radius;
   }

   @Override
   public String toString()
   {
      return "" + radius;
   }

   @Override
   public void draw(int color)
   {
      System.out.println("Circle drawn at " + super.toString() +
                         " with radius " + toString() + " in color " + color);
   }
}

清单 4-24 改进了清单 4-13 的类层次结构,以利用清单 4-23 的可绘制接口。您会注意到,每个类 Point 和 Circle 都通过将 implements Drawable 子句附加到其类头来实现该接口。

若要实现接口,该类必须为每个接口方法头指定一个方法,该方法的头与接口的方法头具有相同的签名和返回类型,并且该方法头具有一个代码体。

注意当实现一个方法时,不要忘记接口的方法被隐式声明为 public 。如果您忘记在实现方法的声明中包含 public ,编译器将会报告一个错误,因为您试图为实现方法分配较弱的访问。

当一个类实现一个接口时,该类继承接口的常量和方法头,并通过提供实现来覆盖方法头(因此有了 @Override 注释)。这就是所谓的接口继承

原来圈的表头不需要实现 Drawable 子句。如果该子句不存在, Circle 继承了 Point 的 draw() 方法,仍然被认为是一个 Drawable ,无论它是否覆盖该方法。

接口指定一个类型,该类型的数据值是其类实现接口的对象,其行为是由接口指定的。这一事实意味着,只要对象的类实现了接口,就可以将对象的引用赋给接口类型的变量。以下示例提供了一个演示:

public static void main(String[] args)
{
   Drawable[] drawables = new Drawable[] { new Point(10, 20), new Circle(10, 20, 30) };
   for (int i = 0; i < drawables.length; i++)
      drawables[i].draw(Drawable.RED);
}

因为 Point 和 Circle 实例是依靠这些实现 Drawable 接口的类来绘制的,所以将 Point 和 Circle 实例引用分配给 Drawable 类型的变量(包括数组元素)是合法的。

当您运行此方法时,它会生成以下输出:

Point drawn at (10, 20) in color 1
Circle drawn at (10, 20) with radius 30 in color 1

清单 4-23 的可绘制界面对于绘制一个形状的轮廓很有用。假设您还需要填充形状的内部。你可以尝试通过声明清单 4-25 的可填充接口来满足这个需求。

清单 4-25 。声明一个可填充的界面

interface Fillable
{
   int RED = 1;
   int GREEN = 2;
   int BLUE = 3;
   int BLACK = 4;
   void fill(int color);
}

给定清单 4-23 和 4-25 ,您可以通过指定类点实现可绘制、可填充和类圆实现可绘制、可填充来声明点和圆类实现这两个接口。然后,您可以修改 main() 方法,也将可绘制内容视为可填充内容,以便您可以填充这些形状,如下所示:

public static void main(String[] args)
{
   Drawable[] drawables = new Drawable[] { new Point(10, 20),
                                           new Circle(10, 20, 30) };
   for (int i = 0; i < drawables.length; i++)
      drawables[i].draw(Drawable.RED);
   Fillable[] fillables = new Fillable[drawables.length];
   for (int i = 0; i < drawables.length; i++)
   {
      fillables[i] = (Fillable) drawables[i];
      fillables[i].fill(Fillable.GREEN);
   }
}

在调用每个 drawable 的 draw() 方法后, main() 创建一个与 Drawable 数组长度相同的 Fillable 数组。然后将每个 Drawable 数组元素复制到一个 Fillable 数组元素,然后调用 Fillable 的 fill() 方法。 (Fillable) 造型是必要的,因为 drawable 不是 Fillable。该强制转换操作将会成功,因为被复制的点和圆实例实现了可填充和可绘制。

提示通过在实现之后指定一个逗号分隔的接口名称列表,您可以列出您需要实现的任意多的接口。

实现多个接口会导致名称冲突,编译器会报告错误。例如,假设你试图编译清单 4-26 的接口和类声明。

清单 4-26 。碰撞界面

interface A
{
   int X = 1;
   void foo();
}

interface B
{
   int X = 1;
   int foo();
}

class Collision implements A, B
{
   @Override
   public void foo();

   @Override
   public int foo() { return X; }
}

清单 4-26 的 A 和 B 的每个接口都声明了一个名为 X 的常数。尽管每个常量都具有相同的类型和值,但当编译器在 Collision 的第二个 foo() 方法中遇到 X 时,它会报告一个错误,因为它不知道哪个 X 正在被继承。

说到 foo() ,编译器在遇到碰撞的第二个 foo() 声明时报错,因为 foo() 已经声明过了。不能通过仅更改方法的返回类型来重载方法。

编译器可能会报告额外的错误。例如,Java 7 编译器在被告知编译清单 4-26 中的时会这样说:

Collision.java:19: error: method foo() is already defined in class Collision
   public int foo() { return X; }
              ^
Collision.java:13: error: Collision is not abstract and does not override abstract method foo()
in B class Collision implements A, B
^
Collision.java:16: error: foo() in Collision cannot implement foo() in B
   public void foo();
               ^
  return type void is not compatible with int
Collision.java:19: error: reference to X is ambiguous, both variable X in A and variable X
in B match
   public int foo() { return X; }
                             ^
4 errors

扩展接口

正如子类可以通过保留字扩展来扩展超类一样,你可以使用这个保留字让一个子接口扩展一个超接口。这也被称为接口继承

例如, Drawable 和 Fillable 中重复的颜色常量,当你在一个实现类中单独指定它们的名字时,会导致名字冲突。为了避免这些名字冲突,在名字前面加上接口名和成员访问操作符,或者将这些常量放在它们自己的接口中,并让 Drawable 和 Fillable 扩展这个接口,如清单 4-27 所示。

清单 4-27 。扩展颜色接口

interface Colors
{
   int RED = 1;
   int GREEN = 2;
   int BLUE = 3;
   int BLACK = 4;
}

interface Drawable extends Colors
{
   void draw(int color);
}

interface Fillable extends Colors
{
   void fill(int color);
}

Drawable 和 Fillable 都从 Colors 继承常量,这对于编译器来说不是问题。这些常量只有一个副本(颜色为和),没有名称冲突的可能,所以编译器是满意的。

如果一个类可以通过在实现之后声明一个逗号分隔的接口名称列表来实现多个接口,那么看起来一个接口应该可以用类似的方式扩展多个接口。这个特性在清单 4-28 中进行了演示。

清单 4-28 。扩展一对接口

interface A
{
   int X = 1;
}

interface B
{
   double X = 2.0;
}

interface C extends A, B
{
}

尽管 C 继承了两个同名的常量 X 具有不同的类型和初始化器,清单 4-28 仍然可以编译。然而,如果你实现了 C ,然后试图访问 X ,如清单 4-29 所示,你将会遇到名称冲突。

清单 4-29 。发现名称冲突

class Collision implements C
{
   public void output()
   {
      System.out.println(X); // Which X is accessed?
   }
}

假设你引入一个 void foo();方法头声明成接口一个和一个 int foo();方法头声明进入接口 B 。这一次,当您试图编译修改后的清单 4-28 时,编译器将报告一个错误。

为什么要使用接口?

既然声明、实现和扩展接口的机制已经不存在了,那么您就可以关注使用它们的基本原理了。不幸的是,刚接触 Java 接口特性的人经常被告知,这个特性是为了解决 Java 不支持多实现继承的问题而创建的。虽然接口在这方面很有用,但这不是它们存在的理由。相反, Java 的接口特性是通过将接口从实现中分离出来,为开发人员设计应用提供最大的灵活性。您应该始终对接口(由接口类型或抽象类提供)进行编码。

那些坚持敏捷软件开发(一组基于迭代开发的软件开发方法,强调保持代码简单,频繁测试,并在可交付时交付应用的功能部分)的人知道灵活编码的重要性。他们不能将他们的代码绑定到一个特定的实现上,因为下一次迭代的需求变化可能会导致一个新的实现,并且他们可能会发现他们自己重写了大量的代码,这浪费了时间并且减慢了开发。

接口通过将接口与实现分离来帮助您实现灵活性。例如,清单 4-17 的图形类中的 main() 方法从 Shape 类的子类中创建一个对象数组,然后遍历这些对象,调用每个对象的 draw() 方法。唯一可以被绘制的对象是那些子类形状的对象。

假设你也有一个层次结构的类来模拟电阻、晶体管和其他电子元件。每个元件都有自己的符号,可以在电子电路的原理图中显示。也许您想为每个绘制组件符号的类添加一个绘制功能。

您可以考虑将形状指定为电子元件类层次的超类。然而,电子元件不是形状(尽管它们有形状),所以将这些类放在以形状为根的类层次结构中是没有意义的。

但是,您可以让每个组件类实现 Drawable 接口,该接口允许您将实例化这些类的表达式添加到出现在清单 4-25 之前的 main() 方法中的 drawables 数组中(这样您就可以绘制它们的符号)。这是合法的,因为这些实例是可提取的。

只要有可能,您应该努力在代码中指定接口而不是类,以使您的代码能够适应变化。当使用 Java 的集合框架时尤其如此,我将在第九章详细讨论。

现在,考虑一个简单的例子,它由集合框架的 java.util.List 接口及其 java.util.ArrayList 和 java.util.LinkedList 实现类组成。以下示例展示了基于 ArrayList 类的不灵活代码:

ArrayList<String> arrayList = new ArrayList<String>();
void dump(ArrayList<String> arrayList)
{
   // suitable code to dump out the arrayList
}

这个例子使用基于泛型的参数化类型语言特性(我将在第六章中讨论)来识别存储在数组列表实例中的对象种类。在这个例子中,字符串对象被存储。

这个例子是不灵活的,因为它将 ArrayList 类硬连接到多个位置。这种硬连接使开发人员特别关注数组列表,而不是一般意义上的列表。

当需求改变时,缺乏关注是有问题的,或者可能是由剖析(分析运行中的应用以检查其性能)带来的性能问题,这表明开发人员应该使用 LinkedList 。

这个例子只需要很少的修改就可以满足新的需求。相比之下,更大的代码库可能需要更多的更改。尽管您只需要将 ArrayList 改为 LinkedList ,为了满足编译器的要求,请考虑将 arrayList 改为 linkedList 以保持语义(含义)清晰——您可能需要在整个源代码中多次更改引用 ArrayList 实例的名称。

在重构代码以适应 LinkedList 时,开发人员必然会浪费时间。相反,通过编写这个示例来使用等效的常量,可以节省时间。换句话说,这个例子可以被写成依赖于接口,并且只在一个地方指定数组列表。以下示例向您展示了结果代码的外观:

List<String> list = new ArrayList<String>();
void dump(List<String> list)
{
   // suitable code to dump out the list
}

这个例子比前一个例子灵活得多。如果一个需求或概要分析的变化建议使用 LinkedList 而不是 ArrayList ,只需用 Linked 替换 Array 就可以了。您甚至不必更改参数名。

注意 Java 提供了描述抽象类型(不能实例化的类型)的接口和抽象类。抽象类型代表抽象概念(例如,drawable 和 shape),这种类型的实例是没有意义的。

接口通过缺少实现来提高灵活性——Drawable 和 List 说明了这种灵活性。它们不依赖于任何单一的类层次结构,而是可以由任何层次结构中的任何类来实现。相反,抽象类支持实现,但可以是真正的抽象(例如,清单 4-18 的抽象形状类)。但是,它们仅限于出现在类层次结构的上层。

接口和抽象类可以一起使用。例如,集合框架的 java.util 包提供了 List、Map、和 Set 接口和 AbstractList、AbstractMap、和 AbstractSet 抽象类,它们提供了这些接口的框架实现。

通过实现许多接口方法,框架实现使您可以轻松地创建自己的接口实现来满足您独特的需求。如果它们不满足您的需要,您可以选择让您的类直接实现适当的接口。

练习

以下练习旨在测试您对第四章内容的理解:

  1. 什么是实现继承?
  2. Java 如何支持实现继承?
  3. 一个子类可以有两个或多个超类吗?
  4. 你如何防止一个类被子类化?
  5. 是非判断: super() 调用可以出现在任何方法中。
  6. 如果超类声明一个带一个或多个参数的构造函数,如果子类构造函数没有使用 super() 调用那个构造函数,为什么编译器会报错?
  7. 什么是不可变类?
  8. 是非判断:一个类可以继承构造函数。
  9. 重写一个方法是什么意思?
  10. 从超类方法的覆盖子类方法中调用超类方法需要什么?
  11. 如何防止方法被重写?
  12. 为什么不能使一个重写的子类方法比它所重写的超类方法更难访问呢?
  13. 如何告诉编译器一个方法覆盖了另一个方法?
  14. 为什么 Java 不支持多实现继承?
  15. Java 的终极超类叫什么?
  16. clone() 方法的目的是什么?
  17. 对象的 clone() 方法什么时候抛出 CloneNotSupportedException?
  18. 解释浅拷贝和深拷贝的区别。
  19. == 运算符可以用来判断两个对象在逻辑上是否等价吗?为什么或为什么不?
  20. Object 的 equals() 方法完成了什么?
  21. 表达式 "abc" == "a" + "bc" 返回真还是假?
  22. 如何优化一个耗时的 equals() 方法?
  23. finalize() 方法的目的是什么?
  24. 你应该依靠 finalize() 来关闭打开的文件吗?为什么或为什么不?
  25. 什么是哈希码?
  26. 是非判断:无论何时重写 equals() 方法,都应该重写 hashCode() 方法。
  27. 对象的 toString() 方法返回什么?
  28. 为什么要重写 toString() ?
  29. 定义构图。
  30. 是非判断:组合用于描述“是-a”关系,实现继承用于描述“有-a”关系。
  31. 识别实现继承的根本问题。你如何解决这个问题?
  32. 定义子类型多态性。
  33. 子类型多态性是如何实现的?
  34. 为什么要使用抽象类和抽象方法?
  35. 抽象类可以包含具体方法吗?
  36. 向下抛掷的目的是什么?
  37. 列出 RTTI 的两种形式。
  38. 什么是协变返回类型?
  39. 如何正式声明一个接口?
  40. 是非判断:你可以在接口声明前加上抽象保留字。
  41. 定义标记接口。
  42. 什么是接口继承?
  43. 你如何实现一个接口?
  44. 当您实现多个接口时,您可能会遇到什么问题?
  45. 如何形成接口的层次结构?
  46. 为什么 Java 的接口特性如此重要?
  47. 接口和抽象类完成什么?
  48. 接口和抽象类有什么不同?
  49. 通过声明动物、鸟、鱼、美洲知更鸟、家养金丝雀、虹鳟鱼和 SockeyeSalmon 类来建立动物层级的模型:
  • Animal 是 public 和 abstract ,声明基于 private String 的 kind 和 appearance 字段,声明一个 public 构造器,该构造器将这些字段初始化为传入的参数,声明 public 和 abstract eat() 和 move() 方法,这些方法不带参数并且其返回类型为 void
  • Bird 是 public 和 abstract ,扩展 Animal ,声明一个 public 构造函数,将它的 kind 和 appearance 参数值传递给它的超类构造函数,覆盖它的 eat() 方法以输出吃种子和昆虫(通过 System.out.println() ,并覆盖它的
  • 鱼是公和摘要;延伸动物;声明一个公共构造函数,将它的种类和外观参数值传递给它的超类构造函数;覆盖它的 eat() 方法来输出吃磷虾、藻类和昆虫;并覆盖它的 move() 方法来输出游过水。
  • AmericanRobin 是 public ,扩展了 Bird ,声明了一个 public noargument 构造函数,将" American robin "" red breast "传递给它的超类构造函数。
  • DomesticCanary 是 public ,扩展了 Bird ,声明了一个 public noargument 构造函数,将 "domesticcanary" 和 "yellow,orange,black,brown,white,red" 传递给它的超类构造函数。
  • RainbowTrout 是 public ,扩展了 Fish ,并声明了一个 public noargument 构造函数,该构造函数将" RainbowTrout ""几乎贯穿其整个身体长度的鲜艳斑点五彩条纹带"传递给其超类构造函数。
  • SockeyeSalmon 是 public ,扩展了 Fish ,声明了一个 public noargument 构造函数,将" SockeyeSalmon ""鲜红色带绿头"传递给它的超类构造函数。

注意为了简洁,我从动物层级抽象知更鸟、金丝雀、鳟鱼和鲑鱼中省略了概括知更鸟、金丝雀、鳟鱼和鲑鱼的类。也许您可能想在层次结构中包含这些类。

虽然这个练习展示了使用继承的自然场景的精确建模,但是它也揭示了类爆炸的可能性——太多的类可能被引入来建模一个场景,并且维护所有这些类可能是困难的。在使用继承建模时,请记住这一点。

  • 50.继续上一个练习,用一个 main() 方法声明一个 Animals 类。该方法首先声明一个 animals 数组,该数组被初始化为 AmericanRobin 、 RainbowTrout 、 DomesticCanary 和 SockeyeSalmon 对象。然后该方法遍历这个数组,首先输出 animals[i] (这导致 toString() 被调用),然后调用每个对象的 eat() 和 move() 方法(演示子类型多态性)。

  • 51. Continuing from the previous exercise, declare a public Countable interface with a String getID() method. Modify Animal to implement Countable and have this method return kind’s value. Modify Animals to initialize the animals array to AmericanRobin, RainbowTrout, DomesticCanary, SockeyeSalmon, RainbowTrout, and AmericanRobin objects. Also, introduce code that computes a census of each kind of animal. This code will use the Census class that is declared in Listing 4-30.

    清单 4-30 。普查类存储四种动物的普查数据

    public class Census
    {
       public final static int SIZE = 4;
       private String[] IDs;
       private int[] counts;
    
       public Census()
       {
          IDs = new String[SIZE];
          counts = new int[SIZE];
       }
    
       public String get(int index)
       {
          return IDs[index] + " " + counts[index];
       }
    
       public void update(String ID)
       {
          for (int i = 0; i < IDs.length; i++)
          {
             // If ID not already stored in the IDs array (which is indicated by
             // the first null entry that is found), store ID in this array, and
             // also assign 1 to the associated element in the counts array, to
             // initialize the census for that ID.
             if (IDs[i] == null)
             {
                IDs[i] = ID;
                counts[i] = 1;
                return;
             }
    
             // If a matching ID is found, increment the associated element in
             // the counts array to update the census for that ID.
             if (IDs[i].equals(ID))
             {
                counts[i]++;
                return;
             }
          }
       }
    }
    
    

摘要

继承是相似实体类别之间的层次关系,其中一个类别从至少一个其他实体类别继承状态和行为。从单一类别继承称为单一继承,从至少两个类别继承称为多重继承。

Java 支持单继承和多继承以方便代码重用——为什么要多此一举?Java 支持类上下文中的单一继承(通过保留字扩展),其中一个类通过类扩展从另一个类继承字段和方法。因为涉及到类,Java 把这种继承称为实现继承。Java 只在这样的接口上下文中支持多重继承:一个类通过接口实现(通过保留字实现)从一个或多个接口继承方法模板,或者一个接口通过接口扩展(通过保留字扩展)从一个或多个接口继承方法模板。因为涉及到接口,所以 Java 把这种继承称为接口继承。

一些现实世界的实体有能力改变它们的形态。改变形式的能力被称为多态,这对于用编程语言建模很有用。尽管 Java 支持强制、重载、参数和子类型类型的多态性,但在本章中,我只关注子类型多态性,这是通过向上转换和方法覆盖实现的。

每个类 X 都公开了一个接口(一个由构造函数、方法和[可能]字段组成的协议,这些接口对从其他类创建的对象可用,用于创建和与 X 的对象通信)。Java 通过提供保留字接口将接口概念形式化,接口用于引入一个没有实现的类型。

尽管许多人认为创建接口语言特性是为了解决 Java 不支持多实现继承的问题,但这并不是它存在的真正原因。相反,Java 的接口特性是通过将接口从实现中分离出来,为开发人员设计应用提供最大的灵活性。您应该始终编写接口代码。

在第五章中,我继续通过关注嵌套类型、包、静态导入和异常来探索 Java 语言。

五、掌握高级语言功能:第一部分

在第二章到第四章中,我为学习 Java 语言打下了基础。在第五章中,我在此基础上向你介绍了一些 Java 更高级的语言特性,特别是那些与嵌套类型、包、静态导入和异常相关的特性。其他高级语言特性将在第六章中介绍。

掌握嵌套类型

在任何类之外声明的类被称为顶级类。Java 还支持嵌套类,这些类被声明为其他类或作用域的成员。嵌套类有助于实现顶级类架构。

有四种嵌套类:静态成员类、非静态成员类、匿名类和本地类。后三个类别被称为内部类

在这一节中,我将向您介绍静态成员类和内部类。对于每一种嵌套类,我都为您提供了一个简短的介绍、一个抽象的示例和一个更实用的示例。然后,我简要分析了在类中嵌套接口的主题。

静态成员类

一个静态成员类是一个封闭类的静态成员。虽然是封闭的,但它没有该类的封闭实例,并且不能访问封闭类的实例字段和调用其实例方法。然而,它可以访问封闭类的静态字段并调用其静态方法,甚至是那些被声明为私有的成员。清单 5-1 展示了一个静态成员类声明。

清单 5-1 。声明为静态成员类

class EnclosingClass
{
   private static int i;

   private static void m1()
   {
      System.out.println(i);
   }

   static void m2()
   {
      EnclosedClass.accessEnclosingClass();
   }

   static class EnclosedClass
   {
      static void accessEnclosingClass()
      {
         i = 1;
         m1();
      }

      void accessEnclosingClass2()
      {
         m2();
      }
   }
}

清单 5-1 声明了一个名为 EnclosingClass 的顶级类,具有类字段 i ,类方法 m1() 和 m2() ,以及静态成员类 EnclosedClass 。另外, EnclosedClass 声明了类方法 accessEnclosingClass()和实例方法 accessEnclosingClass 2()。

因为 accessEnclosingClass() 被声明为 static , m2() 必须加上前缀 EnclosedClass 和成员访问操作符才能调用这个方法。

清单 5-2 给出了一个应用类的源代码,演示了如何调用 EnclosedClass 的 accessEnclosingClass() 类方法,实例化 EnclosedClass 并调用其 accessEnclosingClass2() 实例方法。

清单 5-2 。调用静态成员类的类和实例方法

public class SMCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass.EnclosedClass.accessEnclosingClass(); // Output: 1
      EnclosingClass.EnclosedClass ec = new EnclosingClass.EnclosedClass();
      ec.accessEnclosingClass2(); // Output: 1
   }
}

清单 5-2 的 main() 方法揭示了你必须在一个封闭类的名字前加上其封闭类的名字来调用一个类方法,例如, EnclosingClass。enclosed class . accessenclosingclass();。

这个清单还揭示了在实例化封闭类时,必须在封闭类的名称前加上其封闭类的名称,例如, EnclosingClass。enclosed class EC = new enclosing class。enclosed class();。然后,您可以以正常方式调用实例方法,例如,EC . accessenclosingclass 2();。

静态成员类有它们的用途。例如,清单 5-3 的 Double 和 Float 静态成员类提供了它们的封闭 Rectangle 类的不同实现。 Float 版本因其 32 位 float 字段而占用更少的内存,而 Double 版本因其 64 位 double 字段而提供更高的精度。

清单 5-3 。使用静态成员类声明其封闭类的多个实现

abstract class Rectangle
{
   abstract double getX();
   abstract double getY();
   abstract double getWidth();
   abstract double getHeight();

   static class Double extends Rectangle
   {
      private double x, y, width, height;

      Double(double x, double y, double width, double height)
      {
         this.x = x;
         this.y = y;
         this.width = width;
         this.height = height;
      }

      double getX() { return x; }
      double getY() { return y; }
      double getWidth() { return width; }
      double getHeight() { return height; }
   }

   static class Float extends Rectangle
   {
      private float x, y, width, height;

      Float(float x, float y, float width, float height)
      {
         this.x = x;
         this.y = y;
         this.width = width;
         this.height = height;
      }

      double getX() { return x; }
      double getY() { return y; }
      double getWidth() { return width; }
      double getHeight() { return height; }
   }

   // Prevent subclassing. Use the type-specific Double and Float
   // implementation subclass classes to instantiate.
   private Rectangle() {}

   boolean contains(double x, double y)
   {
      return (x >= getX() && x < getX() + getWidth()) &&
             (y >= getY() && y < getY() + getHeight());
   }
}

清单 5-3 的矩形类展示了嵌套的子类。每个 Double 和 Float 静态成员类继承抽象 Rectangle 类,提供私有浮点或双精度浮点字段并覆盖 Rectangle 的抽象方法以将这些字段的值作为 double 返回

矩形是抽象的,因为实例化这个类没有意义。因为用新的实现直接扩展矩形也没有意义(双双和浮动嵌套子类应该足够了),所以它的默认构造函数被声明为私有。相反,你必须实例化矩形。浮动(为了节省内存)或矩形。双(当需要精度时),如清单 5-4 中的所示。

清单 5-4 。创建和使用不同的矩形实现

public class SMCDemo
{
   public static void main(String[] args)
   {
      Rectangle r = new Rectangle.Double(10.0, 10.0, 20.0, 30.0);
      System.out.println("x = " + r.getX());
      System.out.println("y = " + r.getY());
      System.out.println("width = " + r.getWidth());
      System.out.println("height = " + r.getHeight());
      System.out.println("contains(15.0, 15.0) = " + r.contains(15.0, 15.0));
      System.out.println("contains(0.0, 0.0) = " + r.contains(0.0, 0.0));
      System.out.println();
      r = new Rectangle.Float(10.0f, 10.0f, 20.0f, 30.0f);
      System.out.println("x = " + r.getX());
      System.out.println("y = " + r.getY());
      System.out.println("width = " + r.getWidth());
      System.out.println("height = " + r.getHeight());
      System.out.println("contains(15.0, 15.0) = " + r.contains(15.0, 15.0));
      System.out.println("contains(0.0, 0.0) = " + r.contains(0.0, 0.0));
   }
}

清单 5-4 首先通过新矩形实例化矩形的 Double 子类。Double(10.0,10.0,20.0,30.0) 然后调用它的各种方法。继续,清单 5-4 通过新矩形实例化矩形的浮动子类。在此实例上调用矩形方法之前,Float(10.0f,10.0f,20.0f,30.0f) 。

编译两个清单(javac SMCDemo.java 或 javac *。java )并运行应用( java SMCDemo )。然后,您将看到以下输出:

x = 10.0
y = 10.0
width = 20.0
height = 30.0
contains(15.0, 15.0) = true
contains(0.0, 0.0) = false

x = 10.0
y = 10.0
width = 20.0
height = 30.0
contains(15.0, 15.0) = true
contains(0.0, 0.0) = false

Java 的类库包含许多静态成员类。例如, java.lang.Character 类包含一个名为子集的静态成员类,其实例代表 Unicode 字符集的子集。其他示例包括 Java . util . abstract map . simple entry 和 Java . io . objectinputstream . getfield。

注意当你编译一个包含静态成员类的封闭类时,编译器为静态成员类创建一个类文件,其名称由封闭类的名称、美元符号字符和静态成员类的名称组成。例如,编译清单 5-1 ,除了 EnclosingClass.class 之外,您还会发现 enclosing class$enclosed class . class。这种格式也适用于非静态成员类。

非静态成员类

非静态成员类是封闭类的非静态成员。非静态成员类的每个实例都隐式地与封闭类的一个实例相关联。非静态成员类的实例方法可以调用封闭类中的实例方法,并访问封闭类实例的非静态字段。清单 5-5 给出了一个非静态成员类声明。

清单 5-5 。声明为非静态成员类

class EnclosingClass
{
   private int i;

   private void m()
   {
      System.out.println(i);
   }

   class EnclosedClass
   {
      void accessEnclosingClass()
      {
         i = 1;
         m();
      }
   }
}

清单 5-5 声明了一个名为 EnclosingClass 的顶级类,带有实例字段 i ,实例方法 m1() ,以及非静态成员类 EnclosedClass 。此外, EnclosedClass 声明了实例方法 accessEnclosingClass() 。

因为 accessEnclosingClass() 是非静态的, EnclosedClass 必须被实例化才能调用该方法。这个实例化必须通过 EnclosingClass 的实例发生。清单 5-6 完成了这些任务。

清单 5-6 。调用非静态成员类的实例方法

public class NSMCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass ec = new EnclosingClass();
      ec.new EnclosedClass().accessEnclosingClass(); // Output: 1
   }
}

清单 5-6 的 main() 方法首先实例化 EnclosingClass ,并将其引用保存在局部变量 ec 中。然后, main() 使用这个引用作为 new 操作符的前缀来实例化 EnclosedClass ,然后使用其引用来调用 accessEnclosingClass() ,后者输出 1 。

注意在 new 前面加上对封闭类的引用是很少见的。相反,您通常会从构造函数或其封闭类的实例方法中调用封闭类的构造函数。

假设您需要维护一个待办事项列表,其中每个项目都由一个名称和一个描述组成。经过一番思考后,您创建了清单 5-7 中的的 ToDo 类来实现这些项目。

清单 5-7 。将待办事项实现为名称-描述对

class ToDo
{
   private String name;
   private String desc;

   ToDo(String name, String desc)
   {
      this.name = name;
      this.desc = desc;
   }

   String getName()
   {
      return name;
   }

   String getDesc()
   {
      return desc;
   }

   @Override
   public String toString()
   {
      return "Name = " + getName() + ", Desc = " + getDesc();
   }
}

接下来创建一个 ToDoList 类来存储 ToDo 实例。 ToDoList 使用其 ToDoArray 非静态成员类在一个可增长数组中存储 ToDo 实例——你不知道会存储多少个实例,而 Java 数组是固定长度的。参见清单 5-8 。

清单 5-8 。在一个 ToDoArray 实例中最多存储两个 ToDo 实例

class ToDoList
{
   private ToDoArray toDoArray;
   private int index = 0;

   ToDoList()
   {
      toDoArray = new ToDoArray(2);
   }

   boolean hasMoreElements()
   {
      return index < toDoArray.size();
   }

   ToDo nextElement()
   {
      return toDoArray.get(index++);
   }

   void add(ToDo item)
   {
      toDoArray.add(item);
   }

   private class ToDoArray
   {
      private ToDo[] toDoArray;
      private int index = 0;

      ToDoArray(int initSize)
      {
         toDoArray = new ToDo[initSize];
      }

      void add(ToDo item)
      {
         if (index >= toDoArray.length)
         {
            ToDo[] temp = new ToDo[toDoArray.length*2];
            for (int i = 0; i < toDoArray.length; i++)
               temp[i] = toDoArray[i];
            toDoArray = temp;
         }
         toDoArray[index++] = item;
      }

      ToDo get(int i)
      {
         return toDoArray[i];
      }

      int size()
      {
         return index;
      }
   }
}

除了提供一个 add() 方法来将 ToDo 实例存储在 ToDoArray 实例中, ToDoList 还提供了 hasmorelements()和 nextElement() 方法来迭代并返回存储的实例。清单 5-9 展示了这些方法。

清单 5-9 。创建并迭代 ToDo 实例的 ToDo listT4

public class NSMCDemo
{
   public static void main(String[] args)
   {
      ToDoList toDoList = new ToDoList();
      toDoList.add(new ToDo("#1", "Do laundry."));
      toDoList.add(new ToDo("#2", "Buy groceries."));
      toDoList.add(new ToDo("#3", "Vacuum apartment."));
      toDoList.add(new ToDo("#4", "Write report."));
      toDoList.add(new ToDo("#5", "Wash car."));
      while (toDoList.hasMoreElements())
         System.out.println(toDoList.nextElement());
   }
}

编译所有三个清单(javac NSMCDemo.java 或 javac *)。java )并运行应用( java NSMCDemo )。然后,您将看到以下输出:

Name = #1, Desc = Do laundry.
Name = #2, Desc = Buy groceries.
Name = #3, Desc = Vacuum apartment.
Name = #4, Desc = Write report.
Name = #5, Desc = Wash car.

Java 的类库提供了许多非静态成员类的例子。例如, java.util 包的 HashMap 类声明私有的 HashIterator 、 ValueIterator 、 KeyIterator 和 EntryIterator 类,用于迭代 HashMap 的值、键和条目。(我会在第九章的中讨论散列表。)

注意封闭类中的代码可以通过用封闭类的名称和成员访问操作符限定保留字 this 来获得对其封闭类实例的引用。例如,如果 accessEnclosingClass() 中的代码需要获得对其 EnclosingClass 实例的引用,它将指定 EnclosingClass.this 。

匿名类

一个匿名类 是一个没有名字的类。此外,它不是其封闭类的成员。相反,匿名类被同时声明(作为类的匿名扩展或作为接口的匿名实现)并在任何合法指定表达式的地方被实例化。清单 5-10 展示了一个匿名的类声明和实例化。

清单 5-10 。声明并实例化一个扩展类的匿名类

abstract class Speaker
{
   abstract void speak();
}

public class ACDemo
{
   public static void main(final String[] args)
   {
      new Speaker()
      {
         String msg = (args.length == 1) ? args[0] : "nothing to say";

        @Override
         void speak()
         {
            System.out.println(msg);
         }
      }
      .speak();
   }
}

清单 5-10 引入了一个名为演讲者 的抽象类和一个名为 ACDemo 的具体类。后一个类的 main() 方法声明了一个匿名类,它扩展了 Speaker 并覆盖了它的 speak() 方法。当这个方法被调用时,它输出 main() 的第一个命令行参数,或者在没有参数时输出一个默认消息。

匿名类没有构造函数(因为匿名类没有名字)。但是,它的 classfile 包含一个执行实例初始化的 < init > () 方法。这个方法调用超类的 noargument 构造函数(在任何其他初始化之前),这就是在 new 之后指定 Speaker() 的原因。

匿名类实例应该能够访问周围范围的局部变量和参数。但是,实例可能比设计它的方法活得长(由于将实例的引用存储在字段中),并在方法返回后尝试访问不再存在的局部变量和参数。

因为 Java 不允许这种非法访问,这很可能会使虚拟机崩溃,所以它只允许匿名类实例访问被声明为 final 的局部变量和参数(参见清单 5-10 )。在匿名类实例中遇到最终的局部变量/参数名时,编译器会做两件事之一:

  • 如果变量的类型是原语类型(例如, int 或 double ,编译器会用变量的只读值替换其名称。
  • 如果变量的类型是引用(例如,字符串),编译器会在类文件中引入一个合成变量(一个人造变量)和代码,该代码将本地变量/参数的引用存储在合成变量中。

清单 5-11 展示了另一种匿名类声明和实例化。

清单 5-11 。声明并实例化一个实现接口的匿名类

interface Speakable
{
   void speak();
}

public class ACDemo
{
   public static void main(final String[] args)
   {
      new Speakable()
      {
         String msg = (args.length == 1) ? args[0] : "nothing to say";

         @Override
         public void speak()
         {
            System.out.println(msg);
         }
      }
      .speak();
   }
}

清单 5-11 与清单 5-10 非常相似。然而,这个清单的匿名类实现了一个名为 Speakable 的接口,而不是子类化一个 Speaker 类。除了 < init > () 方法调用 java.lang.Object() (接口没有构造函数)之外,清单 5-11 的行为类似于清单 5-10 。

尽管匿名类没有构造函数,但是您可以提供一个实例初始化器来处理复杂的初始化。例如, new Office() {{addEmployee(新员工(“John Doe”));}};实例化 Office 的匿名子类,并通过调用 Office 的 addEmployee() 方法向该实例添加一个 Employee 对象。

为了方便起见,您经常会发现自己在创建和实例化匿名类。例如,假设您需要返回所有带有的文件名的列表。java 后缀。下面的例子向您展示了匿名类如何使用 java.io 包的文件和 FilenameFilter 类来简化实现这个目标:

String[] list = new File(directory).list(new FilenameFilter()
                {
                   @Override
                   public boolean accept(File f, String s)
                   {
                      return s.endsWith(".java");
                   }
                });

本地课程

一个局部类是一个在声明局部变量的任何地方声明的类。此外,它的作用域与局部变量相同。与匿名类不同,局部类有一个名字,可以重用。像匿名类一样,局部类只有在非静态上下文中使用时才有封闭实例。

局部类实例可以访问周围范围的局部变量和参数。然而,被访问的局部变量和参数必须被声明为最终的。例如,清单 5-12 的局部类声明访问一个最终参数和一个最终局部变量。

清单 5-12 。宣布为地方阶级

class EnclosingClass
{
   void m(final int x)
   {
      final int y = x * 2;
      class LocalClass
      {
         int a = x;
         int b = y;
      }
      LocalClass lc = new LocalClass();
      System.out.println(lc.a);
      System.out.println(lc.b);
   }
}

清单 5-12 用其实例方法 m() 声明了一个名为 LocalClass 的局部类 EnclosingClass 。这个局部类声明了一对实例字段( a 和 b ),当 LocalClass 被实例化: new EnclosingClass()时,它们被初始化为 final 参数 x 和 final 局部变量 y 的值。m(10);比如。

清单 5-13 展示了这个局部类。

清单 5-13 。示范一个地方班

public class LCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass ec = new EnclosingClass();
      ec.m(10);
   }
}

实例化 EnclosingClass ,清单 5-13 的 main() 方法调用 m(10) 。被调用的 m() 方法将这个参数乘以 2;实例化 LocalClass ,其 < init > () 方法将参数和双精度值分配给它的一对实例字段(代替使用构造函数来执行此任务);并输出 LocalClass 实例字段。以下输出结果:

10
20

局部类有助于提高代码的清晰度,因为它们可以被移动到离需要它们的地方更近的地方。例如,清单 5-14 声明了一个迭代器 接口和一个 ToDoList 类,其 iterator() 方法返回其局部 Iter 类的一个实例作为迭代器实例(因为 Iter 实现了迭代器)。

清单 5-14 。迭代器接口 和 ToDoList 类

interface Iterator
{
   boolean hasMoreElements();
   Object nextElement();
}

class ToDoList
{
   private ToDo[] toDoList;
   private int index = 0;

   ToDoList(int size)
   {
      toDoList = new ToDo[size];
   }

   Iterator iterator()
   {
      class Iter implements Iterator
      {
         int index = 0;

         @Override
         public boolean hasMoreElements()
         {
            return index < toDoList.length;
         }

         @Override
         public Object nextElement()
         {
            return toDoList[index++];
         }
      }
      return new Iter();
   }

   void add(ToDo item)
   {
      toDoList[index++] = item;
   }
}

清单 5-15 演示了迭代器,重构后的 ToDoList 类,以及清单 5-7 的 ToDo 类。

清单 5-15 。使用可重用迭代器 创建并迭代 ToDo 实例的 ToDoList

public class LCDemo
{
   public static void main(String[] args)
   {
      ToDoList toDoList = new ToDoList(5);
      toDoList.add(new ToDo("#1", "Do laundry."));
      toDoList.add(new ToDo("#2", "Buy groceries."));
      toDoList.add(new ToDo("#3", "Vacuum apartment."));
      toDoList.add(new ToDo("#4", "Write report."));
      toDoList.add(new ToDo("#5", "Wash car."));
      Iterator iter = toDoList.iterator();
      while (iter.hasMoreElements())
         System.out.println(iter.nextElement());
   }
}

从迭代器()返回的迭代器实例返回待办事项的顺序与它们被添加到列表中的顺序相同。虽然只能使用一次返回的迭代器对象,但是每当需要新的迭代器对象时,都可以调用迭代器()。这个功能比清单 5-9 中的单次迭代器有了很大的改进。

类内的接口

接口可以嵌套在类中。一旦声明,一个接口被认为是静态的,即使它没有声明为 static 。例如,清单 5-16 声明了一个名为 X 的封闭类以及两个名为 A 和 B 的嵌套静态接口。

清单 5-16 。在类中声明一对接口

class X
{
   interface A
   {
   }

   static interface B
   {
   }
}

你可以用同样的方式访问清单 5-16 的接口。例如,您可以指定 C 类实现 X.A {} 或 D 类实现 X.B {} 。

与嵌套类一样,嵌套接口通过由嵌套类实现来帮助实现顶级类架构。总的来说,这些类型是嵌套的,因为它们不能(如在清单 5-14 的 Iter 局部类中)或者不需要出现在与顶级类相同的级别上并污染它的包命名空间。

注意在第四章的接口介绍中,我向你展示了如何在接口体中声明常量和方法头。也可以在接口体中声明接口和类。因为这样做的理由很少( java.util.Map.Entry 是一个例外),所以最好避免在接口中嵌套接口和/或类。

母带包

层次结构根据项目之间存在的层次关系来组织项目。例如,一个文件系统可能包含一个带有多个年份子目录的 taxes 目录,其中每个子目录包含与该年相关的税务信息。此外,封闭类可能包含多个嵌套类,这些嵌套类只在封闭类的上下文中有意义。

分层结构也有助于避免名称冲突。例如,在非分层文件系统(由单个目录组成)中,两个文件不能同名。相比之下,分层文件系统允许同名文件存在于不同的目录中。类似地,两个封闭类可以包含同名的嵌套类。名称冲突并不存在,因为项目被划分到不同的名称空间

Java 还支持将顶级用户定义类型划分为多个名称空间,以更好地组织这些类型,并防止名称冲突。Java 使用包来完成这些任务。

在这一节中,我将向您介绍软件包。在定义了这个术语并解释了为什么包名必须是惟一的之后,我给出了 package 和 import 语句。接下来我将解释虚拟机是如何搜索包和类型的,然后给出一个例子来展示如何使用包。在本节的最后,我将向您展示如何将一个类文件包封装到 JAR 文件中。

提示除了最普通的顶级类型和(通常)那些作为应用入口点的类(它们有 main() 方法),你应该考虑将你的类型(尤其是当它们可重用的时候)存储在包中。现在就养成这个习惯,因为在开发 Android 应用时,你会大量使用软件包。每个 Android 应用都必须存储在自己独特的包中。

什么是包?

一个是一个惟一的名称空间,可以包含顶级类、其他顶级类型和子包的组合。只有被声明为 public 的类型才能从包外被访问。此外,描述类接口的常量、构造函数、方法和嵌套类型必须声明为 public 才能从包外访问。

每个包都有一个名称,它必须是一个不可保留的标识符。成员访问操作符将包名与子包名分开,并将包或子包名与类型名分开。例如, graphics.shapes.Circle 中的两个成员访问操作符将包名 graphics 与 shapes 子包名分开,并将子包名 shapes 与 Circle 类型名分开。

注意Oracle 和 Google Android 的每个标准类库都将其许多类和其他顶级类型组织到多个包中。这些包中有许多是标准 java 包的子包。例子有 java.io (与输入/输出操作相关的类型) java.lang (面向语言的类型)java.net(面向网络的类型) java.util (工具类型)。

包名必须是唯一的

假设你有两个不同的 graphics.shapes 包,假设每个 shapes 子包包含一个接口不同的 Circle 类。当编译器遇到 system . out . println(new Circle(10.0,20.0,30.0)。area());在源代码中,需要验证 area() 方法存在。

编译器将搜索所有可访问的包,直到找到包含圆类的 graphics.shapes 包。如果找到的包包含适当的带有 area() 方法的 Circle 类,那么一切正常。否则,如果 Circle 类没有 area() 方法,编译器会报错。

这个场景说明了选择唯一的包名的重要性。具体来说,顶层包名必须是唯一的。选择这个名字的惯例是取你的互联网域名,然后反过来。例如,我会选择 ca.tutortutor 作为我的顶级包名,因为 tutortutor.ca 是我的域名。然后我会指定 ca . tutortutor . graphics . shapes . Circle 来访问 Circle 。

注意反向互联网域名并不总是有效的包名。它的一个或多个组件名可能以数字(【6.com】)开头,包含连字符()或其他非法字符(【aq-x.com】,或者是 Java 的保留字之一(【int.com】)。惯例要求在数字前加上下划线( com)。6 ),用下划线( com.aq_x )替换非法字符,用下划线( com.int )作为保留字的后缀。

程序包语句

package 语句标识源文件的类型所在的包。该语句由保留字 package 组成,后面是成员访问操作符分隔的包和子包名称列表,后面是分号。

例如,包图形;指定源文件的类型位于名为 graphics 的包中,包 graphics.shapes 指定源文件的类型位于图形包的形状子包中。

按照惯例,包名用小写表示。当名称由多个单词组成时,除了第一个单词以外,每个单词都要大写。

源文件中只能出现一个 package 语句。当它存在时,除了注释之外,在该语句之前不能有任何内容。

注意在源文件中指定多个 package 语句,或者在 package 语句上方放置除注释之外的任何内容,都会导致编译器报告错误。

Java 实现将包和子包的名称映射到同名的目录。例如,实现会将图形映射到名为图形的目录,并将图形.形状映射到图形的形状子目录。Java 编译器将实现包类型的类文件存储在相应的目录中。

注意当一个源文件不包含 package 语句时,该源文件的类型被称为属于未命名包。这个包对应于当前目录。

进口声明

想象一下,必须在源代码中为该类型的每次出现重复指定 ca . tutor tutor . graphics . shapes . circle 或其他冗长的包限定类型名。Java 提供了一种替代方法,让您不必指定包的细节。这个替代语句就是 import 语句。

import 语句通过告诉编译器在编译过程中何处查找非限定类型名来从包中导入类型。该语句由保留字 import 组成,后面是成员访问操作符分隔的包和子包名称列表,后面是类型名或 * (星号),后面是分号。

  • 符号是一个通配符,代表所有非限定的类型名。它告诉编译器在 import 语句的指定包中查找这样的名称,除非在以前搜索的包中找到了类型名。(使用通配符不会影响性能或导致代码膨胀,但会导致名称冲突,您将会看到这一点。)

比如导入 ca . tutortutor . graphics . shapes . circle;告诉编译器 ca . tutortutor . graphics . shapes 包中存在不合格的 Circle 类。同样,导入 ca . tutortutor . graphics . shapes . *;告诉编译器在遇到一个矩形类、一个三角形类、甚至一个雇员类(如果还没有找到雇员)时在这个包中查找。

提示你应该避免使用 * 通配符,这样其他开发人员可以很容易地看到源代码中使用了哪些类型。

因为 Java 是区分大小写的,所以在 import 语句中指定的包和子包名称的大小写必须与 package 语句中使用的大小写相同。

当导入语句出现在源代码中时,只有包语句和注释可以在它们之前。

注意在 import 语句上放置除 package 语句、import 语句、static import 语句(稍后讨论)和注释之外的任何内容都会导致编译器报告错误。

当使用通配符版本的 import 语句时,您可能会遇到名称冲突,因为任何非限定的类型名都与通配符匹配。例如,您有 graphics.shapes 和 geometry 包,每个包都包含一个 Circle 类,源代码以 import geometry 开始。*;和导入 graphics . shape . *;语句,并且它还包含一个不合格出现的圆。因为编译器不知道 Circle 是指 geometry 的 Circle 类还是 graphics.shape 的 Circle 类,所以报错。您可以通过用正确的包名限定圆圈来解决这个问题。

注意编译器自动从 java.lang 包中导入字符串类和其他类型,这就是为什么不需要用 java.lang 限定字符串的原因。

搜索包和类型

第一次开始使用包的 Java 新手经常会因为“没有找到类定义”和其他错误而感到沮丧。通过理解虚拟机如何搜索包和类型,可以部分避免这种挫折。

在这一节中,我将解释搜索过程是如何工作的。要理解这个过程,需要认识到编译器是一个特殊的 Java 应用,它在虚拟机的控制下运行。此外,还有两种不同形式的搜索。

编译时搜索

当编译器在源代码中遇到类型表达式(如方法调用)时,它必须找到该类型的声明,以验证表达式是合法的(例如,类型的类中存在一个方法,其参数类型与方法调用中传递的参数类型相匹配)。

编译器首先搜索 Java 平台包(包含类库类型)。然后它搜索扩展包(寻找扩展类型)。当在启动虚拟机时指定了 -sourcepath 命令行选项时(通过 javac ,编译器搜索指定路径的源文件。

注意 Java 平台包存储在 rt.jar 和其他一些重要的 jar 文件中。扩展包存储在一个名为 ext 的特殊扩展目录中。

否则,编译器会在用户类路径中(按从左到右的顺序)搜索包含该类型的第一个用户类文件或源文件。如果没有用户类路径,则搜索当前目录。如果没有匹配的包或者仍然找不到类型,编译器会报告一个错误。否则,编译器会将包信息记录在类文件中。

注意用户类路径是通过用于启动虚拟机的 -classpath 选项指定的,或者当不存在时,通过 CLASSPATH 环境变量指定。

运行时搜索

当编译器或任何其他 Java 应用运行时,虚拟机将遇到类型,并且必须通过称为类加载器的特殊代码加载它们相关的类文件。虚拟机将使用先前存储的与所遇到的类型相关联的包信息来搜索该类型的类文件。

虚拟机搜索 Java 平台包,然后是扩展包,接着是用户类路径(从左到右的顺序)以找到包含该类型的第一个类文件。如果没有用户类路径,则搜索当前目录。如果没有匹配的包或找不到类型,则报告“找不到类定义”错误。否则,类文件被加载到内存中。

注意无论是使用 -classpath 选项还是 CLASSPATH 环境变量来指定用户类路径,都有一个特定的格式必须遵循。在 Windows 下,这种格式表示为 path 1;path2...,其中 path1 、 path2 等是包目录的位置。在 Unix 和 Linux 下,这种格式变为 path1:path2:...。

玩包

假设您的应用需要将消息记录到控制台、文件或另一个目的地。它可以在日志库的帮助下完成这项任务。我对这个库的实现包括一个名为 Logger 的接口,一个名为 LoggerFactory 的抽象类,以及一对名为控制台和文件的包私有类。

注意我介绍的日志库是抽象工厂设计模式的一个例子,它在第 87 页的设计模式:可重用面向对象软件的元素中有介绍,作者是 Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides (Addison-Wesley,1995;ISBN: 0201633612)。

清单 5-17 展示了记录器接口,它描述了记录消息的对象。

清单 5-17 。描述通过记录器接口记录消息的对象

package logging;

public interface Logger
{
   boolean connect();
   boolean disconnect();
   boolean log(String msg);
}

每个 connect() 、、disconnect() 和 log() 方法在成功时返回 true,在失败时返回 false。(在本章的后面,你会发现一种处理失败的更好的技巧。)这些方法没有显式地声明为 public ,因为接口的方法是隐式的 public 。

清单 5-18 展示了 LoggerFactory 抽象类。

清单 5-18 。获取用于将消息记录到特定目的地的记录器

package logging;

public abstract class LoggerFactory
{
   public final static int CONSOLE = 0;
   public final static int FILE = 1;

   public static Logger newLogger(int dstType, String... dstName)
   {
      switch (dstType)
      {
         case CONSOLE: return new Console(dstName.length == 0 ? null
                                                              : dstName[0]);
         case FILE   : return new File(dstName.length == 0 ? null
                                                           : dstName[0]);
         default     : return null;
      }
   }
}

newLogger() 返回一个记录器对象,用于将消息记录到适当的目的地。它使用 varargs(可变参数)特性(参见第三章)来选择性地接受额外的字符串参数,用于那些需要参数的目的地类型。例如,文件需要一个文件名。

清单 5-19 给出了包私有控制台类——这个类不能在日志包中的类之外访问,因为保留字类前面没有保留字公共。

清单 5-19 。将消息记录到控制台

package logging;

class Console implements Logger
{
   private String dstName;

   Console(String dstName)
   {
      this.dstName = dstName;
   }

   @Override
   public boolean connect()
   {
      return true;
   }

   @Override
   public boolean disconnect()
   {
      return true;
   }

   @Override
   public boolean log(String msg)
   {
      System.out.println(msg);
      return true;
   }
}

控制台的 package-private 构造函数保存其参数,该参数很可能是 null ,因为不需要字符串参数。也许控制台的未来版本会使用这个参数来标识多个控制台窗口中的一个。

清单 5-20 呈现了包私有文件类。

清单 5-20 。将消息记录到文件中(最终)

package logging;

class File implements Logger
{
   private String dstName;

   File(String dstName)
   {
      this.dstName = dstName;
   }

   @Override
   public boolean connect()
   {
      if (dstName == null)
         return false;
      System.out.println("opening file " + dstName);
      return true;
   }

   @Override
   public boolean disconnect()
   {
      if (dstName == null)
         return false;
      System.out.println("closing file " + dstName);
      return true;
   }

   @Override
   public boolean log(String msg)
   {
      if (dstName == null)
         return false;
      System.out.println("writing "+msg+" to file " + dstName);
      return true;
   }
}

与控制台不同,文件需要一个非空参数。每个方法首先验证这个参数不是 null 。如果参数为 null ,该方法返回 false 表示失败。(在第十一章的中,我重构了文件以包含适当的文件写入代码。)

日志库允许我们在应用中引入可移植的日志代码。除了调用 newLogger() 之外,不管日志记录的目的地是哪里,这段代码都将保持不变。清单 5-21 展示了一个测试这个库的应用。

清单 5-21 。测试日志库

import logging.Logger;
import logging.LoggerFactory;

public class TestLogger
{
   public static void main(String[] args)
   {
      Logger logger = LoggerFactory.newLogger(LoggerFactory.CONSOLE);
      if (logger.connect())
      {
         logger.log("test message #1");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to console-based logger");
      logger = LoggerFactory.newLogger(LoggerFactory.FILE, "x.txt");
      if (logger.connect())
      {
         logger.log("test message #2");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to file-based logger");
      logger = LoggerFactory.newLogger(LoggerFactory.FILE);
      if (logger.connect())
      {
         logger.log("test message #3");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to file-based logger");
   }
}

按照步骤(假设已经安装了 JDK)创建日志包和测试日志应用,并运行该应用:

  1. 创建一个新目录,并使该目录成为当前目录。
  2. 在当前目录下创建一个日志目录。
  3. 将清单 5-17 复制到日志目录下一个名为【Logger.java 的文件中。
  4. 将清单 5-18 复制到日志目录下一个名为【LoggerFactory.java 的文件中。
  5. 将清单 5-19 复制到日志目录下一个名为【Console.java 的文件中。
  6. 将清单 5-20 复制到日志目录下一个名为【File.java 的文件中。
  7. 将清单 5-21 中的复制到当前目录中一个名为 TestLogger.java 的文件中。
  8. 执行 javac TestLogger.java,它也编译记录器的源文件。
  9. 执行 java 测试记录器。

完成上一步后,您应该观察到来自 TestLogger 应用的以下输出:

test message #1
opening file x.txt
writing test message #2 to file x.txt
closing file x.txt
cannot connect to file-based logger

当测井被移动到另一个位置时会发生什么?例如,将日志移动到根目录并运行测试日志。现在,您将看到一条错误消息,提示虚拟机没有找到日志包及其 LoggerFactory classfile。

您可以通过在运行 java 工具时指定 -classpath 或者将日志包的位置添加到 CLASSPATH 环境变量中来解决这个问题。例如,我选择在下面特定于 Windows 的命令行中使用 -classpath (我觉得这样更方便):

java -classpath \;. TestLogger

反斜杠代表 Windows 中的根目录。(我可以指定一个正斜杠作为替代。)此外,句点代表当前目录。如果它丢失了,虚拟机就会抱怨找不到 TestLogger classfile。

提示如果您发现一条虚拟机报告找不到应用类文件的错误消息,请尝试在类路径后面附加一个句点字符。这样做可能会解决问题。

包和 JAR 文件

JDK 提供了一个 jar 工具,用于归档 jar (Java 归档)文件中的类文件,也用于提取 JAR 文件的类文件。您可以将包存储在 JAR 文件中,这可能不足为奇,因为这极大地简化了基于包的类库的分发。

为了向您展示在 JAR 文件中存储一个包是多么容易,您将创建一个 logger.jar 文件,其中包含日志包的四个类文件( Logger.class 、 LoggerFactory.class 、 Console.class 和 File.class )。完成以下步骤来完成此任务:

  1. 确保当前目录包含之前创建的日志目录及其四个类文件。
  2. 执行 jar cf logger.jar logging*。类别。您也可以执行 jar cf logger.jar logging/*。类别。( c 选项代表“创建新的档案”, f 选项代表“指定档案文件名”。)

现在,您应该在当前目录中找到一个 logger.jar 文件。为了证明这个文件包含四个类文件,执行 jar tf logger.jar 。( t 选项代表“目录列表”。)

您可以通过将 logger.jar 添加到类路径来运行 TestLogger.class 。比如可以通过 java -classpath logger.jar 在 Windows 下运行 test logger;。测试记录器。

注意如果您需要日志功能,您可以像前面演示的那样创建自己的日志框架,或者利用标准类库中包含的 java.util.logging 包。

掌握静态导入

接口应该只用于声明类型。然而,一些开发人员违反了这一原则,使用接口只导出常量。这样的接口被称为常量接口 ,清单 5-22 中的给出了一个例子。

清单 5-22 。声明常量接口

interface Directions
{
   int NORTH = 0;
   int SOUTH = 1;
   int EAST = 2;
   int WEST = 3;
}

使用常量接口的开发人员这样做是为了避免在常量名称前加上其类名(如在 Math 中)。PI ,其中 PI 是 java.lang.Math 类中的常数)。他们通过实现接口来做到这一点——参见清单 5-23 。

清单 5-23 。实现常数接口

public class TrafficFlow implements Directions
{
   public static void main(String[] args)
   {
      showDirection((int) (Math.random()* 4));
   }

   static void showDirection(int dir)
   {
      switch (dir)
      {
         case NORTH: System.out.println("Moving north"); break;
         case SOUTH: System.out.println("Moving south"); break;
         case EAST : System.out.println("Moving east"); break;
         case WEST : System.out.println("Moving west");
      }
   }
}

清单 5-23 的 TrafficFlow 类实现了方向,唯一的目的是不必指定方向。北、两个方向。向南、方向。东和方向。西。

这是一个令人震惊的接口误用。这些常量只不过是一个实现细节,不允许泄露到类的导出接口中,因为它们可能会混淆类的用户(这些常量的目的是什么?).此外,它们代表了未来的承诺:即使当类不再使用这些常量时,接口也必须保留以确保二进制兼容性。

Java 5 引入了一种替代方案,既满足了对常量接口的需求,又避免了它们的问题。这个静态导入特性允许您导入一个类的静态成员,这样您就不必用它们的类名来限定它们。它是通过对 import 语句进行如下的小修改来实现的:

import static *packagespec* . *classname* . ( *staticmembername* | * );

静态导入语句在导入后指定静态。然后,它指定一个成员访问操作符分隔的包和子包名称列表,后面是成员访问操作符和类名。再次指定成员访问操作符,后跟一个静态成员名或星号通配符。

注意在静态导入语句上放置除了 package 语句、import/static import 语句和注释之外的任何内容都会导致编译器报告错误。

您可以指定一个静态成员名称,以便只导入该名称:

import static java.lang.Math.PI;  // Import the PI static field only.
import static java.lang.Math.cos; // Import the cos() static method only.

相反,您可以指定通配符来导入所有静态成员名称:

import static java.lang.Math.*;   // Import all static members from Math.

现在,您可以引用静态成员,而不必指定类名:

System.out.println(cos(PI));

使用多个静态导入语句会导致名称冲突,从而导致编译器报告错误。例如,假设您的 geom 包包含一个 Circle 类,其中有一个名为 PI 的静态成员。现在假设你指定导入静态 Java . lang . math . *;和导入静态 geom 圆. *;在你的源文件的顶部。最后,假设你指定了 system . out . println(PI);在文件代码的某个地方。编译器报告错误,因为它不知道 PI 是属于 Math 还是属于 Circle 。

主控异常

在理想情况下,应用运行时不会发生任何不好的事情。例如,当应用需要打开文件时,文件总是存在的,应用总是能够连接到远程计算机,并且当应用需要实例化对象时,虚拟机永远不会耗尽内存。

相比之下,真实世界的应用偶尔会尝试打开不存在的文件,尝试连接到无法与之通信的远程计算机,并且需要比虚拟机所能提供的更多的内存。您的目标是编写适当响应这些和其他异常情况(异常)的代码。

在这一节中,我将向您介绍异常。在定义了这个术语之后,我看一下在源代码中表示异常。然后,我将研究抛出和处理异常的主题,并通过讨论如何在方法返回之前执行清理任务来结束本文,无论是否抛出了异常。

什么是例外?

一个异常 是与应用正常行为的背离。例如,应用试图打开一个不存在的文件进行读取。正常行为是成功打开文件并开始读取其内容。但是,当文件不存在时,无法读取该文件。

这个例子说明了一个不可避免的异常。然而,一个变通办法是可能的。例如,应用可以检测到该文件不存在,并采取替代措施,这可能包括告诉用户该问题。不可避免的例外情况,如果有可能的解决办法,一定不能忽视。

由于代码编写得不好,可能会出现异常。例如,应用可能包含访问数组中每个元素的代码。由于疏忽,数组访问代码可能试图访问一个不存在的数组元素,从而导致异常。这种异常可以通过编写正确的代码来避免。

最后,可能会发生无法阻止且没有解决方法的异常。例如,虚拟机可能耗尽内存,或者可能找不到类文件。这种被称为错误的异常非常严重,以至于无法(或者至少是不可取的)解决;应用必须终止,向用户显示一条消息,解释它终止的原因。

在源代码中表示异常

异常可以通过错误代码或对象来表示。在讨论了每一种表示并解释了为什么对象更优越之后,我将向您介绍 Java 的异常和错误类层次结构,强调检查异常和运行时异常之间的区别。我通过讨论自定义异常类来结束关于在源代码中表示异常的讨论。

错误代码与对象

在源代码中表示异常的一种方法是使用错误代码。例如,一个方法可能在成功时返回 true,在发生异常时返回 false。或者,一个方法可能在成功时返回 0,并返回一个非零的整数值来标识特定类型的异常。

开发人员传统上设计方法来返回错误代码;我在清单 5-17 的记录器接口中的三种方法中的每一种方法中都展示了这一传统。每个方法在成功时返回 true,或者返回 false 来表示异常(例如,无法连接到记录器)。

尽管必须检查方法的返回值以确定它是否代表异常,但是错误代码很容易被忽略。例如,懒惰的开发人员可能会忽略来自记录器的 connect() 方法的返回代码,并试图调用 log() 。忽略错误代码是发明一种处理异常的新方法的原因之一。

这种新方法是基于对象的。当异常发生时,表示异常的对象由异常发生时正在运行的代码创建。描述异常周围上下文的详细信息存储在对象中。稍后将检查这些细节以解决异常。

然后对象被抛出或者交给虚拟机来搜索一个处理程序,可以处理异常的代码。(如果异常是一个错误,应用不应该提供一个处理程序,因为错误是如此严重[例如,虚拟机内存不足],以至于实际上对它们无能为力。)当处理程序被定位时,它的代码被执行以提供一个解决方法。否则,虚拟机终止该应用。

注意处理异常的代码可能是错误的来源,因为它通常没有经过彻底的测试。请务必测试任何处理异常的代码。

除了太容易被忽略之外,错误代码的布尔值或整数值还不如对象名有意义。比如 fileNotFound 不言而喻,但是 false 是什么意思呢?此外,对象可以包含导致异常的信息。这些细节有助于找到合适的解决方法。

可抛出的类层次结构

Java 提供了表示不同类型异常的类的层次结构。这些类根植于 java.lang.Throwable ,是所有throwable(异常和错误对象——简称为异常和错误——可以被抛出)的终极超类。表 5-1 标识和描述了大多数可抛出的构造函数和方法。

表 5-1。 Throwable 的构造函数和方法

方法描述
Throwable()创建一个包含空详细信息和原因的 throwable。
Throwable(字符串消息)使用指定的详细消息和空原因创建一个 throwable。
Throwable(字符串消息,可抛出原因)用指定的详细消息和原因创建一个 throwable。
可投掷(可投掷原因)创建一个 throwable,其详细消息是非空原因或 null 的字符串表示形式。
【一次性填料跟踪()填写执行堆栈跟踪。这个方法记录当前线程堆栈帧的当前状态信息。(我在第八章的中讨论线程。)
Throwable getCause()返回这个抛出的原因。如果没有原因,则返回 null。
字符串 getMessage()返回 throwable 的详细信息,可能为空。
StackTraceElement [] getStackTrace()提供对由 printStackTrace() 打印的堆栈跟踪信息的编程访问,作为堆栈跟踪元素的数组,每个元素代表一个堆栈帧。
可抛出的 initCause(可抛出的原因)将此 throwable 的原因初始化为指定的值。
void printStackTrace()将这个 throwable 及其堆栈帧的回溯打印到标准错误流。
void set stack trace(stack trace element[]stack trace)设置 getStackTrace() 返回的、 printStackTrace() 打印的堆栈跟踪元素及相关方法。

一个类的公共方法调用抛出各种异常的助手方法并不少见。公共方法可能不会记录从助手方法抛出的异常,因为它们是实现细节,通常对公共方法的调用方是不可见的。

但是,因为此异常可能有助于诊断问题,所以公共方法可以将较低级别的异常包装在公共方法的契约接口中记录的较高级别的异常中。包装的异常被称为原因 ,因为它的存在导致更高级别的异常被抛出。

通过调用 Throwable(Throwable cause)或 Throwable(String message,Throwable cause) 构造函数来创建原因,它们调用 initCause() 方法来存储原因。如果你没有调用任何一个构造函数,你可以直接调用 initCause() ,但是你必须在创建 throwable 之后立即这样做。调用 getCause() 方法返回原因。

当抛出异常时,它会留下一堆未完成的方法调用。 Throwable 的构造函数调用 fillInStackTrace() 记录该堆栈跟踪信息,通过调用 printStackTrace() 输出。

getStackTrace() 方法通过将该信息作为一组 Java . lang . stacktraceelement 实例返回来提供对堆栈跟踪的编程访问——每个实例代表一个条目。 StackTraceElement 提供了返回堆栈跟踪信息的方法。例如, String getMethodName() 返回未完成方法的名称。

setStackTrace() 方法是为远程过程调用(RPC)框架(参见)和其他高级系统而设计的,允许客户端在构造 throwable 或从序列化流中读取 throwable 时覆盖由 fillInStackTrace() 生成的默认堆栈跟踪。(我会在第十一章讨论序列化。)

沿着 throwable 层次结构向下,您会遇到 java.lang.Exception 和 java.lang.Error 类,它们分别代表异常和错误。每个类都提供了四个构造函数,将它们的参数传递给它们的 Throwable 对手,但是除了那些从 Throwable 继承的方法之外,没有提供其他方法。

Exception 本身又被 Java . lang . clonenotsupportedexception(在第四章中讨论过)、 java.lang.IOException (在第十一章中讨论过)等类子类化。同样, Error 本身也是 Java . lang . assertion Error(在第六章中讨论过)、Java . lang . out of memory Error 等类的子类。

注意切勿实例化可抛出、异常或错误。产生的对象没有意义,因为它们太普通了。

检查异常与运行时异常

一个检查异常是一个异常,它代表了一个可能恢复的问题,开发者必须提供一个解决方法。开发人员应该检查(检查)代码,以确保异常在抛出的方法中得到处理,或者被明确标识为在其他地方得到处理。

异常以及除了之外的所有子类 java.lang.RuntimeException (及其子类)描述了被检查的异常。例如,CloneNotSupportedException 和 IOException 类描述了被检查的异常。(CloneNotSupportedException 不应该被检查,因为对于这种异常没有运行时解决方法。)

运行时异常是一个代表编码错误的异常。这种异常也被称为未检查异常,因为它不需要被处理或显式识别——错误必须被修复。因为这些异常可能在许多地方发生,所以强制处理它们会很麻烦。

RuntimeException 及其子类描述未检查的异常。例如,Java . lang . arithmetic exception 描述了整数被零除等算术问题。另一个例子是 Java . lang . arrayindexoutofboundsexception,当你试图访问一个负索引或者索引大于等于数组长度的数组元素时抛出。(事后看来, RuntimeException 应该被命名为 UncheckedException ,因为所有的异常都发生在运行时。)

注意许多开发人员对检查异常不满意,因为处理它们涉及到很多工作。当库提供的方法应该抛出未检查的异常时,却抛出已检查的异常,这使得问题变得更加严重。因此,许多现代语言只支持未检查的异常。

自定义异常类

您可以声明自己的异常类。在这样做之前,问问你自己,标准类库中现有的异常类是否满足你的需要。如果你找到一个合适的类,你应该重用它。(为什么要多此一举?)其他开发人员将已经熟悉现有的类,这些知识将使您的代码更容易学习。当没有现有的类满足您的需求时,考虑一下是子类化异常还是运行时异常。换句话说,您的异常类是被选中还是未被选中?根据经验,如果你认为它会描述一个编码错误,你的类应该子类化 RuntimeException 。

提示当你命名你的类时,遵循提供一个异常后缀的惯例。这个后缀表明你的类描述了一个异常。

假设您正在创建一个 Media 类,它的静态方法是执行面向媒体的工具任务。例如,一种方法将非 MP3 媒体格式的声音文件转换成 MP3 格式。此方法将被传递源文件和目标文件参数,并将源文件转换为目标文件扩展名所暗示的格式。

在执行转换之前,该方法需要验证源文件的格式是否与其文件扩展名所暗示的格式一致。如果没有协议,就必须抛出一个异常。此外,这个异常必须存储预期的和现有的媒体格式,以便处理程序在向用户显示消息时可以识别它们。

因为 Java 的类库没有提供合适的异常类,所以您决定引入一个名为 InvalidMediaFormatException 的类。检测到无效的媒体格式并不是编码错误的结果,因此您还决定扩展异常以指示该异常已被检查。清单 5-24 展示了这个类的声明。

清单 5-24 。声明自定义异常类

package media;

public class InvalidMediaFormatException extends Exception
{
   private String expectedFormat;
   private String existingFormat;

   public InvalidMediaFormatException(String expectedFormat,
                                      String existingFormat)
   {
      super("Expected format: " + expectedFormat + ", Existing format: " +
            existingFormat);
      this.expectedFormat = expectedFormat;
      this.existingFormat = existingFormat;
   }

   public String getExpectedFormat()
   {
      return expectedFormat;
   }

   public String getExistingFormat()
   {
      return existingFormat;
   }
}

InvalidMediaFormatException 提供了一个构造函数,该构造函数调用 Exception 的公共异常(字符串消息)构造函数,该构造函数带有一个包含预期格式和现有格式的详细消息。在详细消息中捕获这样的细节是明智的,因为导致异常的问题可能很难重现。

InvalidMediaFormatException 还提供了返回这些格式的 getExpectedFormat()和 getExistingFormat() 方法 。也许处理程序会在消息中向用户提供这些信息。与详细消息不同,此消息可能是本地化的*,以用户语言(法语、德语、英语等)表达。).*

*抛出异常

现在您已经创建了一个 InvalidMediaFormatException 类,您可以声明 Media 类并开始编写其 convert() 方法。此方法的初始版本验证其参数,然后验证源文件的媒体格式是否与其文件扩展名所暗示的格式一致。查看清单 5-25 。

清单 5-25 。从 convert() 方法抛出异常

package media;

import java.io.IOException;

public final class Media
{
   public static void convert(String srcName, String dstName)
      throws InvalidMediaFormatException, IOException
   {
      if (srcName == null)
         throw new NullPointerException(srcName + " is null");
      if (dstName == null)
         throw new NullPointerException(dstName + " is null");
      // Code to access source file and verify that its format matches the
      // format implied by its file extension.
      //
      // Assume that the source file's extension is RM (for Real Media) and
      // that the file's internal signature suggests that its format is
      // Microsoft WAVE.
      String expectedFormat = "RM";
      String existingFormat = "WAVE";
      throw new InvalidMediaFormatException(expectedFormat, existingFormat);
   }
}

清单 5-25 声明媒体类为最终类,因为这个实用类将只包含类方法,没有理由扩展它。

Media 的 convert() 方法将 throws InvalidMediaFormatException,IOException 追加到它的头中。一个 throws 子句标识所有被检查的异常,这些异常被抛出该方法,并且必须由其他方法处理。它由保留字 throws 组成,后跟一个逗号分隔的已检查异常类名列表,并且总是被附加到方法头。 convert() 方法的 throws 子句表明该方法能够向虚拟机抛出 InvalidMediaException 或 IOException 实例。

convert() 还演示了 throw 语句,它由保留字 throw 后跟一个 Throwable 或子类的实例组成。(您通常会实例化一个异常子类。)该语句将实例抛出给虚拟机,然后虚拟机搜索合适的处理程序来处理异常。

throw 语句的第一个用途是当空引用作为源或目标文件名传递时,抛出一个 Java . lang . nullpointerexception 实例。这种未经检查的异常通常被抛出,以指示通过传递的空引用违反了协定。例如,您不能将空文件名传递给 convert() 。

throw 语句的第二个用途是抛出一个媒体。当预期的媒体格式与现有格式不匹配时,InvalidMediaFormatException 实例无效。在这个虚构的例子中,抛出了异常,因为预期的格式是 RM,而现有的格式是 WAVE。

与 InvalidMediaFormatException 不同, NullPointerException 没有在 convert() 的 throws 子句中列出,因为 NullPointerException 实例未被检查。它们可能发生得如此频繁,以至于迫使开发人员正确处理这些异常的负担太重。相反,开发人员应该编写尽量减少这种情况发生的代码。

虽然没有从 convert() 抛出, IOException 还是列在了这个方法的 throws 子句中,为重构这个方法做准备,以便在文件处理代码的帮助下执行转换。

NullPointerException 是一种当参数被证明无效时抛出的异常。Java . lang . illegalargumentexception 类概括了非法参数场景,以包括其他类型的非法参数。例如,当数字参数为负时,下面的方法抛出一个 IllegalArgumentException 实例:

public static double sqrt(double x)
{
   if (x < 0)
      throw new IllegalArgumentException(x + " is negative");
   // Calculate the square root of x.
}

在使用 throws 子句和 throw 语句时,有一些额外的事项需要记住:

  • 您可以将 throws 子句追加到构造函数中,并在构造函数执行过程中出错时抛出异常。将不会创建结果对象。
  • 当应用的 main() 方法抛出异常时,虚拟机终止应用并调用异常的 printStackTrace() 方法 将抛出异常时等待完成的嵌套方法调用序列打印到控制台。
  • 如果超类方法声明了一个 throws 子句,重写子类方法就不必声明 throws 子句。但是,如果子类方法确实声明了 throws 子句,则该子句不得包含未包含在超类方法的 throws 子句中的已检查异常类的名称,除非它们是异常子类的名称。例如,给定超类方法 void foo()抛出 IOException {} ,覆盖子类方法可以声明为 void foo() {} 、 void foo()抛出 IOException {} ,或者 void foo()抛出 file not found exception { }—Java . io . file not found exception 类子类 IOException 。
  • 当超类的名字出现时,被检查的异常类名不需要出现在 throws 子句中。
  • 当一个方法抛出一个检查过的异常,并且没有处理这个异常或者在其 throws 子句中列出这个异常时,编译器会报告一个错误。
  • 不要在 throws 子句中包含未检查的异常类的名称。这些名称不是必需的,因为这种异常永远不会发生。此外,它们只会弄乱源代码,并可能使试图理解这些代码的人感到困惑。
  • 您可以在方法的 throws 子句中声明检查的异常类名,而无需从方法中引发该类的实例。(也许这个方法还没有完全编码。)但是,Java 要求您提供代码来处理这个异常,即使它没有被抛出。

处理异常

方法通过指定包含一个或多个适当 catch 块的 try 语句来表明其处理一个或多个异常的意图。try 语句由保留字 try 组成,后跟一个大括号分隔的主体。将引发异常的代码放入这个块中。

catch 块由保留字 catch 组成,后面是圆括号分隔的指定异常类名的单参数列表,后面是大括号分隔的主体。您将处理异常的代码放置在此块中,这些异常的类型与 catch 块的参数列表的异常类参数的类型相匹配。

catch 块紧跟在 try 块之后指定。当抛出异常时,虚拟机将搜索一个处理程序。它首先检查 catch 块,看它的参数类型是否匹配,或者是已经抛出的异常的超类类型。

如果找到了 catch 块,它的主体就会执行,并处理异常。否则,虚拟机将继续执行方法调用堆栈,查找其 try 语句包含适当 catch 块的第一个方法。除非找到 catch 块或者执行离开了 main() 方法,否则这个过程将继续。

以下示例说明了 try and catch:

try
{
   int x = 1 / 0;
}
catch (ArithmeticException ae)
{
   System.out.println("attempt to divide by zero");
}

当执行进入 try 块时,会尝试将整数 1 除以整数 0。虚拟机通过实例化算术异常 并抛出该异常来响应。然后它检测 catch 块,该块能够处理抛出的 ArithmeticException 对象,并将执行转移到该块,该块调用 System.out.println() 输出适当的消息——异常得到处理。

因为 ArithmeticException 是未检查异常类型的一个例子,并且因为未检查异常表示必须修复的编码错误,所以您通常不会捕捉到它们,如前所述。相反,您应该修复导致抛出异常的问题。

提示您可能希望使用上一节中显示的缩写样式来命名 catch 块参数。这种约定不仅会产生更有意义的面向异常的参数名( ae 表示已经抛出了算术异常),还能帮助减少编译器错误。例如,为了方便起见,通常将 catch 块的参数命名为 e 。(为什么要打长名字?)然而,当先前声明的局部变量或参数也使用 e 作为其名称时,编译器将报告错误——多个同名的局部变量和参数不能存在于同一个范围内。

处理多种异常类型

可以在 try 块后指定多个 catch 块。例如,清单 5-25 的 convert() 方法指定了一个 throws 子句,表示 convert() 可以抛出当前抛出的 InvalidMediaFormatException 和重构 convert() 时抛出的 IOException 。该重构将导致 convert() 在无法读取源文件或写入目标文件时抛出 IOException ,在无法打开源文件或创建目标文件时抛出 FileNotFoundException(是 IOException 的子类)。所有这些异常都必须被处理,如清单 5-26 所示。

清单 5-26 。处理不同种类的异常

import java.io.FileNotFoundException;
import java.io.IOException;

import media.InvalidMediaFormatException;
import media.Media;

public class Converter
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Converter srcfile dstfile");
         return;
      }
      try
      {
         Media.convert(args[0], args[1]);
      }
      catch (InvalidMediaFormatException imfe)
      {
         System.out.println("Unable to convert " + args[0] + " to " + args[1]);
         System.out.println("Expecting " + args[0] + " to conform to " +
                            imfe.getExpectedFormat() + " format.");
         System.out.println("However, " + args[0] + " conformed to " +
                            imfe.getExistingFormat() + " format.");
      }
      catch (FileNotFoundException fnfe)
      {
      }
      catch (IOException ioe)
      {
      }
   }
}

对清单 5-26 中 Media 的 convert() 方法的调用被放在一个 try 块中,因为该方法能够抛出被检查的 InvalidMediaFormatException、 IOException 或 FileNotFoundException 类的实例——被检查的异常必须通过附加到该方法的 throws 子句来处理或声明抛出。

catch(InvalidMediaFormatException imfe)块的语句旨在向用户提供一条描述性的错误消息。更复杂的应用会将这些名称本地化,以便用户可以用自己的语言阅读消息。不输出面向开发人员的详细消息,因为在这个普通的应用中不需要。

注意面向开发人员的详细消息通常没有本地化。而是用开发者的语言来表达。用户永远不会看到详细消息。

虽然没有抛出,但是需要一个针对 IOException 的 catch 块,因为这个检查过的异常类型出现在 convert() 的 throws 子句中。因为 catch (IOException ioe) 块也可以处理抛出的 FileNotFoundException 实例(因为 FileNotFoundException 子类 IOException ),所以 catch(file not found exception fnfe)块在这一点上并不是必需的,但它的存在是为了分离对无法打开文件进行读取或创建文件进行写入的情况的处理(一旦重构了 convert() 就会解决这个问题)

假设当前目录包含清单 5-26 和一个包含 InvalidMediaFormatException.java 和 Media.java 的媒体子目录,编译这个清单(javac Converter.java,它也编译媒体的源文件,并运行应用,如 java Converter A B 所示。转换器通过呈现以下输出做出响应:

Unable to convert A to B
Expecting A to conform to RM format.
However, A conformed to WAVE format.

清单 5-26 的空 FileNotFoundException 和 IOException catch 块说明了一个常见的问题,即让 catch 块为空是因为它们不方便编码。除非有充分的理由,否则不要创建空的 catch 块。它吞掉了异常,而你不知道异常被抛出了。(为了简洁起见,我并不总是在本书的例子中编写 catch 块。)

注意当您在 try 主体后指定两个或更多具有相同参数类型的 catch 块时,编译器会报告错误。例:试试{ } catch(io exception ioe 1){ } catch(io exception ioe 2){ }。您必须将这些 catch 块合并成一个块。

尽管可以按任何顺序编写 catch 块,但当一个 catch 块的参数是另一个 catch 块的参数的超类型时,编译器会限制这种顺序。子类型参数 catch 块必须在超类型参数 catch 块之前;否则,将永远不会执行子类型参数 catch 块。

例如,FileNotFoundExceptioncatch 块必须在 IOException catch 块之前。如果编译器允许首先指定 IOException catch 块,那么 file not found exceptioncatch 块将永远不会执行,因为 FileNotFoundException 实例也是其 IOException 超类的实例。

再次抛出异常

在讨论 Throwable 类时,我讨论了在高级异常中包装低级异常。此活动通常发生在 catch 块中,如下例所示:

catch (IOException ioe)
{
   throw new ReportCreationException(ioe);
}

这个例子假设一个 helper 方法刚刚抛出了一个通用的 IOException 实例,作为尝试创建一个报告的结果。公共方法的契约声明在这种情况下抛出 ReportCreationException。为了满足约定,抛出后一个异常。为了让负责调试错误应用的开发人员满意, IOException 实例被包装在 ReportCreationException 实例中,该实例被抛出给公共方法的调用者。

有时,catch 块可能无法完全处理异常。也许它需要访问方法调用堆栈中某个祖先方法提供的信息。但是,catch 块可能能够部分处理该异常。在这种情况下,它应该部分处理异常,然后重新抛出异常,以便祖先方法中的处理程序可以完成对它的处理。另一种可能性是记录异常(供以后分析),这在下面的示例中进行了演示:

catch (FileNotFoundException fnfe)
{
   logger.log(fnfe);
   throw fnfe; // Rethrow the exception here.
}

执行清理

在某些情况下,您可能希望在执行过程中留下一个引发异常的方法之前执行清理代码。例如,您可能希望关闭一个已打开但无法写入的文件,这可能是因为磁盘空间不足。Java 为这种情况提供了 finally 块。

finally 块由保留字 finally 组成,后跟一个主体,提供清理代码。finally 块跟在 catch 块或 try 块后面。在前一种情况下,异常可能在最终执行之前被处理(也可能被重新抛出)。在后一种情况下,异常在最终执行后被处理(并且可能被重新抛出)。

清单 5-27 展示了模拟文件复制应用的 main() 方法的第一个场景。

清单 5-27 。在处理一个抛出的异常后,通过关闭文件进行清理

import java.io.IOException;

public class Copy
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Copy srcFile dstFile");
         return;
      }

      int fileHandleSrc = 0;
      int fileHandleDst = 1;
      try
      {
         fileHandleSrc = open(args[0]);
         fileHandleDst = create(args[1]);
         copy(fileHandleSrc, fileHandleDst);
      }
      catch (IOException ioe)
      {
         System.err.println("I/O error: " + ioe.getMessage());
         return;
      }
      finally
      {
         close(fileHandleSrc);
         close(fileHandleDst);
      }
   }

   static int open(String filename)
   {
      return 1; // Assume that filename is mapped to integer.
   }

   static int create(String filename)
   {
      return 2; // Assume that filename is mapped to integer.
   }

   static void close(int fileHandle)
   {
      System.out.println("closing file: " + fileHandle);
   }

   static void copy(int fileHandleSrc, int fileHandleDst) throws IOException
   {
      System.out.println("copying file " + fileHandleSrc + " to file " +
                         fileHandleDst);
      if (Math.random() < 0.5)
         throw new IOException("unable to copy file");
   }
}

清单 5-27 展示了一个复制应用类,它模拟了从源文件到目标文件的字节复制。try 块调用 open() 方法打开源文件,调用 create() 方法创建目标文件。每个方法都返回一个基于整数的文件句柄,它唯一地标识了文件。

接下来,这个块调用 copy() 方法来执行复制。在输出一个合适的消息后, copy() 调用 Math 类的 random() 方法(在第七章中正式讨论过)返回一个介于 0 和 1 之间的随机数。当这个方法返回一个小于 0.5 的值,这模拟了一个问题(可能磁盘已满),实例化 IOException 类并抛出这个实例。

虚拟机定位 try 块之后的 catch 块,并使其处理程序执行,从而输出一条消息。然后,允许执行 catch 块后面的 finally 块中的代码。它的目的是通过调用传递的文件句柄上的 close() 方法来关闭两个文件。

编译这段源代码(javac Copy.java),用两个任意参数运行应用( java Copy x.txt x.bak )。没有问题时,您应该观察到以下输出:

copying file 1 to file 2
closing file: 1
closing file: 2

当出现问题时,您应该观察到以下输出:

copying file 1 to file 2
I/O error: unable to copy file
closing file: 1
closing file: 2

无论是否发生 I/O 错误,请注意 finally 块是要执行的最后一个代码。即使 catch 块以 return 语句结束,finally 块也会执行。

此示例说明了处理引发的异常后的 finally 块执行。但是,您可能希望在处理异常之前执行清理。清单 5-28 展示了一个复制应用的变体,演示了这种替代方案。

清单 5-28 。在处理抛出的异常之前通过关闭文件进行清理

import java.io.IOException;

public class Copy
{
   public static void main(String[] args) throws IOException
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Copy srcFile dstFile");
         return;
      }

      int fileHandleSrc = 0;
      int fileHandleDst = 1;
      try
      {
         fileHandleSrc = open(args[0]);
         fileHandleDst = create(args[1]);
         copy(fileHandleSrc, fileHandleDst);
      }
      finally
      {
         close(fileHandleSrc);
         close(fileHandleDst);
      }
   }

   static int open(String filename)
   {
      return 1; // Assume that filename is mapped to integer.
   }

   static int create(String filename)
   {
      return 2; // Assume that filename is mapped to integer.
   }

   static void close(int fileHandle)
   {
      System.out.println("closing file: " + fileHandle);
   }

   static void copy(int fileHandleSrc, int fileHandleDst) throws IOException
   {
      System.out.println("copying file " + fileHandleSrc + " to file " +
                         fileHandleDst);
      if (Math.random() < 0.5)
         throw new IOException("unable to copy file");
   }
}

清单 5-28 与清单 5-27 中的几乎相同。唯一的区别是附加到 main() 方法头的 throws 子句和 catch 块的移除。当抛出 IOException 时,finally 块在执行离开 main() 方法之前执行。这一次,Java 的默认异常处理程序执行 printStackTrace() ,您会看到类似如下的输出:

copying file 1 to file 2
closing file: 1
closing file: 2
Exception in thread "main" java.io.IOException: unable to copy file
                at Copy.copy(Copy.java:48)
                at Copy.main(Copy.java:19)

练习

以下练习旨在测试您对第五章内容的理解:

  1. 什么是嵌套类?
  2. 识别四种嵌套类。
  3. 哪些嵌套类也被称为内部类?
  4. 是非判断:静态成员类有一个封闭实例。
  5. 如何从封闭类之外实例化一个非静态成员类?
  6. 什么时候需要声明局部变量和参数 final ?
  7. 是非判断:一个接口可以在一个类中声明,也可以在另一个接口中声明。
  8. 定义包。
  9. 如何确保包名是唯一的?
  10. 什么是包语句?
  11. 是非判断:您可以在一个源文件中指定多个 package 语句。
  12. 什么是进口陈述?
  13. 如何表明希望通过一条 import 语句导入多种类型?
  14. 在运行时搜索期间,当虚拟机找不到类文件时会发生什么?
  15. 如何指定虚拟机的用户类路径?
  16. 定义常量接口。
  17. 为什么使用常量接口?
  18. 为什么常量接口不好?
  19. 什么是静态导入语句?
  20. 如何指定静态导入语句?
  21. 什么是例外?
  22. 在表示异常方面,对象在哪些方面优于错误代码?
  23. 什么是可投掷的?
  24. getCause() 方法返回什么?
  25. 异常和错误有什么区别?
  26. 什么是检查异常?
  27. 什么是运行时异常?
  28. 在什么情况下你会引入自己的异常类?
  29. 是非判断:通过将 throw 语句追加到方法的头,可以使用该语句来标识从方法中引发的异常。
  30. try 语句的目的是什么,catch 块的目的是什么?
  31. finally 块的目的是什么?
  32. 2D 图形软件包支持二维绘图和转换(旋转,缩放,平移等)。).这些转换需要一个 3 乘 3 的矩阵(一个表格)。声明一个 G2D 类,它包含一个私有的矩阵非静态成员类。在 G2D 的无参数构造函数中实例化矩阵,将矩阵实例初始化为单位矩阵(除了左上角到右下角的元素为 1,其他元素均为 0 的矩阵)。
  33. 扩展日志包以支持一个空设备,其中的消息被丢弃。
  34. 修改日志包,使日志记录器的 connect() 方法在无法连接到其日志目的地时抛出 CannotConnectException ,另外两个方法在未调用 connect() 或抛出 CannotConnectException 时各抛出 NotConnectedException 。
  35. 修改测试记录器以适当地响应抛出的 CannotConnectException 和 NotConnectedException 对象。* *摘要

在任何类之外声明的类称为顶级类。Java 还支持嵌套类,即声明为其他类或作用域的成员的类。

有四种嵌套类:静态成员类、非静态成员类、匿名类和本地类。后三类被称为内部类。

Java 支持将顶级类型划分为多个名称空间,以更好地组织这些类型,并防止名称冲突。Java 使用包来完成这些任务。

package 语句标识源文件的类型所在的包。import 语句通过告诉编译器在编译过程中何处查找非限定类型名来从包中导入类型。

异常是与应用正常行为的差异。尽管可以用错误代码或对象来表示,但是 Java 使用对象,因为错误代码没有意义,并且不能包含导致异常的信息。

Java 提供了表示不同类型异常的类的层次结构。这些类根植于 Throwable 。沿着 throwable 层次向下,您会遇到异常和错误类,它们代表非错误异常和错误。

异常及其子类,除了 RuntimeException (及其子类)描述被检查的异常。之所以检查它们,是因为您必须检查代码,以确保异常在抛出或被识别为在其他地方处理时得到处理。

RuntimeException 及其子类描述未检查的异常。您不必处理这些异常,因为它们代表编码错误(修复错误)。尽管它们的类名可以出现在 throws 子句中,但这样做会增加混乱。

throw 语句向虚拟机抛出一个异常,虚拟机将搜索一个合适的处理程序。当检查异常时,其名称必须出现在方法的 throws 子句中,除非异常的超类的名称在该子句中列出。

方法通过指定 try 语句和适当的 catch 块来处理一个或多个异常。无论是否抛出异常,在抛出的异常离开方法之前,都可以包含 finally 块来执行清理代码。

第六章继续通过关注断言、注释、泛型和枚举来探索 Java 语言。*