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

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

掌握嵌套类型

在任何类之外声明的类被称为顶级类。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 。迭代器接口 T5】和 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(可变参数)特性(参见第 3 章)来选择性地接受额外的字符串参数,用于那些需要参数的目的地类型。例如,文件需要一个文件名。

清单 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 表示失败。(在第 11 章的中,我重构了文件以包含适当的文件写入代码。)

日志库允许我们在应用中引入可移植的日志代码。除了调用 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 T2】的文件中。
  4. 清单 5-18 复制到日志目录下一个名为【LoggerFactory.java T2】的文件中。
  5. 清单 5-19 复制到日志目录下一个名为【Console.java T2】的文件中。
  6. 清单 5-20 复制到日志目录下一个名为【File.java T2】的文件中。
  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 的字符串表示形式。
【一次性填料跟踪() 填写执行堆栈跟踪。这个方法记录当前线程堆栈帧的当前状态信息。(我在第 8 章的中讨论线程。)
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() 生成的默认堆栈跟踪。(我会在第 11 章讨论序列化。)

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

Exception 本身又被 Java . lang . clonenotsupportedexception(在第四章中讨论过)、 java.lang.IOException (在第十一章中讨论过)等类子类化。同样, Error 本身也是 Java . lang . assertion Error(在第 6 章中讨论过)、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() 方法(在第 7 章中正式讨论过)返回一个介于 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)

练习

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

  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 块来执行清理代码。

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