三、探索类和对象

在第二章中,我向你介绍了 Java 语言的基础。您现在知道了如何通过将语句插入到类的 main() 方法中来编写简单的应用。然而,当您试图以这种方式开发复杂的应用时,您一定会发现开发是乏味的、缓慢的,并且容易出错。类和对象通过简化应用架构来解决这些问题。

在第三章中,我向你介绍了 Java 对类和对象的支持。您将学习如何声明一个类并从该类中实例化对象,如何在该类中声明字段并访问这些字段,如何在该类中声明方法并调用它们,如何初始化类和对象,以及如何在不再需要对象时将其删除。

在第 2 章讨论变量的时候,我向你介绍了数组。您学习了数组变量,并发现了一种创建数组的简单方法。然而,Java 也提供了一种更强大、更灵活的方法来创建数组,这有点类似于创建对象的方式。本章还通过向您介绍这一功能扩展了第 2 章的的阵列覆盖范围。

声明类和实例化对象

在涉及类和对象的现代编程方法之前,应用遵循结构化编程,其中数据结构被创建来组织和存储数据项,而函数(返回值的命名代码序列)和过程(不返回值的命名代码序列)被用于操纵数据结构内容。这种数据与代码的分离使得建模真实世界的实体(比如银行账户和雇员)变得困难,并且经常导致复杂应用的维护问题。

比雅尼·斯特劳斯特鲁普(C++编程语言的创始人)等计算机科学家发现,通过将数据结构与函数和过程合并成称为类的离散单元,可以简化这种复杂性。这些类可以描述真实世界的实体并被实例化。结果对象被证明是建模这些实体的有效方法。

您首先学习如何声明一个类,然后学习如何在 new 操作符和构造函数的帮助下从这个类创建对象。最后,您将了解构造函数参数以及如何指定它们来初始化对象,了解局部变量以及如何指定它们来帮助控制构造函数中的代码流。

声明类

一个是制造对象(代码和数据的命名分组)的模板,也称为类实例,或简称实例。类概括了现实世界中的实体,而对象是这些实体在应用级别的具体表现。您可能会认为类是 cookie cutter,对象是 cookie cutter 创建的 cookie。

因为不能实例化不存在的类中的对象,所以必须首先声明该类。声明由一个标题和一个正文组成。至少,头文件由保留字 class 组成,后跟一个标识该类的名称(以便可以从源代码中的其他地方引用它)。正文以左括号字符( { )开始,以右括号( } )结束。夹在这些分隔符之间的是各种声明。考虑清单 3-1

清单 3-1 。声明骨架图像类

class Image
{
   // various member declarations
}

清单 3-1 声明了一个名为 Image 的类,它大概描述了某种在屏幕上显示的图像。按照惯例,类名以大写字母开头。此外,多单词类名中每个后续单词的第一个字母都要大写。这就是所谓的驼绒

用 New 运算符和构造函数实例化对象

Image 是一个用户定义类型的例子,可以从该类型创建对象。通过使用带有构造函数的 new 操作符来创建这些对象,如下所示:

Image image = new Image();

new 操作符分配内存来存储由 new 的唯一操作数指定类型的对象,在本例中恰好是 Image() 。对象存储在一个叫做的内存区域中。

图像后面的括号(圆括号)表示一个构造器,它是一段代码,通过以某种方式初始化来构造一个对象。 new 操作符在分配内存存储对象后立即调用(调用)构造函数。

当构造函数结束时, new 返回一个对该对象的引用(一个内存地址或其他标识符),这样就可以在应用的其他地方访问它。对于新创建的图像对象,其引用存储在一个名为图像的变量中,该变量的类型被指定为图像。(通常将变量称为对象,如在 image 对象中,尽管它只存储对象的引用而不是对象本身。)

注意 new 返回的引用在源代码中用关键字 this 表示。无论这个出现在哪里,它都代表当前对象。同样,存储引用的变量被称为引用变量

图像没有显式声明构造函数。当一个类没有声明构造函数时,Java 会隐式地为这个类创建一个构造函数。创建的构造函数被称为默认无参数构造函数,因为当调用构造函数时,在它的 ( 和 ) 字符之间没有出现参数(稍后讨论)。

注意当声明了至少一个构造函数时,Java 不会创建默认的无参数构造函数。

指定构造函数参数和局部变量

通过指定类名后跟一个参数列表,可以在类体内显式声明一个构造函数,参数列表是一个圆括号分隔的逗号分隔的零个或多个参数声明的列表。参数是一个构造函数或方法变量,当它被调用时,接收传递给构造函数或方法的表达式值。这个表达式值被称为自变量

清单 3-2 增强了清单 3-1 的图像类,声明了三个构造函数,它们带有声明零个、一个或两个参数的参数列表和一个用于测试该类的 main() 方法。

清单 3-2 。用三个构造函数和一个 main() 方法声明一个图像类

public class Image
{
   Image()
   {
      System.out.println("Image() called");
   }

   Image(String filename)
   {
      this(filename, null);
      System.out.println("Image(String filename) called");
   }

   Image(String filename, String imageType)
   {
      System.out.println("Image(String filename, String imageType) called");
      if (filename != null)
      {
         System.out.println("reading " + filename);
         if (imageType != null)
            System.out.println("interpreting " + filename + " as storing a " +
                               imageType + " image");
      }
      // Perform other initialization here.
   }

   public static void main(String[] args)
   {
      Image image = new Image();
      System.out.println();
      image = new Image("image.png");
      System.out.println();
      image = new Image("image.png", "PNG");
   }
}

清单 3-2 的图像类首先声明一个无参数构造函数,用于将图像对象初始化为默认值(无论它们是什么)。此构造函数模拟默认初始化。它通过调用 System.out.println() 来输出一条表示它已被调用的消息。

Image 接下来声明一个 Image(String filename) 构造函数,它的参数列表由一个参数声明组成——一个变量的类型后跟变量名。 java.lang.String 参数命名为 filename ,表示该构造函数从文件中获取图像内容。

注意在本章和其余章节中,我通常会在第一次使用预定义类型(如字符串)之前加上存储该类型的包层次结构。例如,字符串存储在 java 包的 lang 子包中。我这样做是为了帮助您了解类型存储在哪里,以便您可以更容易地指定将这些类型导入到源代码中的导入语句(而不必首先搜索类型的包)——您不必导入存储在 java.lang 包中的类型,但是为了完整起见,我仍然将 java.lang 包作为类型名的前缀。在第 5 章中,我会对包和导入声明有更多的说明。

一些构造函数依赖其他构造函数来帮助它们初始化它们的对象。这样做是为了避免冗余代码,冗余代码会增加对象的大小,不必要地从堆中取走可用于其他目的的内存。例如, Image(字符串文件名)依靠 Image(字符串文件名,字符串图像类型)将文件的图像内容读入内存。

虽然看起来不是这样,但是构造函数没有名字(但是,通常通过指定类名和参数列表来引用构造函数)。一个构造函数通过使用关键字 this 和圆括号分隔的逗号分隔的参数列表来调用另一个构造函数。例如, Image(字符串文件名)执行 this(文件名,空);执行图像(字符串文件名,字符串图像类型)。

注意你必须使用关键字 this 来调用另一个构造函数——你不能使用类名,就像在 Image() 中一样。 this() 构造函数调用(如果存在的话)必须是在构造函数中执行的第一个代码——该规则防止您在同一个构造函数中指定多个 this() 构造函数调用。最后,您不能在方法中指定 this()——构造函数只能由其他构造函数调用,并且只能在对象创建期间调用。(我将在本章后面讨论方法。)

如果存在,构造函数调用必须是构造函数中指定的第一个代码;否则,编译器会报告错误。因此,调用另一个构造函数的构造函数只有在另一个构造函数完成后才能执行额外的工作。例如, Image(字符串文件名)执行 System.out.println("Image(字符串文件名)调用");被调用的图像(字符串文件名,字符串图像类型)构造函数完成后。

Image(String filename,String imageType) 构造函数声明了一个 imageType 参数,该参数表示存储在文件中的图像类型——例如,可移植网络图形(PNG ) 图像。大概,构造函数使用 imageType 通过不检查文件内容来学习图像格式来加速处理。当 null 被传递给 imageType 时,正如 Image(String filename) 构造函数所发生的那样, Image(String filename,String imageType) 检查文件内容以学习格式。如果 null 也被传递给 filename , Image(String filename,String imageType) 不会读取该文件,但可能会通知试图创建 Image 对象的代码出现错误情况。

在声明了构造函数之后,清单 3-2 声明了一个 main() 方法 ,让您创建 Image 对象并查看输出消息。 main() 创建三个 Image 对象,调用第一个不带参数的构造函数,第二个带参数的构造函数 "image.png" ,第三个带参数的构造函数" Image . PNG "" PNG "。

注意传递给构造函数或方法的参数数量,或者运算符操作数的数量,被称为构造函数、方法或运算符的 arity

每个对象的引用被分配给一个名为 image 的引用变量,替换先前存储的第二个和第三个对象分配的引用。(每次出现 system . out . println();输出一个空行,使输出更容易阅读。)

main() 的出现将图像从仅仅一个类变成了一个应用。您通常将 main() 放在用于创建对象的类中,以测试这些类。当构建一个供他人使用的应用时,通常在一个类中声明 main() ,其目的是运行一个应用,而不是从那个类创建一个对象——然后应用只从那个类运行。参见第 1 章的 DumpArgs 和 EchoText 类的例子。

在将清单 3-2 保存到【Image.java】的之后,通过在命令行执行 javac Image.java 来编译这个文件。假设没有错误消息,通过指定 java Image 来执行应用。您应该观察到以下输出:

Image() called

Image(String filename, String imageType) called
reading image.png
Image(String filename) called

Image(String filename, String imageType) called
reading image.png
interpreting image.png as storing a PNG image

第一行输出表明 noargument 构造函数已经被调用。随后的输出行表明已经调用了第二个和第三个构造函数。

除了声明参数,构造函数还可以在其主体中声明变量来帮助它执行各种任务。例如,前面提到的 Image(String filename,String imageType) 构造函数可能会从一个(假设的) File 类创建一个对象,该类提供了读取文件内容的方法。在某些时候,构造函数实例化该类,并将实例的引用赋给一个变量,如下面的代码所示:

Image(String filename, String imageType)
{
   System.out.println("Image(String filename, String imageType) called");
   if (filename != null)
   {
      System.out.println("reading " + filename);
      File file = new File(filename);
      // Read file contents into object.
      if (imageType != null)
         System.out.println("interpreting " + filename + " as storing a " +
                            imageType + " image");
      else
         // Inspect image contents to learn image type.
         ; // Empty statement is used to make if-else syntactically valid.
   }
   // Perform other initialization here.
}

与文件名和图像类型参数一样,文件是构造函数的局部变量,称为局部变量以区别于参数。虽然这三个变量都是构造函数的局部变量,但是参数和局部变量之间有两个关键的区别:

  • 文件名和图像类型参数在构造函数开始执行时存在,并一直存在到执行离开构造函数。相比之下,文件在其声明点出现并继续存在,直到声明它的块被终止(通过一个右括号字符)。参数或局部变量的这个属性被称为生存期
  • 可以从构造函数的任何地方访问文件名和图像类型参数。相比之下,文件只能从它的声明点到声明它的块的末尾被访问。不能在声明前或声明块后访问局部变量,但嵌套子块可以访问局部变量。参数或局部变量的这个属性被称为范围

注意生存期和范围(也称为可见性)属性也适用于类、对象和字段(稍后讨论)。当加载到内存中时,类就存在了,当从内存中卸载时,类就不存在了,通常是在应用退出时。此外,加载的类通常对其他类可见。

一个对象的生命周期从通过 new 操作符创建它开始,直到它被垃圾收集器从内存中删除(在本章后面讨论)。它的范围取决于各种因素,例如当它的引用被赋给局部变量或字段时。我将在本章后面讨论字段。

字段的生存期取决于它是实例字段还是类字段。当字段属于一个对象(实例字段)时,它在对象被创建时存在,在对象从内存中消失时消失。当该字段属于一个类(类字段)时,该字段在该类被加载时开始存在,并在该类从内存中移除时消失。与对象一样,字段的范围取决于各种因素,例如字段是否被声明为具有私有访问权限——您将在本章的后面了解私有访问权限。

局部变量不能与参数同名,因为参数总是与局部变量具有相同的范围。但是,一个局部变量可以与另一个局部变量同名,前提是这两个变量位于不同的范围内(即位于不同的块内)。例如,您可以指定 int x = 1;在 if-else 语句的 if 块中,并指定 double x = 2.0;在语句对应的 else 块中,每个局部变量都是不同的。

注意对构造函数参数、自变量和局部变量的讨论也适用于方法参数、自变量和局部变量——我将在本章后面讨论方法。

封装状态和行为

类从模板的角度模拟现实世界的实体,例如汽车和储蓄账户。对象表示特定的实体,例如,John 的红色 Toyota Camry(汽车实例)和 Cuifen 的结余为两万美元的储蓄帐户(储蓄帐户实例)。

实体有属性,比如颜色红色,制造丰田,型号凯美瑞,余额两万美元。一个实体的属性集合被称为其状态。实体也有行为,如开门、开车、显示油耗、存款、取款、显示账户余额。

类和它的对象通过将状态和行为组合成一个单元来建模一个实体——类抽象状态,而它的对象提供具体的状态值。这种状态和行为的结合被称为封装。在结构化编程中,开发人员关注于通过结构化代码分别对行为进行建模,并通过存储供结构化代码操作的数据项的数据结构对状态进行建模,与此不同,使用类和对象的开发人员关注于通过声明封装状态和行为的类来对实体进行模板化,用这些类中的特定状态值来实例化对象以表示特定的实体,并通过它们的行为与对象进行交互。

在这一节中,我首先向您介绍 Java 表示状态的语言特性,然后向您介绍它表示行为的语言特性。因为一些状态和行为支持类的内部架构,并且不应该对那些想要使用该类的人可见,所以我通过介绍信息隐藏的重要概念来结束这一节。

通过字段表示状态

Java 让你通过字段来表示状态,这些字段是在类体内声明的变量。实体属性通过实例字段描述。因为 Java 也支持与类而不是对象相关联的状态,所以 Java 提供了类字段来描述这个类状态。

首先学习如何声明和访问实例字段,然后学习如何声明和访问类字段。在了解了如何声明只读实例和类字段之后,您将回顾从不同上下文访问字段的规则。

声明和访问实例字段

您可以通过最低限度地指定类型名,后跟命名字段的标识符,再跟一个分号字符()来声明实例字段。)。清单 3-3 展示了一个汽车类,它有三个实例字段声明。

清单 3-3 。用声明一个汽车类制造、型号和 numDoors 实例字段

class Car
{
   String make;
   String model;
   int numDoors;
}

清单 3-3 声明了两个字符串实例字段,名为 make 和 model 。它还声明了一个名为 numDoors 的 int 实例字段。按照惯例,字段名以小写字母开头,多词字段名中每个后续单词的第一个字母大写。

当创建一个对象时,实例字段被初始化为缺省的零值,您在源代码级别将它解释为文字值 false 、’\ u 0000’、 0 、 0L 、 0.0 、 0.0F 或 null (取决于元素类型)。例如,如果您要执行 Car Car = new Car();、 make 和 model 将被初始化为 null 和 numDoors 将被初始化为 0 。

您可以使用成员访问运算符()为对象的实例字段赋值或从中读取值。);左操作数指定对象的引用,右操作数指定要访问的实例字段。清单 3-4 使用这个操作符来初始化一个汽车对象的制造、型号和 numDoors 实例字段。

清单 3-4 。初始化汽车对象的实例字段

public class Car
{
   String make;
   String model;
   int numDoors;

   public static void main(String[] args)
   {
      Car car = new Car();
      car.make = "Toyota";
      car.model = "Camry";
      car.numDoors = 4;
   }
}

清单 3-4 展示了一个 main() 方法,它实例化了 Car 。 car 实例的 make 实例字段被赋予 "Toyota" 字符串,其 model 实例字段被赋予 "Camry" 字符串,其 numDoors 实例字段被赋予整数文字 4 。(字符串的双引号分隔字符串的字符序列,但不是字符串的一部分。)

您可以在声明实例字段时显式初始化该字段,以提供非零默认值,该值将覆盖默认的零值。清单 3-5 展示了这一点。

清单 3-5 。将汽车的 numDoors 实例字段初始化为默认非零值

public class Car
{
   String make;
   String model;
   int numDoors = 4;

   Car()
   {
   }

   public static void main(String[] args)
   {
      Car johnDoeCar = new Car();
      johnDoeCar.make = "Chevrolet";
      johnDoeCar.model = "Volt";
   }
}

清单 3-5 显式初始化 numDoors 到 4 ,因为开发者已经假设这个类建模的大多数汽车有四个门。当 Car 通过 Car() 构造函数初始化时,开发者只需要初始化那些有四个门的汽车的 make 和 model 实例字段。

直接初始化一个对象的实例字段通常不是一个好主意,当我讨论信息隐藏(在本章的后面)的时候你会知道为什么。相反,你应该在类的构造函数中执行这个初始化——参见清单 3-6

清单 3-6 。通过构造函数初始化汽车的实例字段

public class Car
{
   String make;
   String model;
   int numDoors;

   Car(String make, String model)
   {
      this(make, model, 4);
   }

   Car(String make, String model, int nDoors)
   {
      this.make = make;
      this.model = model;
      numDoors = nDoors;
   }

   public static void main(String[] args)
   {
      Car myCar = new Car("Toyota", "Camry");
      Car yourCar = new Car("Mazda", "RX-8", 2);
   }
}

清单 3-6 的 Car 类声明 Car(String make,String model) 和 Car(String make,String model,int nDoors) 构造函数。第一个构造函数让您指定品牌和型号,而第二个构造函数让您指定三个实例字段的值。

第一个构造函数执行 this(make,model,4);将它的 make 和 model 参数的值连同默认值 4 一起传递给第二个构造函数。这样做展示了一种显式初始化实例字段的替代方法,从代码维护的角度来看,这种方法更可取。

Car(String make,String model,int numDoors) 构造函数演示了关键字 this 的另一种用法。具体来说,它演示了构造函数参数与类的实例字段同名的场景。在变量名前加上这个前缀。”使 Java 编译器创建访问实例字段的字节码。比如 this . make = make;将 make 参数的字符串对象引用分配给这个(当前) Car 对象的 make 实例字段。ifmake = make;相反,如果指定了,那么通过将 make 的值赋给自身,它将一事无成;Java 编译器可能不会生成代码来执行不必要的赋值。与此相反,“这个。"对于 numDoors = nDoors 来说是不必要的;赋值,从 nDoors 参数值初始化 numDoors 域。

注意以最小化错误(通过忘记在字段名前加上“ this。"),最好保持字段名和参数名的不同(例如 numDoors 和 nDoors )。或者,您可以在字段名前面加上下划线(例如,_ n 或者)。无论哪种方式,你都不用担心这个。”前缀(又忘了指定)。

声明和访问类字段

在许多情况下,您只需要实例字段。但是,您可能会遇到这样的情况:无论创建了多少个对象,您都需要一个字段的单一副本。

例如,假设您想要跟踪已经创建的 Car 对象的数量,并在该类中引入一个计数器实例字段(初始化为 0)。您还可以在该类的构造函数中放置代码,当创建一个对象时,该代码会将计数器的值增加 1。然而,因为每个对象都有自己的计数器实例字段的副本,所以这个字段的值永远不会超过 1。清单 3-7 通过用静态关键字作为字段声明的前缀,将计数器声明为类字段,解决了这个问题。

清单 3-7 。给汽车增加一个计数器类字段

public class Car
{
   String make;
   String model;
   int numDoors;
   static int counter;

   Car(String make, String model)
   {
      this(make, model, 4);
   }

   Car(String make, String model, int numDoors)
   {
      this.make = make;
      this.model = model;
      this.numDoors = numDoors;
      counter++; // This code is unsafe because counter can be accessed directly.
   }

   public static void main(String[] args)
   {
      Car myCar = new Car("Toyota", "Camry");
      Car yourCar = new Car("Mazda", "RX-8", 2);
      System.out.println(Car.counter);
   }
}

清单 3-7 的静态前缀意味着计数器字段只有一个副本,而不是每个对象一个副本。当一个类被加载到内存中时,类字段被初始化为缺省的零值。例如,计数器被初始化为 0 。(与实例字段一样,您也可以在其声明中为类字段赋值。)每创建一个对象,计数器就会加 1,这得益于 Car(String make,String model,int numDoors) 构造函数中的 counter++ 表达式。

与实例字段不同,类字段通常通过成员访问操作符直接访问。虽然可以通过对象引用访问类字段(如在 myCar.counter 中),但通常使用类名访问类字段,如在 Car.counter 中。(也更容易看出代码正在访问一个类字段。)

注意因为 main() 方法是清单 3-7 的 Car 类的成员,你可以直接访问 counter ,就像在 System.out.println(counter)中一样;。然而,要在另一个类的 main() 方法的上下文中访问 counter ,您必须指定 Car.counter 。

如果运行清单 3-7 ,你会注意到它输出 2 ,因为已经创建了两个汽车对象。

声明只读实例和类字段

先前声明的字段既可以写入也可以读取。但是,您可能希望声明一个只读字段,例如,一个以 pi (3.14159…)等常数值命名的字段。Java 通过提供保留字 final 让你完成这个任务。

每个对象都接收自己的只读实例字段副本。此字段必须作为字段声明的一部分或在类的构造函数中初始化。当在构造函数中初始化时,只读实例字段被称为空白 final ,因为它没有值,直到在构造函数中给它赋值。因为构造函数可能会给每个对象的 blank final 赋予不同的值,所以这些只读变量并不是真正的常量。

如果您想要一个真正的常量,它是一个对所有对象都可用的只读值,您需要创建一个只读类字段。您可以通过在该字段的声明中包含保留字 static 和 final 来完成这项任务。

清单 3-8 显示了如何声明一个只读的类字段。

清单 3-8 。在雇员类中声明一个真常数

class Employee
{
   final static int RETIREMENT_AGE = 65;
}

清单 3-8 的 RETIREMENT_AGE 声明是编译时间常数的一个例子。因为它的值只有一个副本(由于静态关键字),并且因为这个值永远不会改变(由于 final 关键字),编译器可以通过将常数值插入到所有使用它的计算中来自由地优化编译后的代码。代码运行得更快,因为它不必访问只读的类字段。

查看字段访问规则

前面的字段访问示例可能看起来有些混乱,因为有时您可以直接指定字段的名称,而在其他时候您需要在字段名称前面加上对象引用或类名以及成员访问操作符。以下规则通过指导您如何从各种上下文中访问字段来消除这种混淆:

  • 从与类字段声明相同的类中的任意位置指定类字段的名称。示例:计数器
  • 指定类字段的类的名称,后跟成员访问运算符,再后跟该类外部的类字段的名称。示例: Car.counter
  • 将实例字段的名称指定为与实例字段声明相同的类中的任何实例方法、构造函数或实例初始值设定项(稍后讨论)。示例: numDoors
  • 指定一个对象引用,后面是成员访问操作符,再后面是实例字段的名称,该实例字段来自与实例字段声明相同的类中的任何类方法或类初始化器(稍后讨论),或者来自该类之外。例: Car car =新车();car . numdoors = 2;

尽管最终的规则似乎暗示您可以从类上下文中访问实例字段,但事实并非如此。相反,您是从对象上下文中访问该字段。

前面的访问规则并不详尽,因为还有两种字段访问场景需要考虑:声明一个与实例字段或类字段同名的局部变量(甚至是参数)。在任一场景中,局部变量/参数被称为隐藏(隐藏或屏蔽)字段。

如果您发现您声明了一个隐藏字段的局部变量或参数,您可以重命名该局部变量/参数,或者您可以使用带有保留字 this (实例字段)或类名(类字段)的成员访问运算符来显式标识该字段。例如,清单 3-6 的 Car(String make,String model,int nDoors) 构造函数通过指定诸如 this . make = make;区分实例字段和同名参数。

通过方法表现行为

Java 让你通过方法来表现行为,这些方法是在类的主体中声明的代码块。实体行为通过实例方法来描述。因为 Java 也支持与类而不是对象相关联的行为,所以 Java 提供了类方法来描述这些类行为。

首先学习如何声明和调用实例方法,然后学习如何创建实例方法调用链。接下来,您将了解如何声明和调用类方法,了解关于向方法传递参数的更多细节,并探索 Java 的 return 语句。在学习了如何递归调用方法作为迭代的替代方法,以及如何重载方法之后,您将回顾从不同上下文调用方法的规则。

声明和调用实例方法

您可以通过以下方式声明实例方法:最低限度地指定一个返回类型名称,后跟一个命名该方法的标识符,再跟一个参数列表,最后跟一个大括号分隔的主体。清单 3-9 展示了一个带有 printDetails() 实例方法的 Car 类。

清单 3-9 。在 Car 类中声明一个 printDetails() 实例方法

public class Car
{
   String make;
   String model;
   int numDoors;

   Car(String make, String model)
   {
      this(make, model, 4);
   }

   Car(String make, String model, int numDoors)
   {
      this.make = make;
      this.model = model;
      this.numDoors = numDoors;
   }

   void printDetails()
   {
      System.out.println("Make = " + make);
      System.out.println("Model = " + model);
      System.out.println("Number of doors = " + numDoors);
      System.out.println();
   }

   public static void main(String[] args)
   {
      Car myCar = new Car("Toyota", "Camry");
      myCar.printDetails();
      Car yourCar = new Car("Mazda", "RX-8", 2);
      yourCar.printDetails();
   }
}

清单 3-9 声明了一个名为 printDetails() 的实例方法。按照惯例,方法名以小写字母开头,多单词方法名中每个后续单词的第一个字母大写。

方法类似于构造函数,因为它们有参数列表。当您呼叫方法时,会将引数传递给这些参数。因为 printDetails() 不接受参数,所以它的参数列表是空的。

注意一个方法的名字和它的参数的数量、类型和顺序被称为它的签名

当一个方法被调用时,其主体中的代码被执行。在 printDetails() 的情况下,该方法的主体执行一系列 System.out.println() 方法调用,以输出其 make 、 model 和 numDoors 实例字段的值。

与构造函数不同,方法被声明为具有返回类型。返回类型标识该方法返回的值的种类(例如, int count() 返回 32 位整数)。当一个方法不返回值时(并且 printDetails() 也不返回值),它的返回类型被替换为关键字 void ,如 void printDetails() 所示。

注意构造函数没有返回类型,因为它们不能返回值。如果一个构造函数可以返回一个任意值,那么 Java 将如何返回这个值呢?毕竟, new 操作符返回一个对象的引用; new 怎么也能返回一个构造函数的值呢?

使用成员访问操作符调用方法:左边的操作数指定对象的引用,右边的操作数指定要调用的方法。例如, myCar.printDetails() 和 yourCar.printDetails() 表达式调用 myCar 和 yourCar 对象上的 printDetails() 实例方法。

编译清单 3-9(【Car.java】)并运行这个应用( java Car )。您应该观察到以下输出,其不同的实例字段值证明了 printDetails() 与一个对象相关联:

Make = Toyota
Model = Camry
Number of doors = 4

Make = Mazda
Model = RX-8
Number of doors = 2

当实例方法被调用时,Java 将一个隐藏的参数传递给该方法(作为参数列表中最左边的参数)。该参数是对调用该方法的对象的引用。它在源代码级别通过保留字 this 表示。不需要在实例字段名前面加上“ this。"因为 Java 编译器会确保使用隐藏参数来访问实例字段,所以每当您试图访问实例字段名称而该名称又不是参数名称时,都会从方法内部调用。

方法调用堆栈

方法调用需要一个方法调用栈(也称为方法调用栈)来跟踪执行必须返回的语句。把方法调用栈想象成自助餐厅中一堆干净托盘的模拟——你从这堆托盘的顶部弹出(移除)干净托盘,洗碗机将把下一个干净托盘推到这堆托盘的顶部。

当一个方法被调用时,虚拟机将它的参数和第一条语句的地址推送到方法调用堆栈上。虚拟机还为方法的局部变量分配堆栈空间。当方法返回时,虚拟机移除局部变量空间,从堆栈中弹出地址和参数,并将执行转移到该地址处的语句。

将实例方法调用链接在一起

两个或多个实例方法调用可以通过成员访问操作符链接在一起,从而产生更紧凑的代码。为了完成实例方法调用链接,你需要稍微不同地重新构建你的实例方法,这在清单 3-10 中有所揭示。

清单 3-10 。实现实例方法,以便对这些方法的调用可以链接在一起

public class SavingsAccount
{
   int balance;

   SavingsAccount deposit(int amount)
   {
      balance += amount;
      return this;
   }

   SavingsAccount printBalance()
   {
      System.out.println(balance);
      return this;
   }

   public static void main(String[] args)
   {
      new SavingsAccount().deposit(1000).printBalance();
   }
}

清单 3-10 显示你必须指定类名作为实例方法的返回类型。 deposit() 和 printBalance() 都必须指定 SavingsAccount 作为返回类型。另外,必须指定 return this;(返回当前对象的引用)作为最后一条语句——我稍后讨论 return 语句。

例如,新建 SavingsAccount()。押金(1000)。print balance();创建一个 SavingsAccount 对象,使用返回的 SavingsAccount 引用调用 SavingsAccount 的 deposit() 实例方法,向储蓄帐户添加一千美元(为了方便起见,我忽略了美分),最后使用 deposit() 返回的 SavingsAccount 引用(同 SavingsAccount 实例)调用 saving

声明和调用类方法

在许多情况下,实例方法就是您所需要的。但是,您可能会遇到需要描述独立于任何对象的行为的情况。

例如,假设您想引入一个工具类(一个由静态 [class]方法组成的类),它的类方法执行各种类型的转换(比如从摄氏度转换到华氏度)。您不希望从该类创建一个对象来执行转换。相反,您只是想调用一个方法并获得它的结果。清单 3-11 通过提供一个带有一对类方法的转换类来解决这个需求。这些方法可以被调用,而不必创建一个转换对象。

清单 3-11 。一个带有一对类方法的转换工具类

class Conversions
{
   static double c2f(double degrees)
   {
      return degrees * 9.0 / 5.0 + 32;
   }

   static double f2c(double degrees)
   {
      return (degrees - 32) * 5.0 / 9.0;
   }
}

清单 3-11 的转换类声明了 c2f() 和 f2c() 方法,用于从摄氏度到华氏度的转换,反之亦然,并返回这些转换的结果。每个方法头(方法签名和其他信息)都以关键字 static 为前缀,将方法转换成类方法。

要执行一个类方法,通常要在它的名字前面加上类名。比如可以执行 conversions . c2f(100.0);找出相当于 100 摄氏度的华氏温度,以及 conversions . f2c(98.6);发现正常体温的摄氏当量。您不需要实例化转换,然后通过该实例调用这些方法,尽管您可以这样做(但这不是好的形式)。

注意每个应用至少有一个类方法。具体来说,应用必须指定 public static void main(String[]args)作为应用的入口点。静态保留字使得这个方法成为一个类方法。(我将在本章后面解释保留字 public 。)

因为类方法不是用引用当前对象的隐藏参数调用的, c2f() 、 f2c() 和 main() 不能访问对象的实例字段或调用其实例方法。这些类方法只能访问类字段和调用类方法。

向方法传递参数

方法调用包括传递给该方法的一系列(零个或多个)参数。Java 通过一种称为按值传递的参数传递方式将参数传递给方法,下面的示例将演示这种方式:

Employee emp = new Employee("John ");
int recommendedAnnualSalaryIncrease = 1000;
printReport(emp, recommendAnnualSalaryIncrease);
printReport(new Employee("Cuifen"), 1500);

按值传递将变量的值(例如,存储在 emp 中的引用值或存储在 recommended annual salary increase 中的 1000 值)或一些其他表达式的值(例如, new Employee("Cuifen") 或 1500 )传递给该方法。

由于传递值,您不能通过此参数的 printReport() 参数从 printReport() 内部将不同的 Employee 对象的引用分配给 emp 。毕竟,您只是将 emp 值的一个副本传递给了该方法。

许多方法和构造函数要求您在调用它们时传递固定数量的参数。然而,Java 也提供了传递可变数量的参数的能力——这样的方法/构造函数通常被称为 varargs 方法 / 构造函数 。若要声明采用可变数量的参数的方法或构造函数,请在该方法/构造函数的最右侧参数的类型名称后指定三个连续的句点。以下示例展示了一个接受可变数量参数的 sum() 方法:

double sum(double. . . values)
{
   int total = 0;
   for (int i = 0; i < values.length; i++)
      total += values[i];
   return total;
}

sum() 的实现合计传递给该方法的参数个数,例如 sum(10.0,20.0) 或 sum(30.0,40.0,50.0) 。(在后台,这些参数存储在一个一维数组中,如 values.length 和 values[i] 所示。)在对这些值求和之后,通过 return 语句返回这个总数。

通过 Return 语句从一个方法返回

不返回值(其返回类型设置为 void )的方法中语句的执行从第一条语句流向最后一条语句。然而,Java 的 return 语句让方法或构造函数在到达最后一个语句之前退出。如清单 3-12 所示,这种形式的 return 语句由保留字 return 后跟一个分号组成。

清单 3-12 。使用 Return 语句从方法中提前返回

public class Employee
{
   String name;

   Employee(String name)
   {
      setName(name);
   }

   void setName(String name)
   {
      if (name == null)
      {
         System.out.println("name cannot be null");
         return;
      }
      else
         this.name = name;
   }

   public static void main(String[] args)
   {
      Employee john = new Employee(null);
   }
}

清单 3-12 的雇员(字符串名称)构造函数调用 setName() 实例方法初始化 name 实例字段。为此提供一个单独的方法是一个好主意,因为它允许您在构造时以及以后初始化实例字段。(也许员工改变了他或她的名字。)

注意当从同一个类中的构造函数或方法调用一个类的实例或类方法时,只需指定方法的名称。不要用成员访问操作符和对象引用或类名作为方法调用的前缀。

setName() 使用 if 语句来检测将空引用分配给名称字段的企图。当检测到这种尝试时,它输出“名称不能为空”错误消息,并提前从该方法返回,以便不能分配空值(并替换先前分配的名称)。

注意使用 return 语句时,您可能会遇到编译器报告“不可达代码”错误信息的情况。当它检测到永远不会执行的代码并不必要地占用内存时,它就会这样做。您可能会在 switch 语句中遇到这个问题。比如,假设你指定 case 2:printUsageInstructions();返回;打破;作为本声明的一部分。编译器在检测到 return 语句后面的 break 语句时会报告一个错误,因为 break 语句是不可访问的;它永远不会被执行。

return 语句的前一种形式在返回值的方法中是非法的。对于这样的方法,Java 提供了 return 的替代版本,允许方法返回值(其类型必须与方法的返回类型相匹配)。以下示例演示了此版本:

double divide(double dividend, double divisor)
{
   if (divisor == 0.0)
   {
      System.out.println("cannot divide by zero");
      return 0.0;
   }
   return dividend / divisor;
}

divide() 使用 if 语句检测将其第一个参数除以 0.0 的尝试,并在检测到此尝试时输出错误消息。此外,它返回 0.0 来表示这种尝试。如果没有问题,则执行除法并返回结果。

注意不能在构造函数中使用这种形式的 return 语句,因为构造函数没有返回类型。

递归调用方法

一个方法通常执行可能包含对其他方法的调用的语句,比如 printDetails() 调用 System.out.println() 。然而,偶尔有一个方法调用本身是很方便的。这个场景被称为递归

例如,假设您需要编写一个方法来返回一个阶乘(一个特定整数之前的所有正整数的乘积)。比如 3!(该!是阶乘的数学符号)等于 3×2×1 或 6。

编写此方法的第一种方法可能由以下示例中的代码组成:

int factorial(int n)
{
   int product = 1;
   for (int i = 2; i <= n; i++)
      product *= i;
   return product;
}

虽然这段代码完成了它的任务(通过迭代), factorial() 也可以按照下面例子的递归方式编写:

int factorial(int n)
{
   if (n == 1)
      return 1; // base problem
   else
      return n * factorial(n - 1);
}

递归方法利用了能够用更简单的术语来表达问题的优势。根据这个例子,最简单的问题,也就是大家熟知的基数问题,是 1!(1).

当一个大于 1 的参数被传递给 factorial() 时,该方法通过用下一个更小的参数值调用自己,将问题分解成一个更简单的问题。最终会达到基数问题。

例如,调用 factorial(4) 会产生下面的表达式堆栈:

4 * factorial(3)
3 * factorial(2)
2 * factorial(1)

最后一个表达式在栈顶。当 factorial(1) 返回 1 时,这些表达式在堆栈开始展开时计算:

  • 2 阶乘(1) 现在变成 21 (2)
  • 3 阶乘(2) 现在变成了 32 (6)
  • 4 阶乘(3) 现在变成了 46 (24)

递归为表达许多问题提供了一种优雅的方式。其他示例包括在基于树的数据结构中搜索特定值,以及在分层文件系统中,查找并输出包含特定文本的所有文件的名称。

注意递归消耗堆栈空间,所以要确保你的递归最终以一个基本问题结束;否则,您将耗尽堆栈空间,您的应用将被迫终止。

重载方法

Java 允许您将名称相同但参数列表不同的方法引入到同一个类中。这个特性被称为方法重载。当编译器遇到方法调用表达式时,它会将被调用方法的参数列表与每个重载方法的参数列表进行比较,以寻找要调用的正确方法。

当两个同名方法的参数列表在参数的数量或顺序上不同时,它们会被重载。比如 Java 的 String 类提供了重载的 int indexOf(int ch) 和 int indexOf(int ch,int fromIndex) 方法。这些方法在参数计数上有所不同。(我在第 7 章的中探索字符串。)

当至少有一个参数的类型不同时,两个同名的方法被重载。比如 Java 的 java.lang.Math 类提供了重载的静态双 abs(双 a) 和静态 int abs(int a) 方法。一个方法的参数是一个 double;另一个方法的参数是一个 int (我在第 7 章的中探索数学。)

不能通过仅更改返回类型来重载方法。例如, double sum(double。。。值)和 int sum(double。。。值)不超载。这些方法没有重载是因为编译器在源代码中遇到 sum(1.0,2.0) 时,没有足够的信息来选择调用哪个方法。

查看方法调用规则

前面的方法调用示例可能看起来很混乱,因为有时您可以直接指定方法名,而在其他时候您需要在方法名前面加上对象引用或类名以及成员访问操作符。以下规则通过指导您如何从各种上下文中调用方法来消除这种混淆:

  • 从与类方法相同的类中的任意位置指定类方法的名称。例:c2f(37.0);
  • 指定类方法的类的名称,后跟成员访问运算符,再后跟该类外部的类方法的名称。示例:conversions . c2f(37.0);(你也可以通过一个对象实例调用一个类方法,但是这被认为是不好的形式,因为它隐藏了一个类方法被调用的事实。)
  • 指定实例方法的名称,该名称来自与实例方法在同一类中的任何实例方法、构造函数或实例初始值设定项。例: setName(名称);
  • 指定一个对象引用,后面是成员访问操作符,再后面是实例方法的名称,该实例方法来自与实例方法相同的类内或来自类外的任何类方法或类初始值设定项。例: Car 汽车=新车(“丰田”、“凯美瑞”);car . print details();

尽管后一条规则似乎意味着您可以从类上下文中调用实例方法,但事实并非如此。相反,您可以从对象上下文中调用该方法。

此外,不要忘记确保传递给方法的参数的数量,以及这些参数传递的顺序,并且这些参数的类型与它们在被调用的方法中对应的参数一致。

注意字段访问和方法调用规则合并在表达式 System.out.println()中;,其中最左边的成员访问操作符访问 java.lang.System 类中的 out 类字段(类型为 java.io.PrintStream ),最右边的成员访问操作符调用该字段的 println() 方法。您将在第 11 章的中了解 PrintStream ,在第 8 章中了解系统。

隐藏信息

每个类 X 都公开了一个接口(一个由构造函数、方法和【可能】字段组成的协议,它们可供从其他类创建的对象使用,用于创建和与 X 的对象通信)。

接口作为一个类和它的客户端之间的单向契约,客户端是外部的构造函数、方法和其他(面向初始化的)类实体(将在本章后面讨论),它们通过调用构造函数和方法以及访问字段(通常是公共静态最终字段或常量)与类的实例进行通信。契约是这样的,类承诺不改变它的接口,这将破坏依赖于该接口的客户端。

X 还提供了一个实现(公开的方法中的代码,以及可选的助手方法和可选的不应该公开的支持字段),它对接口进行编码。辅助方法是辅助暴露方法和不应该暴露的方法。

当设计一个类时,你的目标是公开一个有用的接口,同时隐藏该接口实现的细节。您隐藏了实现,以防止开发人员意外地访问您的类中不属于该类接口的部分,这样您就可以在不破坏客户端代码的情况下自由地更改实现。隐藏实现通常被称为信息隐藏。此外,许多开发人员认为实现隐藏是封装的一部分。

Java 通过提供四个级别的访问控制来支持实现隐藏,其中三个级别通过保留字来表示。您可以使用下列访问控制级别来控制对字段、方法和构造函数的访问,并使用其中两个级别来控制对类的访问:

  • Public :声明为 public 的字段、方法或构造函数可以从任何地方访问。类也可以被声明为公共的。
  • Protected :声明为 protected 的字段、方法或构造函数可以从与成员类相同的包中的所有类以及该类的子类中访问,而不考虑包。(我在第 5 章中讨论了包。)
  • 私有:声明为私有的字段、方法或构造函数不能从声明它的类之外访问。
  • Package-private :在没有访问控制保留字的情况下,一个字段、方法或构造函数只能被与成员类相同的包中的类访问。非公立班也是如此。公共、保护或私有的缺失意味着包私有。

注意声明为 public 的类必须存储在一个同名的文件中。例如,一个公共图像类必须存储在 Image.java 中。一个源文件只能声明一个公共顶级类。(也可以声明 public 的嵌套类,你将在第 5 章中学习如何这么做。)

您通常会将您的类的实例字段声明为私有,并提供特殊的公共实例方法来设置和获取它们的值。按照惯例,设置字段值的方法的名称以 set 开头,被称为 setters 。类似地,获取字段值的方法的名称带有前缀 get (或者 is ,对于布尔字段)并且被称为getter清单 3-13 在一个雇员类声明的上下文中演示了这个模式。

清单 3-13 。接口与实现的分离

public class Employee
{
   private String name;

   public Employee(String name)
   {
      setName(name);
   }

   public void setName(String empName)
   {
      name = empName; // Assign the empName argument to the name field.
   }

   public String getName()
   {
      return name;
   }
}

清单 3-13 展示了一个由公共雇员类,它的公共构造函数,以及它的公共 setter/getter 方法组成的接口。这个类和这些成员可以从任何地方访问。该实现由私有名称字段和构造函数/方法代码组成,只能在雇员类中访问。

当您可以简单地省略 private 并直接访问 name 字段时,这么做似乎毫无意义。但是,假设您被告知要引入一个新的构造函数,它接受单独的姓和名参数,并引入新的方法,将雇员的姓和名设置/获取到这个类中。此外,假设已经确定名字和姓氏将比整个名字被更频繁地访问。清单 3-14 揭示了这些变化。

清单 3-14 。在不影响现有接口的情况下修改实现

public class Employee
{
   private String firstName;
   private String lastName;

   public Employee(String name)
   {
      setName(name);
   }

   public Employee(String firstName, String lastName)
   {
      setName(firstName + " " + lastName);
   }

   public void setName(String name)
   {
      // Assume that the first and last names are separated by a
      // single space character. indexOf() locates a character in a
      // string; substring() returns a portion of a string.
      setFirstName(name.substring(0, name.indexOf(' ')));
      setLastName(name.substring(name.indexOf(' ') + 1));
   }

   public String getName()
   {
      return getFirstName() + " " + getLastName();
   }

   public void setFirstName(String empFirstName)
   {
      firstName = empFirstName;
   }

   public String getFirstName()
   {
      return firstName;
   }

   public void setLastName(String empLastName)
   {
      lastName = empLastName;
   }

   public String getLastName()
   {
      return lastName;
   }
}

清单 3-14 显示出姓名字段已经被删除,取而代之的是新的名字和姓氏字段,这两个字段是为了提高性能而添加的。因为 setFirstName() 和 setLastName() 将比 setName() 被更频繁地调用,并且因为 getFirstName() 和 getLastName() 将比 getName() 被更频繁地调用,所以(在每种情况下)让前两个方法 set/getfirst names 和 lastName 更高效

清单 3-14 还揭示了 setName() 调用 setFirstName() 和 setLastName() ,以及 getName() 调用 getFirstName() 和 getLastName() ,而不是直接访问 firstName 和 lastName 字段。虽然在这个例子中避免直接访问这些字段是不必要的,但是设想另一个实现变化,向 setFirstName() 、 setLastName() 、 getFirstName() 和 getLastName() 添加更多代码;不调用这些方法将导致新代码无法执行。

当雇员的实现从清单 3-13 变为清单 3-14 所示时,客户端代码(实例化并使用一个类的代码,如雇员)不会中断,因为原始接口保持不变,尽管接口已经被扩展。这种缺少破损是由于隐藏了清单 3-13 的实现,尤其是名称字段。

注意 setName() 调用 String 类的 indexOf() 和 substring() 方法。你将在第 7 章的中了解这些和其他字符串方法。

Java 提供了一个鲜为人知的信息隐藏相关语言特性,让一个对象(或类方法/初始化器)访问另一个对象的私有字段或调用其私有方法。清单 3-15 提供了一个演示。

清单 3-15 。一个对象访问另一个对象的私有字段

public class PrivateAccess
{
   private int x;

   PrivateAccess(int x)
   {
      this.x = x;
   }

   boolean equalTo(PrivateAccess pa)
   {
      return pa.x == x;
   }

   public static void main(String[] args)
   {
      PrivateAccess pa1 = new PrivateAccess(10);
      PrivateAccess pa2 = new PrivateAccess(20);
      PrivateAccess pa3 = new PrivateAccess(10);
      System.out.println("pa1 equal to pa2: " + pa1.equalTo(pa2));
      System.out.println("pa2 equal to pa3: " + pa2.equalTo(pa3));
      System.out.println("pa1 equal to pa3: " + pa1.equalTo(pa3));
      System.out.println(pa2.x);
   }
}

清单 3-15 的 PrivateAccess 类声明了一个名为 x 的 private int 字段。它还声明了一个接受 PrivateAccess 参数的 equalTo() 方法。其思想是将参数对象与当前对象进行比较,以确定它们是否相等。

通过使用 == 运算符来比较参数对象的 x 实例字段的值与当前对象的 x 实例字段的值,当它们相同时返回布尔值 true,从而确定是否相等。令人困惑的是,Java 允许您指定 pa.x 来访问参数对象的私有实例字段。另外, main() 能够通过 pa2 对象直接访问 x 。

我之前介绍了 Java 的四个访问控制级别,并介绍了以下关于私有访问控制级别的声明:“声明为 private 的字段、方法或构造函数不能从声明它的类之外访问。”当你仔细考虑这个声明并检查清单 3-15 时,你会意识到 x 没有被声明它的 PrivateAccess 类之外的类访问。因此,没有违反私有访问控制级别。

唯一可以访问这个私有实例字段的代码是位于 PrivateAccess 类中的代码。如果您试图通过在另一个类的上下文中创建的 PrivateAccess 对象来访问 x ,编译器会报告一个错误。

能够从 PrivateAccess 内部直接访问 x 是一种性能增强;直接访问这个实现细节比调用返回其值的方法更快。

编译 PrivateAccess.java(javac PrivateAccess.java)并运行应用( java PrivateAccess )。您应该观察到以下输出:

pa1 equal to pa2: false
pa2 equal to pa3: false
pa1 equal to pa3: true
20

技巧养成开发有用的接口同时隐藏实现的习惯,因为这将为你在维护你的类时省去很多麻烦。

初始化类和对象

类和对象在使用前需要正确初始化。你已经知道了类字段在类加载后被初始化为缺省的零值,随后可以通过类字段初始化器在声明中给它们赋值来初始化,例如,static int counter = 1;。类似地,当一个对象的内存通过 new 分配时,实例字段被初始化为默认值,随后可以通过实例字段初始化器在它们的声明中给它们赋值来初始化;例如,int num doors = 4;。

已经讨论过的初始化的另一个方面是构造函数,它用于初始化一个对象,通常是通过给各种实例字段赋值,但也能够执行任意代码,例如打开文件和读取文件内容的代码。

Java 提供了两个额外的初始化特性:类初始化器和实例初始化器。在本节向您介绍了这些特性之后,我将讨论所有 Java 初始化器执行工作的顺序。

类初始化器

构造函数执行对象的初始化任务。从类初始化的角度来看,它们的对应物是类初始化器。

一个类初始化器是一个静态前缀的块,它被引入到一个类体中。它用于通过一系列语句初始化一个加载的类。例如,我曾经使用一个类初始化器来加载一个定制的数据库驱动程序类。清单 3-16 显示了加载细节。

清单 3-16 。通过类初始化器加载数据库驱动程序

class JDBCFilterDriver implements Driver
{
   static private Driver d;

   static
   {
      // Attempt to load JDBC-ODBC Bridge Driver and register that
      // driver.
      try
      {
         Class c = Class.forName("sun.jdbc.odbc.JdbcOdbcDriver");
         d = (Driver) c.newInstance();
         DriverManager.registerDriver(new JDBCFilterDriver());
      }
      catch (Exception e)
      {
         System.out.println(e);
      }
   }
   //. . .
}

清单 3-16 的 JDBCFilterDriver 类使用其类初始化器来加载和实例化描述 Java 的 JDBC-ODBC 桥驱动程序的类,并向 Java 的数据库驱动程序注册一个 JDBCFilterDriver 实例。虽然这个清单中面向 JDBC 的代码现在对您来说可能毫无意义,但是这个清单展示了类初始化器的用处。(我在第十四章中讨论 JDBC。)

一个类可以声明类初始化器和类字段初始化器的混合,如清单 3-17 所示。

清单 3-17 。混合类初始值设定项和类字段初始值设定项

class C
{
   static
   {
      System.out.println("class initializer 1");
   }

   static int counter = 1;

   static
   {
      System.out.println("class initializer 2");
      System.out.println("counter = " + counter);
   }
}

清单 3-17 声明了一个名为 C 的类,它指定了两个类初始化器和一个类字段初始化器。当 Java 编译器将声明了至少一个类初始值设定项或类字段初始值设定项的类编译到类文件中时,它会创建一个特殊的 void < clinit > () 类方法,该方法按照所有类初始值设定项和类字段初始值设定项出现的顺序(从上到下)存储它们的字节码等价物。

注意 < clinit > 不是一个有效的 Java 方法名,但是从运行时的角度来看是一个有效的名称。尖括号被选作名称的一部分,以防止与您可能在该类中声明的任何 clinit() 方法发生名称冲突。

对于类 C ,T10】clinit>()将首先包含等同于 System.out.println("类初始化器 1 ")的字节码;,接下来它将包含相当于 static int counter = 1 的字节码;,它将最终包含与 system . out . println(" class initializer 2 ")等价的字节码;system . out . println(" counter = "+counter);。

当类 C 被加载到内存中时, < clinit > () 立即执行并生成以下输出:

class initializer 1
class initializer 2
counter = 1

实例初始化器

不是所有的类都可以有构造函数,当我介绍匿名类时,你会在第 5 章中发现。对于这些类,Java 提供了实例初始化器来处理实例初始化任务。

一个实例初始化器是一个块,它被引入到一个类主体中,而不是作为一个方法或构造函数的主体被引入。实例初始化器用于通过一系列语句初始化一个对象,如清单 3-18 所示。

清单 3-18 。通过实例初始化器初始化一对数组

class Graphics
{
   double[] sines;
   double[] cosines;

   {
      sines = new double[360];
      cosines = new double[sines.length];
      for (int degree = 0; degree < sines.length; degree++)
      {
         sines[degree] = Math.sin(Math.toRadians(degree));
         cosines[degree] = Math.cos(Math.toRadians(degree));
      }
   }
}

清单 3-18 的图形类使用一个实例初始化器来创建一个对象的正弦和余弦数组,并将这些数组的元素初始化为范围从 0 到 359 度的正弦和余弦。这样做是因为读取数组元素比在其他地方重复调用 Math.sin() 和 Math.cos() 要快;性能很重要。(在第七章我介绍了 Math.sin() 和 Math.cos() 。)

一个类可以声明实例初始化器和实例字段初始化器的混合,如清单 3-19 所示。

清单 3-19 。混合实例初始值设定项和实例字段初始值设定项

class C
{
   {
      System.out.println("instance initializer 1");
   }

   int counter = 1;

   {
      System.out.println("instance initializer 2");
      System.out.println("counter = " + counter);
   }
}

清单 3-19 声明了一个名为 C 的类,它指定了两个实例初始化器和一个实例字段初始化器。当 Java 编译器将一个类编译成 classfile 时,会创建一个特殊的 void < init > () 方法,在没有显式声明构造函数时,代表默认的无参数构造函数;否则,它为每个遇到的构造函数创建一个 < init > () 方法。此外,它在每个 < init > () 方法中存储所有实例初始化器和实例字段初始化器的字节码等价物,按照它们出现的顺序(从上到下)存储在构造函数代码之前。

注意 < init > 不是一个有效的 Java 方法名,但是从运行时的角度来看是一个有效的名称。选择尖括号作为名称的一部分是为了防止与您可能在该类中声明的任何 init() 方法冲突。

对于类 C , < init > () 将首先包含等同于 System.out.println("实例初始化器 1 ")的字节码;,接下来它将包含相当于 int counter = 1 的字节码;,它将最终包含与 System.out.println("实例初始化器 2 ")等价的字节码;system . out . println(" counter = "+counter);。

当 new C() 执行时, < init > () 立即执行并生成以下输出:

instance initializer 1
instance initializer 2
counter = 1

注意你应该很少需要使用实例初始化器,这在业界并不常用。其他开发人员在浏览源代码时可能会错过实例初始化器,并且可能会感到困惑。

初始化顺序

类的主体可以包含类字段初始值设定项、类初始值设定项、实例字段初始值设定项、实例初始值设定项和构造函数的混合。(你应该更喜欢构造函数而不是实例字段初始值设定项,尽管我很抱歉没有始终如一地这样做,并且将实例初始值设定项的使用限制在匿名类中,这在第 5 章中讨论过。)此外,类字段和实例字段初始化为默认值。理解所有这些初始化发生的顺序对于防止混淆是必要的,所以查看清单 3-20

清单 3-20 。一个完整的初始化演示

public class InitDemo
{
   static double double1;
   double double2;
   static int int1;
   int int2;
   static String string1;
   String string2;

   static
   {
      System.out.println("[class] double1 = " + double1);
      System.out.println("[class] int1 = " + int1);
      System.out.println("[class] string1 = " + string1);
      System.out.println();
   }

   {
      System.out.println("[instance] double2 = " + double2);
      System.out.println("[instance] int2 = " + int2);
      System.out.println("[instance] string2 = " + string2);
      System.out.println();
   }

   static
   {
      double1 = 1.0;
      int1 = 1000000000;
      string1 = "abc";
   }

   {
      double2 = 1.0;
      int2 = 1000000000;
      string2 = "abc";
   }

   InitDemo()
   {
      System.out.println("InitDemo() called");
      System.out.println();
   }

   static double double3 = 10.0;
   double double4 = 10.0;

   static
   {
      System.out.println("[class] double3 = " + double3);
      System.out.println();
   }

   {
      System.out.println("[instance] double4 = " + double3);
      System.out.println();
   }

   public static void main(String[] args)
   {
      System.out.println ("main() started");
      System.out.println();
      System.out.println("[class] double1 = " + double1);
      System.out.println("[class] double3 = " + double3);
      System.out.println("[class] int1 = " + int1);
      System.out.println("[class] string1 = " + string1);
      System.out.println();
      for (int i = 0; i < 2; i++)
      {
         System.out.println("About to create InitDemo object");
         System.out.println();
         InitDemo id = new InitDemo();
         System.out.println("id created");
         System.out.println();
         System.out.println("[instance] id.double2 = " + id.double2);
         System.out.println("[instance] id.double4 = " + id.double4);
         System.out.println("[instance] id.int2 = " + id.int2);
         System.out.println("[instance] id.string2 = " + id.string2);
         System.out.println();
      }
   }
}

清单 3-20 的 InitDemo 类声明了双精度浮点原始类型的两个类字段和两个实例字段,整数原始类型的一个类字段和一个实例字段,以及字符串引用类型的一个类字段和一个实例字段。它还引入了一个显式初始化的类字段、一个显式初始化的实例字段、三个类初始值设定项、三个实例初始值设定项和一个构造函数。如果您编译并运行此代码,您将会看到以下输出:

[class] double1 = 0.0
[class] int1 = 0
[class] string1 = null

[class] double3 = 10.0

main() started

[class] double1 = 1.0
[class] double3 = 10.0
[class] int1 = 1000000000
[class] string1 = abc

About to create InitDemo object

[instance] double2 = 0.0
[instance] int2 = 0
[instance] string2 = null

[instance] double4 = 10.0

InitDemo() called

id created

[instance] id.double2 = 1.0
[instance] id.double4 = 10.0
[instance] id.int2 = 1000000000
[instance] id.string2 = abc

About to create InitDemo object

[instance] double2 = 0.0
[instance] int2 = 0
[instance] string2 = null

[instance] double4 = 10.0

InitDemo() called

id created

[instance] id.double2 = 1.0
[instance] id.double4 = 10.0
[instance] id.int2 = 1000000000
[instance] id.string2 = abc

当您结合前面对类初始化器和实例初始化器的讨论来研究这个输出时,您会发现一些关于初始化的有趣事实:

  • 类字段在类加载后立即初始化为默认值或显式值。在一个类加载之后,所有的类字段都被归零为默认值。 < clinit > () 方法内的代码执行显式初始化。
  • 所有的类初始化都发生在 < clinit > () 方法返回之前。
  • 实例字段在对象创建期间初始化为默认值或显式值。当 new 为一个对象分配内存时,它将所有实例字段归零为默认值。一个 < init > () 方法内的代码执行显式初始化。
  • 所有实例初始化都发生在 < init > () 方法返回之前。

此外,因为初始化以自顶向下的方式发生,所以试图在声明类字段之前访问该字段的内容或者试图在声明实例字段之前访问该字段的内容会导致编译器报告非法前向引用

收集垃圾

对象是通过保留字 new 创建的,但是它们是如何被销毁的呢?如果没有销毁对象的方法,它们最终会填满堆的可用空间,应用将无法继续运行。Java 没有为开发人员提供从内存中删除它们的能力。相反,Java 通过提供一个垃圾收集器来处理这个任务,垃圾收集器是在后台运行的代码,偶尔会检查未被引用的对象。当垃圾收集器发现一个未被引用的对象(或者多个相互引用的对象,并且彼此之间没有其他引用——例如,只有 A 引用 B 并且只有 B 引用 A )时,它会将该对象从堆中移除,从而腾出更多的堆空间。

未引用对象是不能从应用内的任何地方访问的对象。例如,新员工(“约翰”、“多伊”);是一个未被引用的对象,因为 new 返回的员工引用被丢弃。相反,引用对象是应用存储至少一个引用的对象。例如, Employee emp =新员工(" John "," Doe ");是一个被引用的对象,因为变量 emp 包含对 Employee 对象的引用。

当应用移除其最后存储的引用时,被引用的对象变得不被引用。例如,如果 emp 是一个局部变量,它包含了对一个雇员对象的唯一引用,那么当声明 emp 的方法返回时,这个对象就变得不被引用了。一个应用也可以通过将 null 赋给它的引用变量来删除一个存储的引用。例如,EMP = null;删除对先前存储在 emp 中的 Employee 对象的引用。

Java 的垃圾收集器消除了不依赖垃圾收集器的 C++实现中的一种内存泄漏。在这些 C++实现中,开发人员必须在动态创建的对象超出范围之前销毁它们。如果它们在毁灭前消失,它们将留在堆中。最终,堆填满,应用停止。

尽管这种形式的内存泄漏在 Java 中不是问题,但一种相关形式的泄漏却是有问题的:不断地创建对象而忘记删除对每个对象的一个引用会导致堆被填满,应用最终会停止运行。这种形式的内存泄漏通常发生在集合(存储对象的基于对象的数据结构)的上下文中,并且对于长时间运行的应用来说是一个主要问题——web 服务器就是一个例子。对于寿命较短的应用,您通常不会注意到这种形式的内存泄漏。

考虑清单 3-21 中的。

清单 3-21 。一个内存泄漏堆栈

public class Stack
{
   private Object[] elements;
   private int top;

   public Stack(int size)
   {
      elements = new Object[size];
      top = 1; // indicate that stack is empty
   }

   public void push(Object o)
   {
      if (top + 1 == elements.length)
      {
         System.out.println("stack is full");
         return;
     }
     elements[++top] = o;
   }

   public Object pop()
   {
      if (top == 1)
      {
         System.out.println("stack is empty");
         return null;
      }
      Object element = elements[top--];
//      elements[top + 1] = null;
      return element;
   }

   public static void main(String[] args)
   {
      Stack stack = new Stack(2);
      stack.push("A");
      stack.push("B");
      stack.push("C");
      System.out.println(stack.pop());
      System.out.println(stack.pop());
      System.out.println(stack.pop());
   }
}

清单 3-21 描述了一个被称为的集合,这是一个按照后进先出的顺序存储元素的数据结构。堆栈对于记忆东西很有用,比如当一个方法停止执行并且必须返回到它的调用者时返回的指令。

Stack 提供了一个 push() 方法,用于将任意对象推送到堆栈的顶部,还提供了一个 pop() 方法,用于按照对象被推的相反顺序将对象从堆栈顶部弹出。

在创建了一个最多可以存储两个对象的堆栈对象后, main() 调用 push() 三次,将三个字符串对象推送到堆栈上。因为堆栈的内部数组只能存储两个对象,所以当 main() 试图推“C”时, push() 会输出一个错误消息。

此时, main() 试图从堆栈中弹出三个对象,将每个对象输出到标准输出设备。前两个 pop() 方法调用成功,但最后一个方法调用失败并输出错误消息,因为调用时堆栈为空。

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

stack is full
B
A
stack is empty
null

栈类有一个问题:它泄漏内存。当你将一个对象压入堆栈时,它的引用存储在内部的元素数组中。当您从堆栈中弹出一个对象时,将获得该对象的引用,并且减少 top ,但是该引用将保留在数组中(直到您调用 push() )。

想象一个场景,其中栈对象的引用被分配给一个类字段,这意味着栈对象在应用的生命周期内一直存在。此外,假设您已经将三个 50 兆字节的图像对象压入堆栈,然后将它们弹出堆栈。在使用这些对象之后,您将 null 分配给它们的引用变量,认为它们将在下一次垃圾收集器运行时被垃圾收集。然而,这种情况不会发生,因为堆栈对象仍然维护其对这些对象的引用,因此 150 兆字节的堆空间对应用不可用,并且应用可能会耗尽内存。

这个问题的解决方案是让 pop() 在返回引用之前将 null 显式分配给元素条目。只需取消对元素[top + 1] = null 的注释;清单 3-21 中的行使这种情况发生。

您可能会认为,当不再需要引用变量的被引用对象时,您应该总是将空值分配给引用变量。然而,经常这样做并不能提高性能或者释放大量的堆空间,并且在不小心的时候会导致抛出 Java . lang . nullpointerexception 类的实例。(我将在第 5 章关于 Java 面向异常的语言特性的中讨论 NullPointerException )。通常在管理自己内存的类中取消引用变量,比如前面提到的 Stack 类。

注意要了解更多关于 Java 5 环境中的垃圾收集,请查看 Oracle 的“Java HotSpot 虚拟机中的内存管理”白皮书(www . Oracle . com/tech network/Java/javase/tech/Memory Management-white paper-1-150020 . pdf)。

重访数组

在第 2 章的中,我向您介绍了数组,它是内存区域(具体来说,是堆),在大小相等且连续的槽中存储值,称为元素。我还举了几个例子,包括下面这个例子:

char gradeLetters[] = { 'A', 'B', 'C', 'D', 'F' };

这里有一个名为 gradeLetters 的数组变量,它存储了对一个五元素内存区域的引用,该内存区域将字符 A 、 B 、 C 、 D 和 F 存储在连续且大小相等(16 位)的内存位置中。

注意我已经把 [] 括号放在了等级字母之后。尽管这是合法的,但习惯上还是将这些括号放在类型名之后,如 char[] gradeLetters = { 'A ',' B ',' C ',' D ',' F ' };。在本节中,我将演示这两种方法。

您可以通过指定个等级字母[ x ],来访问一个元素,其中 x 是一个标识数组元素的整数,被称为索引;第一个数组元素总是位于索引 0 处。以下示例显示了如何输出和更改第一个元素的值:

System.out.println(gradeLetters[0]); // Output the first grade letter.
gradeLetters[0] = 'a'; // Perhaps you prefer lowercase grade letters.

{ 'A ',' B ',' C ',' D ',' F' } 数组创建语法是语法糖的一个例子(简化语言的语法,使其使用起来更“甜蜜”)。在后台,用新的操作符创建数组,并初始化为这些值,如下所示:

char gradeLetters[] = new char[] { 'A', 'B', 'C', 'D', 'F' };

首先,分配一个五字符的内存区域。接下来,该区域的五个字符元素被初始化为 A 、 B 、 C 、 D 和 F 。最后,对这些元素的引用存储在数组变量 gradeLetters 中。

注意将整数值放在字符后面的方括号中是错误的。比如编译器在遇到 new char[5] { 'A ',' B ',' C ',' D ',' F' }中的 5 时报错;。

您可以将数组视为一种特殊的对象,尽管它不是与类实例是对象相同意义上的对象。这个伪对象有一个单独的只读长度字段,包含数组的大小(元素的数量)。例如, gradeLetters.length 返回 gradeLetters 数组中元素(5)的个数。

虽然您可以使用前两种方法中的任何一种来创建数组,但是您通常会指定第三种方法,这种方法不涉及显式的元素初始化,并且随后会初始化数组。下面的代码演示了这种方法:

char gradeLetters[] = new char[5];

您可以将元素的数量指定为方括号之间的正整数。运算符 new 将每个数组元素的存储位置中的位清零,您在源代码级别将其解释为文字值 false 、 '\u0000' 、 0 、 0L 、 0.0 、 0.0F 或 null (取决于元素类型)。

然后,您可以初始化数组,如下所示:

gradeLetters[0] = 'A';
gradeLetters[1] = 'B';
gradeLetters[2] = 'C';
gradeLetters[3] = 'D';
gradeLetters[4] = 'F';

但是,您可能会发现使用循环执行此任务更方便,如下所示:

for (int i = 0; i < gradeLetters.length; i++)
   gradeLetters[i] = 'A' + i;

前面的例子着重于创建一个数组,它的值共享一个通用的原始类型(字符,由 char 关键字表示)。您还可以创建对象引用的数组。例如,您可以创建一个数组来存储三个图像对象引用,如下所示:

Image[] imArray = { new Image("image0.png"), new Image("image1.png"), new Image("image2.png") };

这里有一个名为 imArray 的数组变量,它存储一个对三元素内存区域的引用,其中每个元素存储一个对 Image 对象的引用。图像对象位于内存中的其他地方。

您可以通过指定 imArray[x]来访问一个图像元素。以下示例假设存在一个返回图像长度(以字节为单位)的 getLength() 方法,并在第一个 Image 对象上调用该方法以返回第一个图像的长度,该长度随后被输出:

System.out.println(imArray[0].getLength());

与前面的 gradeLetters 示例一样,您可以将 new 操作符与语法 sugar initializer 结合起来,如下所示:

Image[] imArray = new Image[] { new Image("image0.png"), new Image("image1.png"),
                                new Image("image2.png") };

最后,您可以使用第三种方法,通过将每个元素中的所有位设置为 0,将每个对象引用初始化为空引用。这种方法演示如下:

Image[] imArray = new Image[3];

因为 new 将每个元素初始化为空引用,所以您必须显式初始化该数组,您可以方便地这样做,如下所示:

for (int i = 0; i < imArray.length; i++)
   imArray[i] = new Image("image" + i + ".png"); // image0.png, image1.png, and so on

“图像”+ i +”。png" 表达式使用字符串连接运算符( + )将图像与存储在变量 i 中的整数值的字符串等效项组合起来。png 。产生的字符串被传递给 Image 的 Image(字符串文件名)构造函数,产生的引用被存储在一个数组元素中。

注意根据循环的长度,在循环上下文中使用字符串连接操作符会导致大量不必要的字符串对象的创建。我会在第 7 章向你介绍 String 类的时候讨论这个话题。

前面的例子着重于创建一维数组。然而,你也可以创建多维数组(即二维或多维数组)。例如,考虑温度值的二维数组。

虽然您可以使用这三种方法中的任何一种来创建温度数组,但是当这些值变化很大时,第三种方法更可取。以下示例将此数组创建为一个三行两列的双精度浮点温度值表:

double[][] temperatures = new double[3][2];

注意双和温度之间的两组方括号。这两组括号表示二维数组(表格)。还要注意新和双后面的两组方括号。每个集合包含一个正整数值,表示每行的行数( 3 )或列数( 2 )。

注意创建多维数组时,与数组变量相关联的方括号对的数量和 new 后面的方括号对的数量以及类型名必须相同。

创建数组后,可以用合适的值填充它的元素。以下示例通过 Math.random() 将每个 temperatures 元素初始化为随机生成的温度值,该元素被访问为 temperatures【row】【col】,我将在第 7 章中对此进行解释:

for (int row = 0; row < temperatures.length; row++)
   for (int col = 0; col < temperatures[row].length; col++)
      temperatures[row][col] = Math.random() * 100;

外部 for 循环选择从第 0 行到数组长度的每一行(确定数组中的行数)。内部 for 循环选择从 0 到当前行数组长度的每一列(确定该数组表示的列数)。本质上,您看到的是一个一维行数组,其中每个元素都引用一个一维列数组。

随后,您可以使用另一个 for 循环以表格格式输出这些值,如以下示例所示,该代码不会尝试对齐完美列中的温度值:

for (int row = 0; row < temperatures.length; row++)
{
   for (int col = 0; col < temperatures[row].length; col++)
      System.out.print(temperatures[row][col] + " ");
   System.out.println();
}

Java 提供了创建多维数组的另一种方法,在这种方法中,您可以单独创建每个维度。例如,要以这种方式通过 new 创建以前的二维温度数组,首先创建一维行数组(外部数组),然后创建一维列数组(内部数组),如以下代码所示:

// Create the row array.
double[][] temperatures = new double[3][]; // Note the extra empty pair of brackets.
// Create a column array for each row.
for (int row = 0; row < temperatures.length; row++)
   temperatures[row] = new double[2]; // 2 columns per row

这种数组被称为不规则数组 ,因为每行可以有不同数量的列;该阵列不是矩形的,而是参差不齐的。

注意当创建行数组时,你必须指定一对额外的空括号作为跟随 new 的表达式的一部分。(对于三维数组——表格的一维数组,其中该数组的元素引用行数组——您必须指定两对空括号作为跟随 new 的表达式的一部分。)

练习

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

  1. 什么是课?
  2. 如何声明一个类?
  3. 什么是对象?
  4. 如何实例化一个对象?
  5. 什么是构造函数?
  6. 是非判断:当一个类没有声明构造函数时,Java 会创建一个默认的无参数构造函数。
  7. 什么是参数表,什么是参数?
  8. 什么是参数列表,什么是参数?
  9. 是非判断:通过指定类名后跟一个参数列表来调用另一个构造函数。
  10. 定义 arity。
  11. 什么是局部变量?
  12. 定义寿命。
  13. 定义范围。
  14. 什么是封装?
  15. 定义字段。
  16. 实例字段和类字段有什么区别?
  17. 什么是空终值,它与真常量有什么不同?
  18. 如何防止字段被隐藏?
  19. 定义方法。
  20. 实例方法和类方法有什么区别?
  21. 定义递归。
  22. 怎么霸王一个方法?
  23. 什么是类初始化器,什么是实例初始化器?
  24. 定义垃圾收集器。
  25. 是非:String[]letters = new String[2]{“A”,“B”};是正确的语法。
  26. 什么是参差不齐的数组?
  27. factorial() 方法提供了一个尾递归的例子,这是递归的一个特例,其中方法的最后一条语句包含一个递归调用,这被称为尾调用。提供另一个尾部递归的例子。
  28. 创建一个包含姓名、作者和国际标准书号(ISBN)字段的 Book 类。提供合适的构造函数和返回字段值的 getter 方法。在这个类中引入一个 main() 方法,该方法创建一个 Book 对象的数组,并遍历该数组,输出每本书的名称、作者和 ISBN。

摘要

类是制造对象的模板,这些对象被命名为代码和数据的集合。类概括了现实世界中的实体,而对象是这些实体在应用级别的具体表现。

new 操作符分配内存来存储由 new 的唯一操作数指定类型的对象。这个运算符后面是一个构造函数,它是一个用于初始化对象的代码块。 new 在分配内存存储对象后立即调用构造函数。

Java 允许您通过字段来表示实体的状态,字段是在类的主体中声明的变量。实体属性通过实例字段来描述。因为 Java 也支持与类而不是对象相关联的状态,所以 Java 提供了类字段来描述这个类状态。

Java 允许你通过方法来表示一个实体的行为,这些方法是在一个类的主体中声明的命名代码块。实体行为通过实例方法来描述。因为 Java 也支持与类而不是对象相关联的行为,所以 Java 提供了类方法来描述这些类行为。

类和对象在使用前需要正确初始化。您已经了解了类字段在类加载后被初始化为缺省的零值,随后可以通过类字段初始化器在声明中给它们赋值来初始化。类似地,当对象的内存通过 new 分配时,实例字段被初始化为默认值,随后可以通过实例字段初始值设定项或构造函数在声明中给它们赋值来初始化。

Java 也支持这个任务的类初始化器和实例初始化器。一个类初始化器是一个被引入到类体中的带前缀的静态块。它用于通过一系列语句初始化一个加载的类。实例初始化器是引入到类体中的块,与作为方法或构造函数的体引入相对。实例初始化器用于通过一系列语句初始化一个对象。

对象是通过保留字 new 创建的,但是它们是如何被销毁的呢?如果没有销毁对象的方法,它们最终会填满堆的可用空间,应用将无法继续运行。Java 没有为开发人员提供从内存中删除它们的能力。相反,Java 通过提供垃圾收集器来处理这项任务,垃圾收集器是在后台运行的代码,偶尔会检查未引用的对象。

您可以将数组视为一种特殊的对象,尽管它不是与类实例是对象相同意义上的对象。这个伪对象有一个单独的只读长度字段,包含数组的大小(元素的数量)。

除了使用在第 2 章中首次出现的语法糖来创建数组之外,你还可以使用新的操作符来创建数组,可以使用也可以不使用语法糖。

在第 4 章中,我继续通过检查 Java 语言对继承、多态和接口的支持来探索 Java 语言。