《Java核心技术卷一》第四章
第4章 对象与类
4.1 面向对象程序设计概述
**面向对象程序设计(Object-Oriented Programming, OOP)**是当今的主流程序设计范型,它取代了 20 世纪 70 年代的”结构化”或过程式编程技术。
Java 是面向对象的。
面向对象的程序是由对象组成的,每个对象包含对用户公开的特定功能和隐藏的实现。程序中的很多对象是来自标准类的”成员”,还有一些是自定义的。究竟是自己构造对象,还是从外界购买,这完全取决于开发项目的预算和时间。但是,从根本上说,只要对象能够满足要求,就不必关心其功能到底是如何实现的。
传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题。一旦确定了这些过程,下一步往往要考虑存储数据的适当方式。首先,他们会确定操作数据的过程,然后再决定如何组织数据的结构,以便于操作数据。而 OOP 却调整了这个次序,将数据放在第一位,然后再考虑操作数据的算法。
4.1.1 类
类(class) 指定了如何构造对象。可以将类想象成制作小甜饼的模具,将对象想象为小甜饼。由一个类构造(construct) 对象的过程称为创建这个类的一个实例(instance)。
用 Java 编写的所有代码都在某个类中。
封装(encapsulation,有时称为信息隐藏)是处理对象的一个重要概念。从形式上看,封装就是将数据和行为组合在一个包中,并对对象的使用者隐藏具体的实现细节。对象中的数据称为实例字段(instance field),操作数据的过程称为方法(method)。作为一个类的实例,一个特定对象有一组特定的实例字段值。这些值的集合就是这个对象的当前状态(state)。只要在对象上调用一个方法,它的状态就有可能发生改变。
实现封装的关键在于,绝对不能让其他类中的方法直接访问这个类的实例字段。程序只能通过对象的方法与对象数据进行交互。封装给对象赋予了 “ 黑盒 ” 特征,这是提高可用性和可靠性的关键。这意味着一个类可以完全改变存储数据的方式,只要仍旧使用同样的方法操作数据,其他对象就不会知道也不用关心这个类所发生的变化。
OOP 的另一个原则会让用户自定义 Java 类变得更为容易,这就是:可以通过扩展其他类来构建新类。事实上,Java 提供了一个“普通”的类,名为 Object。所有其他类都扩展了这个 Object 类。
扩展一个已有的类时,这个新类具有被扩展的那个类的全部属性和方法。你只需要在新类中提供适用于这个新类的新方法和实例字段。通过扩展一个类来得到另外一个类的概念称为继承(inheritance)。
4.1.2 对象
要想使用 OOP,一定要清楚对象的三个主要特性:
- 对象的行为(behavior)——可以对这个对象做哪些操作,或者可以对这个对象应用哪些方法?
- 对象的状态(state)——调用那些方法时,对象会如何响应?
- 对象的标识(identity)——如何区分可能有相同行为和状态的不同对象?
同一个类的所有实例对象都有一种家族相似性,它们都支持相同的行为。一个对象的行为由所能调用的方法来定义。
此外,每个对象都会保存着描述当前状况的信息,这就是对象的状态。对象的状态可能会随着时间而发生改变,但这种改变不是自发的。对象状态的改变必然是调用方法的结果(如果不经过方法调用就可以改变对象状态,这说明破坏了封装性)。
但是,对象的状态并不能完全描述一个对象,因为每个对象都有一个唯一的标识(identity,或称身份)。例如,在一个订单处理系统中,任何两个订单都是不同的,即使它们订购的商品完全相同。需要注意,作为同一个类的实例,每个对象的标识总是不同的,状态也通常有所不同。
对象的这些关键特性会彼此相互影响。例如,对象的状态会影响它的行为(如果一个订单”已发货”或”已付款”,就应该拒绝要求增加商品的方法调用。反过来,如果订单是”空的”,即还没有订购任何商品,就不应该允许”发货”。
4.1.3 识别类
传统的过程式程序中,必须从最上面的 main 函数开始编写程序。设计一个面向对象系统时,则没有所谓的”最上面”。因此,学习 OOP 的初学者常常会感觉无从下手。答案是:首先从识别类开始,然后再为各个类添加方法。
识别类的一个简单经验是在分析问题的过程中寻找名词,而方法对应动词。例如,在订单处理系统中,有这样一些名词:
- 商品(Item);
- 订单(Order);
- 发货地址(Shipping address);
- 付款(Payment);
- 账户(Account)。
从这些名词就可以得到类 Item、Order 等。
接下来查找动词。商品要添加(add)到订单中,订单可以发货(ship)或取消(cancel),另外可以对订单完成付款(apply)。对于每一个动词,如”添加””发货””取消”或者”完成付款”,要识别出负责完成相应动作的对象。例如,当一个新商品添加到订单中时,订单对象就是负责的对象,因为它知道如何存储商品以及如何对商品进行排序。也就是说,add 应该是 Order 类的一个方法,它接受一个 Item 对象作为参数。
当然,这种”名词与动词”原则只是一种经验,在创建类的时候,只有经验能帮助你确定哪些名词和动词重要。
4.1.4 类之间的关系
类之间最常见的关系有
- 依赖( “uses-a” );
- 聚合( “has-a” );
- 继承( “is-a” )。
依赖(dependence),即 “ uses-a “ 关系,是一种最明显的也最一般的关系。例如,Order 类使用了 Account 类,因为 Order 对象需要访问 Account 对象来查看信用状态。但是 Item 类不依赖于 Account 类,因为 Item 对象不需要考虑客户账户。因此,如果一个类的方法要使用或操作另一个类的对象,我们就说前一个类依赖于后一个类。
应当尽可能减少相互依赖的类。这里的关键是,如果类 A 不知道 B 的存在,它就不会关心 B 的任何改变(这意味着 B 的改变不会在 A 中引入 bug)。用软件工程的方法来说,就是要尽可能减少类之间的耦合(coupling)。
聚合(aggregation),即 “ has-a “ 关系,很容易理解,因为这种关系很具体。例如,一个 Order 对象包含一些 Item 对象。包含关系意味着类 A 的对象包含类 B 的对象。
注释: 有些方法学家不喜欢聚合这个概念,而更喜欢使用更一般的”关联”关系。从建模的角度看,这是可以理解的。但对于程序员来说,”has-a”关系更加形象。我喜欢使用聚合还有另一个原因:关联的标准记法不是很清楚,请参见表 4-1。
表 4-1 表达类关系的 UML 记法
| 关系 | UML 连接符 |
|---|---|
| 继承 | ─────▷ |
| 接口实现 | ─ - - - ▷ |
| 依赖 | ┄┄┄┄> |
| 聚合 | ◇───── |
| 关联 | ────── |
| 直接关联 | ─────> |
继承(inheritance),即”is-a”关系,表示一个更特殊的类与一个更一般的类之间的关系。例如,RushOrder 类继承了 Order 类。在特殊化的 RushOrder 类中包含一些用于优先处理的特殊方法,还提供了一个计算运费的不同方法;而其他的方法,如添加商品、生成账单等都是从 Order 类继承来的。一般而言,如果类 D 扩展了类 C,类 D 会继承类 C 的方法,另外还会有一些额外的功能。
4.2 使用预定义类
在 Java 中,没有类就无法做任何事情,我们前面曾经接触过几个类。然而,并不是所有的类都表现出面向对象的典型特征。以 Math 类为例。你已经看到,可以直接使用 Math 类的方法,如 Math.random,而不必了解它具体是如何实现的,你只需要知道方法名和参数(如果有的话)。这正是封装的关键所在,当然所有类都是这样。但 Math 类只封装了功能,它不需要也不必隐藏数据。由于没有数据,因此也不必考虑创建对象和初始化它们的实例字段,因为根本没有实例字段!
下一节将会介绍一个更典型的类——Date 类,从中可以了解如何构造对象,以及如何调用类的方法。
4.2.1 对象与对象变量
要想使用对象,首先必须构造对象,并指定其初始状态。然后对对象应用方法。
在 Java 程序设计语言中,要使用**构造器(constructor,或称构造函数)**构造新实例。构造器是一种特殊的方法,其作用是构造并初始化对象。下面来看一个例子。标准 Java 库中包含一个 Date 类。它的对象可以描述一个时间点,例如,”December 31, 1999, 23:59:59 GMT”。
***构造器总是与类同名。***因此,Date 类的构造器就名为 Date。要想构造一个 Date 对象,需要在构造器前面加上 new 操作符,如下所示:
1 | |
这个表达式会构造一个新对象。这个对象初始化为当前的日期和时间。
如果需要的话,可以将这个对象传递给一个方法:
1 | |
或者,可以对刚构造的对象应用一个方法。Date 类中有一个 toString 方法。这个方法将生成日期的一个字符串描述。可以如下对新构造的 Date 对象应用 toString 方法:
1 | |
在这两个例子中,构造的对象仅使用了一次。通常,你可能希望保留所构造的对象从而能继续使用,为此,需要将对象存放在一个变量中:
1 | |
图 4-3 显示了对象变量 rightNow,它引用了新构造的对象。

图 4-3 创建一个新对象
对象与对象变量之间存在着一个重要的区别。例如,以下语句
1 | |
定义了一个对象变量 startTime,它可以引用 Date 类型的对象。但是,一定要认识到:***变量 startTime 不是一个对象,而且实际上它甚至还没有引用任何对象。***此时不能在这个变量上使用任何 Date 方法。下面的语句
1 | |
将产生编译错误。
必须首先初始化 startTime 变量,这里有两个选择。当然,可以初始化这个变量,让它引用一个新构造的对象:
1 | |
也可以设置这个变量,让它引用一个已有的对象:
1 | |
现在,这两个变量都引用同一个对象(如图 4-4 所示)。

图 4-4 对象变量引用同一个对象
要认识到重要的一点:对象变量并不实际包含一个对象,它只是引用一个对象。
在 Java 中,任何对象变量的值都是一个引用,指向存储在另外一个地方的某个对象。
new 操作符的返回值也是一个引用。下面的语句:
1 | |
有两个部分。表达式 new Date( ) 构造了一个 Date 类型的对象,它的值是新创建对象的一个引用。再将这个引用存储在 startTime 变量中。
可以显式地将对象变量设置为 null,指示这个对象变量目前没有引用任何对象。
1 | |
我们将在 4.3.6 节更详细地讨论 null。
C++ 注释: 很多人错误地认为 Java 中的对象变量就相当于 C++ 中的引用。然而,C++ 中没有 null 引用,而且引用不能赋值。应当把 Java 中的对象变量看作类似于 C++ 的对象指针。例如,
1 | |
实际上等同于
1 | |
一旦建立了这种关联,一切就清楚了。当然,只有使用了 new 调用后 Date* 指针才会初始化。就这一点而言,C++ 与 Java 的语法几乎是一样的。
1 | |
**如果把一个变量复制到另一个变量,两个变量就指向同一个日期,即它们是同一个对象的指针。**Java 中的 null 引用对应于 C++ 中的 NULL 指针。
所有的 Java 对象都存储在堆中。当一个对象包含另一个对象变量时,它只是包含另一个堆对象的指针。
在 C++ 中,指针十分令人头疼,因为它们很容易出错。稍不小心就会创建一个错误的指针,或者使内存管理出现问题。在 Java 语言中,这些问题都不存在。如果使用一个没有初始化的指针,那么运行时系统将会产生一个运行时错误,而不是生成随机的结果。另外,你不必担心内存管理问题,垃圾回收器会处理相关的事宜。
C++ 确实做了很大的努力,它通过支持复制构造器和赋值运算符来实现对象的自动复制。例如,一个链表(linked list)的副本是一个新链表,其内容与原始链表相同,但是有一组独立的链接。这样一来就可以适当地设计类,使它们与内置类型有相同的复制行为。在 Java 中,必须使用 clone 方法获得一个对象的完整副本。
4.2.2 Java 类库中的 LocalDate 类
在前面的例子中,我们使用了 Java 标准类库中的 Date 类。Date 类的实例有一个状态,也就是一个特定的时间点。
尽管在使用 Date 类时不必知道这一点,但时间是用距离一个固定时间点的毫秒数(可正可负)表示的,这个时间点就是所谓的纪元(epoch),它是 UTC 时间 1970 年 1 月 1 日 00:00:00。UTC 就是 Coordinated Universal Time(国际协调时间),与大家熟悉的 GMT(即 Greenwich Mean Time,格林尼治时间)一样,是一种实用的科学标准时间。
但是,Date 类对于处理人类记录日期的日历信息并不是很有用,如 “December 31, 1999”。这个特定的日期描述遵循 Gregorian 阳历,这是世界上大多数国家使用的日历。但是,同样的这个时间点采用中国或希伯来的阴历来描述会大不相同。
类库设计者决定将保存时间与给时间点命名分开。所以,标准 Java 类库分别包含了两个类:一个是用来表示时间点的 Date 类;另一个是用来表示熟悉的日历表示法表示日期的 LocalDate 类。Java 8 引入了另外一些类来处理日期和时间的不同方面。
将时间度量与日历分开是一种很好的面向对象设计。通常,最好使用不同的类表示不同的概念。
不要使用构造器来构造 LocalDate 类的对象。 实际上,应当使用静态工厂方法(factory method),它会代表你调用构造器。下面的表达式:
1 | |
会构造一个新对象,表示构造这个对象时的日期。
可以提供年、月和日来构造对应一个特定日期的对象:
1 | |
当然,通常我们都希望将构造的对象保存在一个对象变量中:
1 | |
一旦有了一个 LocalDate 对象,可以用方法 getYear、getMonthValue 和 getDayOfMonth 得到年、月和日:
1 | |
看起来这似乎没有多大的意义,因为这是构造对象时使用的那些值。不过,有时可能有一个计算得到的日期,然后你希望调用这些方法来了解它的更多信息。例如,plusDays 方法会生成一个新的 LocalDate*,如果把应用这个方法的对象称为当前对象,那么这个新日期对象则是距当前对象指定天数的一个新日期*:
1 | |
LocalDate 类封装了一些实例字段来维护所设置的日期。如果不查看源代码,就不可能知道类内部的日期表示。当然,封装的定义就在于内部表示并不重要,重要的是类对外提供的方法。
注释: 实际上,Date 类也有得到日、月、年的方法,分别是 getDay、getMonth 以及 getYear,不过这些方法已经废弃。当类库设计者意识到某个方法最初就不该引入时,就把它标记为废弃,不鼓励使用。最好不要使用废弃的方法,因为将来的某个类库版本很有可能会将它们完全删除。
提示: JDK提供了jdeprscan工具来检查你的代码中是否使用了Java API已经废弃的特性。有关说明参见https://docs.oracle.com/en/java/javase/17/docs/specs/man/jdeprscan.html。
4.2.3 更改器方法与访问器方法
再来看上一节中的plusDays方法调用:
1 | |
这个调用之后 newYearsEve 会有什么变化?它会改为1000天之后的日期吗?事实上,并没有。plusDays 方法会生成一个新的 LocalDate 对象,然后把这个新对象赋给 aThousandDaysLater 变量。原来的对象不做任何改动。我们说plusDays 方法没有**更改(mutate)**调用这个方法的对象。(这类似于第3章中见过的 String 类的 toUpperCase 方法。在一个字符串上调用 toUpperCase 时,这个字符串仍保持不变,并返回一个包含大写字符的新字符串。)
Java库的一个较早版本曾经有另一个处理日历的类,名为 GregorianCalendar 。可以如下为这个类表示的一个日期增加1000天:
1 | |
与 LocalDate.plusDays 方法不同,GregorianCalendar.add 方法是一个更改器方法(mutator method)。调用这个方法后,someDay 对象的状态会改变。可以如下查看新状态:
1 | |
正是因为这个原因,我们将变量命名为 someDay 而不是 newYearsEve ——调用这个更改器方法之后,它不再是新年前夜。
相反,只访问对象而不修改对象的方法有时称为访问器方法(accessor method)。例如,LocalDate.getYear 和GregorianCalendar.get 就是访问器方法。
在Java语言中,访问器方法与更改器方法在语法上没有明显的区别。
下面用一个具体应用 LocalDate 类的程序来结束这一节。这个程序将显示当前月的日历,格式如下:
1 | |
当前日期标记有一个 * 号。可以看到,这个程序需要知道如何计算某月份的天数以及一个给定日期是星期几。
下面来看这个程序的关键步骤。首先构造一个对象,并用当前的日期初始化。
1 | |
下面获得当前的月份和日期。
1 | |
然后,将 date 设置为这个月的第一天,并得到这一天为星期几。
1 | |
变量 weekday 设置为 DayOfWeek 类型的对象。我们调用这个对象的 getValue 方法来得到对应星期几的一个数值。这会得到一个整数,这里遵循国际惯例,即周末是一周的结束,星期一就返回 1,星期二返回 2,依此类推。星期日则返回 7。
注意,日历的第一行是缩进的,使当月第一天对应正确的星期几。下面的代码会打印表头和第一行的缩进:
1 | |
现在我们来打印日历的主体。进入一个循环,其中 date 遍历一个月中的每一天。
每次迭代中,我们要打印日期值。如果 date 是当前日期,这个日期则用一个 * 标记。接下来,把 date 推进到下一天。如果到达新的一周的第一天,则换行打印:
1 | |
什么时候结束呢?我们不知道这个月有几天,是 31 天、30 天、29 天还是 28 天?实际上,只要 date 还在当月就要继续迭代。
可以看到,利用 LocalDate 类可以编写一个日历程序,它能处理星期几以及各月天数不同等复杂问题。你并不需要知道 LocalDate 类如何计算月和星期几,只需要使用这个类的接口,也就是诸如 plusDays 和 getDayOfWeek 等方法。
4.3 自定义类
在第 3 章中,我们已经开始编写一些简单的类。但是,那些类都只包含一个简单的 main 方法。现在来学习如何编写更复杂的应用所需要的那种主力类(workhorse class)。通常,这些类没有 main 方法,而有自己的实例字段和实例方法。要想构建一个完整的程序,会结合使用多个类,其中只有一个类有 main 方法。
4.3.1 Employee 类
在 Java 中,最简单的类定义形式为:
1 | |
下面看一个非常简单的 Employee 类,编写工资管理系统时可能会用到:
1 | |
在这个程序中,我们构造了一个 Employee 数组,并填入了 3 个 Employee 对象:
1 | |
接下来,使用 Employee 类的 raiseSalary 方法将每个员工的薪水提高 5%:
1 | |
最后,调用 getName 方法、getSalary 方法和 getHireDay 方法打印各个员工的信息:
1 | |
注意,在这个示例程序中包含两个类:Employee 类和带有 public 访问修饰符的 EmployeeTest 类。EmployeeTest 类包含 main 方法,其中使用了前面介绍的代码。
源文件名是 EmployeeTest.java,这是因为文件名必须与 public 类的名字匹配。一个源文件中只能有一个公共类,但可以有任意数目的非公共类。
接下来,编译这段代码的时候,编译器将在目录中创建两个类文件:EmployeeTest.class 和 Employee.class。
然后启动这个程序,为字节码解释器提供程序中包含 main 方法的那个类的类名:
1 | |
字节码解释器开始运行 EmployeeTest 类的 main 方法中的代码。这个代码会先后构造 3 个 Employee 对象,并显示它们的状态。
4.3.2 使用多个源文件
在程序清单 4-2 中,一个源文件包含了两个类。许多程序员习惯将各个类放在一个单独的源文件中。例如,将 Employee 类存放在文件 Employee.java 中,而将 EmployeeTest 类存放在文件 EmployeeTest.java 中。
如果喜欢这样组织文件,可以有两种编译程序的方法。一种是使用通配符调用 Java 编译器:
1 | |
这样一来,所有与通配符匹配的源文件都将被编译成类文件。或者可以简单地键入以下命令:
1 | |
你可能会感到惊讶,使用第二种方式时并没有显式地编译 Employee.java。不过,当 Java 编译器发现 EmployeeTest.java 中使用了 Employee 类时,它会查找名为 Employee.class 的文件。如果没有找到这个类文件,就会自动搜索 Employee.java 并编译这个文件。另外,如果 Employee.java 的版本较已有的 Employee.class 文件版本更新,Java 编译器就会自动重新编译这个文件。
4.3.3 剖析 Employee 类
下面各小节将对 Employee 类进行剖析。首先从这个类的方法开始。通过查看源代码会发现,这个类包含一个构造器和 4 个方法:
1 | |
这个类的所有方法都被标记为 public。关键字 public 意味着任何类的任何方法都可以调用这些方法(共有 4 种访问级别)。
接下来,需要注意在 Employee 类的实例中有 3 个实例字段,用来存放将要操作的数据:
1 | |
关键字 private 确保只有 Employee 类本身的方法能够访问这些实例字段,任何其他类的方法都不能读写这些字段。
注释: 可以用 public 标记实例字段,但这是一种很不好的做法。public 实例字段允许程序的任何部分都能对其进行读取和修改,这就完全破坏了封装。任何类的任何方法都可以修改 public 字段,从我们的经验来看,有些代码将利用这种做法获取权限,而这是我们最不希望看到的。因此,这里强烈建议将实例字段标记为 private。
最后,请注意,有两个实例字段本身就是对象:name 字段是 String 类对象的引用,hireDay 字段是 LocalDate 类对象的引用。类经常包含类类型的实例字段。
4.3.4 从构造器开始
下面先看看 Employee 类的构造器:
1 | |
可以看到,构造器与类同名。构造 Employee 类的对象时,构造器会运行,这会将实例字段初始化为所希望的初始状态。
例如,使用下面这个代码创建 Employee 类的一个实例时:
1 | |
将如下设置实例字段:
1 | |
构造器与其他方法有一个重要的不同。**构造器总是结合 new 操作符来调用。不能对一个已经存在的对象调用构造器来重新设置实例字段。**例如,
1 | |
将产生编译错误。
本章后面还会更详细地介绍有关构造器的内容。现在只需要记住:
- 构造器与类同名。
- 每个类可以有一个以上的构造器。
- 构造器可以有 0 个、1 个或多个参数。
- 构造器没有返回值。
- 构造器总是结合 new 操作符一起调用。
所有 Java 对象都是在堆中构造的,构造器总是结合 new 操作符一起使用。
警告: 请注意,不要引入与实例字段同名的局部变量。例如,下面的构造器将不会设置 name 或 salary 实例字段:
(实例字段声明在类内部但在任何方法之外)
1 | |
这个构造器声明了局部变量name和salary。这些变量只能在构造器内部访问,它们会遮蔽(shadow)同名的实例字段。因此,必须注意在所有的方法中都不要使用与实例字段同名的变量。
4.3.5 用var声明局部变量
**在 Java 10 中,如果可以从变量的初始值推导出它们的类型,那么可以用var关键字声明局部变量,而无须指定类型。**例如,可以不这样声明:
1 | |
只需要写为:
1 | |
这一点很好,因为这样可以避免重复写类型名 Employee。
注意:var关键字只能用于方法中的局部变量。参数和字段的类型必须声明。
4.3.6 使用null引用
对象变量包含一个对象的引用,或者包含一个特殊值 null,后者表示没有引用任何对象。
如果对 null 值应用一个方法,会产生一个 NullPointerException 异常。
1 | |
这是一个很严重的错误,类似于 “索引越界” 异常。如果你的程序没有 “捕获” 异常,那么程序就会终止。正常情况下,程序并不捕获这些异常,而是依赖于程序员从一开始就不要带来异常。
提示: 程序因 NullPointerException 异常终止时,栈轨迹会显示问题出现在哪一行代码中。从 Java 17 开始,错误消息会包含 null 值的变量或方法名。例如,在以下调用中:
1 | |
错误消息会告诉你 e 是否为 null 或者 getHireDay 是否返回 null。
定义一个类时,最好清楚地知道哪些字段可能为 null。在我们的例子中,我们不希望 name 或 hireDay 字段为 null 。不用担心 salary 字段,这个字段是基本类型,所以不能使用 null 。
hireDay 字段肯定是非 null 的,因为它初始化为一个新的 LocalDate 对象。但是 name 可能为 null ,如果调用构造器时为 n 提供的变量是 null,name 就会是 null 。
对此有两种解决方法。”宽容” 方法是把 null 参数转换为一个适当的非 null 值:
1 | |
Objects 类对此提供了一个便利方法:
1 | |
“严格” 方法则干脆拒绝 null 参数:
1 | |
如果用 null 名字构造一个 Employee 对象,就会产生 NullPointerException 异常。乍看上去这种补救方法好像不太有用,不过这种方法有两个好处:
- 异常报告会提供这个问题的描述。
- 异常报告会准确地指出问题所在的位置,否则 NullPointerException 异常会出现在其他地方,而很难追踪到正导致问题的构造器参数。
注释: 如果要接受一个对象引用作为构造参数,就要问问自己:是不是真的希望接受可有可无的值。如果不是,那么”严格”方法更合适。
4.3.7 隐式参数与显式参数
**方法会操作对象并访问它们的实例字段。**例如,以下方法:
1 | |
将调用这个方法的对象的 salary 实例字段设置为一个新值。考虑下面这个调用:
1 | |
其作用是将 number007.salary 字段的值增加5%。具体地说,这个调用将执行以下指令:
1 | |
raiseSalary 方法有两个参数。第一个参数称为隐式(implicit)参数,是出现在方法名前面的 Employee 类型的对象。第二个参数是位于方法名后面括号中的数值,这是一个显式(explicit)参数。(有人把隐式参数称为方法调用的目标或接收者。)
可以看到,显式参数显示在程序中,例如 double byPercent 。隐式参数则没有出现在方法声明中。
在每一个方法中,关键字 this 指示隐式参数。如果愿意,可以如下写 raiseSalary 方法:
1 | |
有些程序员更偏爱这样的风格,因为这样可以将实例字段与局部变量明显地区分开来。
在 Java 中,所有的方法都必须在类的内部定义,但这并不表示它们是内联方法。是否将某个方法设置为内联方法是 Java 虚拟机的任务。即时编译器会关注那些简短、经常调用而且没有被覆盖的方法调用,并进行优化。
4.3.8 封装的优点
最后再仔细看一下非常简单的 getName 方法、getSalary 方法和 getHireDay 方法。
1 | |
这些都是典型的访问器方法。由于它们只返回实例字段的值,因此又称为字段访问器(field accessor)。
name 是一个只读字段,一旦在构造器中设置,就没有办法能够修改这个字段。这样我们可以确保 name 字段不会受到外界的破坏。
虽然 salary 不是只读字段,但是它只能用 raiseSalary 方法修改。具体地,如果这个值出现了错误,那么只需要调试这个 raiseSalary 方法就可以了。如果 salary 字段是公共的,破坏这个字段值的捣蛋程序有可能出现在任何地方(那就很难调试了)。
有些时候,可能想要获得或设置实例字段的值,那么你需要提供下面三项内容:
- 一个私有的实例字段;
- 一个公共的字段访问器方法;
- 一个公共的字段更改器方法。
首先,可以改变内部实现,而不影响该类方法之外的任何其他代码。例如,如果将存储姓名的字段改为:
1 | |
那么 getName 方法可以改为返回
1 | |
这个修改对于程序的其余部分是完全不可见的。
当然,为了进行新旧数据表示之间的转换,访问器方法和更改器方法可能需要做许多工作。这将为我们带来第二点好处:更改器方法可以完成错误检查,而只对字段赋值的代码不会费心这么做。例如,setSalary 方法可以检查工资是否小于 0。
警告: 注意不要编写返回可变对象引用的访问器方法。在本节之前的一版中,我们的 Employee 类就违反了这个设计原则,其中的 getHireDay 方法返回了一个 Date 类对象:
1 | |
LocalDate 类没有更改器方法,与之不同,Date 类有一个更改器方法 setTime,可以设置毫秒数。
Date 对象是可变的,这一点就破坏了封装性!考虑下面这段有问题的代码:
1 | |
出错的原因很微妙。d 和 harry.hireDay 引用同一个对象(请参见图 4-5)。对 d 调用更改器方法就可以自动地改变这个成员对象的私有状态!

如果需要返回一个可变对象的引用,应该首先对它进行克隆(clone)。对象克隆是指存放在另一个位置上的对象副本。有关对象克隆的详细内容将在第 6 章中讨论。下面是修改后的代码:
1 | |
凭经验可知,如果需要返回一个可变数据域的拷贝,就应该使用克隆。
4.3.9 基于类的访问权限
从前面已经知道,方法可以访问所调用对象的私有数据。一个方法可以访问所属类的所有对象的私有数据。例如,下面看一下用来比较两个雇员的equals方法。
1 | |
典型的调用方式是
1 | |
这个方法访问 harry 的私有域,这点并不会引发奇怪。然而,它还访问 boss 的私有域。这是合法的,其原因是 boss 是 Employee 类对象,而 Employee 类的方法可以访问 Employee 类的任何一个对象的私有域。
4.3.10 私有方法
在Java中,为了实现一个私有的方法,只需要将关键字public改为private即可。
如果将一个方法设置为私有,倘若你改变了方法的具体实现,并没有义务保证这个方法依然可用。如果数据的表示发生了变化,那么这个方法可能变得更复杂,或者不再需要;这并不重要。重点在于,只要方法是私有的,类的设计者就可以确信它不会在别处使用,所以可以将其删去。如果一个方法是公共的,就不能简单地将其删除,因为可能会有其他代码依赖这个方法。
4.3.11 final 实例字段
可以将实例字段定义为 final。这样的字段必须在构造对象时初始化。也就是说,必须确保每一个构造器执行之后,这个字段的值已经设置,并且以后不能再修改这个字段。例如,可以将 Employee 类中的 name 字段声明为 final,因为在对象构造之后,这个值不会再改变,即没有 setName 方法。
1 | |
final 修饰符对于类型为基本类型或者不可变类的字段尤其有用。(如果类中的所有方法都不会改变其对象,这样的类就是不可变类。例如,String 类就是不可变的。)
对于可变类,使用 final 修饰符可能会造成混乱。例如,考虑以下字段:
1 | |
它在 Employee 构造器中初始化为
1 | |
final 关键字只是表示存储在 evaluations 变量中的对象引用不会再指示另一个不同的 StringBuilder 对象。不过这个对象可以更改:
1 | |
4.4 静态字段与静态方法
在前面给出的示例程序中,main 方法都标记了 static 修饰符。下面来讨论这个修饰符的含义。
4.4.1 静态字段
如果将一个字段定义为 static,那么这个字段并不出现在每个类的对象中。每个静态字段只有一个副本。可以认为静态字段属于类,而不属于单个对象。例如,假设需要为每一个员工分配唯一的标识码,这里为 Employee 类添加一个实例字段 id 和一个静态字段 nextId:
1 | |
现在,每一个 Employee 对象都有自己的 id 字段,但这个类的所有实例将共享一个 nextId 字段。换句话说,如果有 1000 个 Employee 类对象,则有 1000 个实例字段 id,每个对象有一个实例字段 id。但是,只有一个静态字段 nextId。即使没有 Employee 对象,静态字段 nextId 也存在。它属于类,而不属于任何单个对象。
注释: 在一些面向对象程序设计语言中,静态字段被称为类字段。术语”静态”只是沿用了 C++ 的叫法,并无实际意义。
在构造器中,我们为新 Employee 对象分配下一个可用的 ID,然后将其增 1:
1 | |
假设我们构造了对象 harry。harry 的 id 字段设置为静态字段 nextId 的当前值,并将静态字段 nextId 的值加 1:
1 | |
4.4.2 静态常量
静态变量使用比较少,但静态常量却很常用。例如,Math类中定义了一个静态常量:
1 | |
在你的程序中,可以用 Math.PI 来访问这个常量。
如果省略关键字 static,那么 PI 就变成了 Math 类的一个实例字段。也就是说,需要通过 Math 类的一个对象来访问PI,并且每一个 Math 对象都有它自己的一个PI副本。
另一个你已经多次使用的静态常量是 System.out 。它在 System 类中声明如下:
1 | |
前面曾经多次提到过,最好不要有公共字段,因为谁都可以修改公共字段。不过,公共常量(即 final 字段)却没问题。因为 out 被声明为 final ,所以,不允许再将它重新赋值为另一个打印流:
1 | |
注释: 如果查看 System 类,就会发现有一个 setOut 方法可以将 System.out 设置为不同的流。你可能会感到奇怪,为什么这个方法可以修改 final 变量的值。原因在于,setOut 方法是一个原生方法,而不是在 Java 语言中实现的。原生方法可以绕过 Java 语言的访问控制机制。这是一种特殊的解决方法,你自己编写程序时不要模仿这种做法。
4.4.3 静态方法
**静态方法是不操作对象的方法。**例如,Math 类的 pow 方法就是一个静态方法。以下表达式:
1 | |
会计算幂 x^a。它并不使用任何 Math 对象来完成这个任务。换句话说,它没有隐含参数。
可以认为静态方法是没有 this 参数的方法(在一个非静态方法中,this 参数指示这个方法的隐含参数)。
Employee 类的静态方法不能访问 id 实例字段,因为它并不操作对象。但是,静态方法可以访问静态字段。下面是这样一个静态方法的示例:
1 | |
要调用这个方法,需要提供类名:
1 | |
这个方法可以省略关键字 static 。但是,这样一来,你就需要通过 Employee 类型的对象引用来调用这个方法。
注释: 可以使用对象调用静态方法,这是合法的。例如,如果 harry 是一个 Employee 对象,那么可以调用 harry.advanceId( ) 而不是 Employee.advanceId( )。不过,我发现这种写法很容易造成混淆,其原因是 advanceId 方法计算的结果与 harry 毫无关系。我们建议使用类名而不是对象来调用静态方法。
下面两种情况可以使用静态方法:
- 方法不需要访问对象状态,因为它需要的所有参数都通过显式参数提供(例如 Math.pow)。
- 方法只需要访问类的静态字段(例如 Employee.advanceId)。
4.4.4 工厂方法
静态方法还有另外一种常见的用途。类似 LocalDate 和 NumberFormat 的类使用静态工厂方法(factory method)来构造对象。你已经见过工厂方法 LocalDate.now 和 LocalDate.of。可以如下得到不同样式的格式化对象:
1 | |
为什么 NumberFormat 类不使用构造器来创建对象呢?这有两个原因:
无法为构造器命名。构造器的名字总是要与类名相同。但是,这里希望有两个不同的名字,分别得到货币实例和百分比实例。
使用构造器时,无法改变所构造对象的类型。而工厂方法实际上将返回 DecimalFormat 类的对象,这是继承 NumberFormat 的一个子类。
4.4.5 main 方法
需要指出,可以调用静态方法不需要任何对象。例如,不需要构造 Math 类的任何对象就可以调用 Math.pow。
同理,main 方法也是一个静态方法。
1 | |
main 方法不对任何对象进行操作。事实上,启动程序时还没有任何对象。将执行静态 main 方法,并构造程序所需要的对象。
提示: 每一个类都可以有一个 main 方法。这是为类增加演示代码的一个技巧。例如,可以在 Employee 类中添加一个 main 方法:
1 | |
要运行 Employee 类的演示,只需要执行:
1 | |
如果 Employee 类是一个更大的应用的一部分,那么可以使用以下命令运行这个应用:
1 | |
Employee 类的 main 方法永远不会执行。
4.5 方法参数
按值调用(call by value)表示方法接收的是调用者提供的值。而按引用调用(call by reference)表示方法接收的是调用者提供的变量位置(location)。所以,方法可以修改按引用传递的变量的值,而不能修改按值传递的变量的值。”按……调用”(call by)是一个标准的计算机科学术语,用来描述各种程序设计语言(不只是 Java)中方法参数的行为(事实上,以前还有一种按名调用(call by name),Algol 程序设计语言是最古老的高级语言之一,它就采用了按名调用方式。不过,这种传递方式已经成为历史)。
Java 程序设计语言总是采用按值调用。也就是说,方法会得到所有参数值的一个副本。具体来说,方法不能修改传递给它的任何参数变量的内容。
例如,考虑下面的调用:
1 | |
不论这个方法具体如何实现,我们知道,在这个方法调用之后,percent 的值还是 10。下面再仔细研究一下这种情况。假定一个方法试图将一个参数值增加至 3 倍:
1 | |
然后调用这个方法:
1 | |
不过,这并不起作用。调用这个方法之后,percent 的值还是 10。下面来看发生了什么:
- x 初始化为 percent 值的一个副本(也就是 10)。
- x 乘以 3 后等于 30,但是 percent 仍然是 10(如图 4-6 所示)。
- 这个方法结束之后,参数变量 x 不再使用。

不过,有两种不同类型的方法参数:
- 基本数据类型(数字、布尔值)。
- 对象引用。
你已经看到,一个方法不可能修改基本数据类型的参数,而对象参数就不同了,可以很容易地实现一个方法将一个员工的工资增至 3 倍:
1 | |
如下调用这个方法时,
1 | |
具体的执行过程为:
x 初始化为 harry 值的一个副本,这里就是一个对象引用。
raiseSalary 方法应用于这个对象引用。x 和 harry 同时引用的那个 Employee 对象的工资提高了 200%。
方法结束后,参数变量 x 不再使用。当然,对象变量 harry 继续引用那个工资增至 3 倍的员工对象(如图 4-7 所示)。

图 4-7 修改参数引用的对象有持续效果
可以看到,实现方法改变对象参数的状态是完全可以的,实际上也相当常见。理由很简单,方法得到的是对象引用的副本,原来的对象引用和这个副本都引用同一个对象。
很多程序设计语言(特别是 C++ 和 Pascal)提供了两种参数传递方式:按值调用和按引用调用。有些程序员(甚至有些书的作者)声称 Java 对对象采用的是按引用调用,实际上,这是不对的。由于这种误解很常见,所以很有必要给出一个反例来详细地说明这个问题。
下面来编写一个交换两个 Employee 对象的方法:
1 | |
如果 Java 对对象采用的是按引用调用,那么这个方法就应该能够实现交换:
1 | |
但是,这个方法并没有改变存储在变量 a 和 b 中的对象引用。swap 方法的参数 x 和 y 初始化为两个对象引用的副本,这个方法交换的是这两个副本。
1 | |
方法结束时,参数变量 x 和 y 被丢弃了。原来的变量 a 和 b 仍然引用这个方法调用之前所引用的对象(如图4-8所示)。

图 4-8 交换参数变量没有持续效果
这说明:Java程序设计语言对对象采用的不是按引用调用。实际上,对象引用(object reference)是按值传递的。
下面来总结在Java中对方法参数能做什么和不能做什么:
- 方法不能修改基本数据类型的参数(即数值型或布尔型)。
- 方法可以改变对象参数的状态。
- 方法不能让一个对象参数引用一个新对象。
程序清单4-4 中的程序展示了这几点。在这个程序中,首先试图将一个数值参数的值增至3倍,但没有成功:
1 | |
随后,成功地将一个员工的工资增至3倍:
1 | |
方法结束之后,harry 引用的对象的状态发生了改变。这是因为这个方法可以通过对象引用的副本修改所引用对象的状态。
最后,程序演示了 swap 方法的失败结果:
1 | |
可以看出,参数变量 x 和 y 交换了,但是变量 a 和 b 没有受到影响。
4.6 对象构造
4.6.1 重载
有些类有多个构造器。例如,可以如下构造一个空的StringBuilder对象:
1 | |
或者,可以指定一个初始字符串:
1 | |
这种功能叫作重载(overloading)。如果多个方法(比如,StringBuilder构造器方法)有相同的方法名但有不同的参数,便出现了重载。编译器必须挑选出具体调用哪个方法。它用各个方法首部中的参数类型与特定方法调用中所使用的值类型进行匹配,来选出正确的方法。如果编译器无法匹配参数,就会产生编译时错误,这可能因为根本不存在匹配,或者所有重载方法中没有一个相对更好的方法(这个查找匹配的过程称为重载解析(overloading resolution))。
注释: Java允许重载任何方法,而不只是构造器方法。因此,要完整地描述一个方法,需要指定方法名以及参数类型,这叫作方法的签名(signature)。例如,String类有4个名为indexOf的公共方法。它们的签名是
1 | |
返回类型不是方法签名的一部分。也就是说,不能有两个名字相同、参数类型也相同却有不同返回类型的方法。
4.6.2 默认字段初始化
如果在构造器中没有显式地为一个字段设置初始值,就会将它自动设置为默认值:数值将设置为0,布尔值为false,对象引用为null。有些人认为依赖默认值的做法是一种不好的编程实践。确实,如果不明确地对字段进行初始化,就会影响程序代码的可读性。
注释: 这是字段与局部变量的一个重要区别。方法中的局部变量必须明确地初始化。但是在类中,如果没有初始化类中的字段,将会自动初始化为默认值(0、false 或 null)。
例如,考虑 Employee 类。假定没有在构造器中指定如何初始化某些字段,默认情况下,就会将 salary 字段初始化为0,将 name 和 hireDay 字段初始化为 null 。
但是,这并不是一个好主意。如果有人调用 getName 方法或 getHireDay 方法,就会得到一个 null 引用,这可能不是他们想要的结果:
1 | |
4.6.3 无参数的构造器
很多类都包含无参数的构造器,由无参数构造器创建对象时,对象的状态会设置为适当的默认值。例如,以下是 Employee 类的一个无参数构造器:
1 | |
如果你写的类没有构造器,就会为你提供一个无参数构造器。这个构造器将所有的实例字段设置为相应的默认值。所以,实例字段中的所有数值型数据会设置为 0,所有布尔值设置为 false,所有对象变量将设置为 null。
如果类中提供了至少一个构造器,但是没有提供无参数构造器,那么构造对象时就必须提供参数,否则就是不合格的。例如,程序清单 4-2 中的 Employee 类提供了一个构造器:
1 | |
对于这个类,构造默认的员工就是不合格的。也就是说,以下调用
1 | |
将产生错误。
警告: 请记住,仅当类没有任何其他构造器的时候,你才会得到一个默认的无参数构造器。编写类的时候,如果写了一个你自己的构造器,要想让这个类的使用者能够通过以下调用创建一个实例:
1 | |
你就必须提供一个无参数的构造器。当然,如果接受所有字段设置为默认值,则只需要提供以下代码:
1 | |
4.6.4 显式字段初始化
通过重载类的构造器方法,可以采用多种形式设置类实例字段的初始状态。不论调用哪个构造器,每个实例字段都要设置为一个有意义的初始值,确保这一点总是一个好主意。
可以在类定义中直接为任何字段赋值。例如:
1 | |
在执行构造器之前完成这个赋值。如果一个类的所有构造器都需要把某个特定的实例字段设置为同一个值,那么这个语法尤其有用。
初始值不一定是常量值。在下面的例子中,就是利用方法调用初始化一个字段。考虑以下Employee类,其中每个员工有一个id字段。可以使用以下方式进行初始化:
1 | |
4.6.5 参数名
在编写很小的构造器时(这十分常见),在为参数命名时可能有些困惑。
我们通常喜欢用单个字母作为参数名:
1 | |
但这样做有一个缺点:只有阅读代码才能够了解参数n和参数s的含义。
有些程序员在每个参数前面加上一个前缀”a”:
1 | |
这样更好一些。读者一眼就能够看懂参数的含义。
还有一种常用的技巧,它基于这样的事实:参数变量会遮蔽同名的实例字段。例如,如果将参数命名为salary,那么salary将指示这个参数,而不是实例字段。但是,还是可以用this.salary访问实例字段。回想一下,this指示隐式参数,也就是所构造的对象。下面来看一个示例:
1 | |
4.6.6 调用另一个构造器
关键字 this 指示一个方法的隐式参数。不过,这个关键字还有另外一个含义。
如果构造器的第一个语句形如 this( … ),这个构造器将调用同一个类的另一个构造器。
下面是一个典型的例子:
1 | |
当调用 new Employee(60000) 时,Employee(double) 构造器将调用 Employee(String, double) 构造器。
采用这种方式使用 this 关键字非常有用,这样只需要写一次公共构造代码。
4.6.7 初始化块
前面已经介绍过两种初始化实例字段的方法:
- 在构造器中设置值;
- 在声明中赋值。
实际上,Java 还有第三种机制,称为初始化块(initialization block)。在一个类的声明中,可以包含任意的代码块。构造这个类的对象时,这些块就会执行。例如,
1 | |
在这个示例中,无论使用哪个构造器构造对象,id 字段都会在对象初始化块中初始化。首先运行初始化块,然后才运行构造器的主体部分。
这种机制不是必需的,也不常见。通常会直接将初始化代码放在构造器中。
注释: 可以在初始化块中设置字段,即使这些字段在类后面才定义,这是合法的。但是,为了避免循环定义,不允许读取在后面初始化的字段。具体规则请参见 Java 语言规范的 8.3.3 节(http://docs.oracle.com/javase/specs)。这些规则太过复杂,让编译器的实现者都很头疼,所以较早的 Java 版本中这些规则的实现存在一些小错误。因此,建议总是将初始化块放在字段定义之后。
由于初始化实例字段有多种途径,所以列出构造过程的所有路径可能让人很费解。下面是调用构造器时的具体处理步骤:
- 如果构造器的第一行调用了另一个构造器,则基于所提供的参数执行第二个构造器。
- 否则,
a) 所有实例字段初始化为其默认值(0、false 或 null)。
b) 按照在类声明中出现的顺序,执行所有字段初始化方法和初始化块。 - 执行构造器主体代码。
可以通过提供一个初始值,或者使用一个静态的初始化块来初始化静态字段。前面已经介绍过第一种机制:
1 | |
如果类的静态字段需要复杂的初始化代码,那么可以使用静态的初始化块。
将代码放在一个块中,并标记关键字 static。下面是一个示例。我们希望将员工 ID 的起始值赋为一个小于 10000 的随机整数。
1 | |
在类第一次加载的时候,会完成静态字段的初始化。与实例字段一样,除非将静态字段显式地设置成其他值,否则默认的初始值为0、false或null。所有的静态字段初始化方法以及静态初始化块都将依照类声明中出现的顺序执行。
注释: 让人惊讶的是,在JDK 6之前,完全可以用Java编写一个没有main方法的”Hello, World”程序。
1 | |
当用 java Hello 调用这个类时,就会加载这个类,静态初始化块将会打印 “Hello, World”。在此之后才会显示一个消息指出 main 未定义。从 Java 7 以后,java 程序会首先检查是否有一个 main 方法。
这个例子使用了 Random 类来生成随机数。从 JDK 17 开始,java.util.random 包提供了考虑多种因素的强算法的实现。阅读 java.util.random 包的API文档,其中对如何选择算法给出了建议。然后通过提供算法名来得到一个实例,如下所示:
1 | |
调用 generator.nextInt(n) 或其他 RandomGenerator 方法来生成随机数。(RandomGenerator 是一个接口,第6章将介绍接口概念。Random 类的对象可以使用所有 RandomGenerator 方法。)
4.6.8 对象析构与 finalize 方法
有些面向对象的程序设计语言(特别是 C++)有显式的析构器方法,其中放置一些清理代码,当对象不再使用可能需要执行这些清理代码。在析构器中,最常见的操作是回收分配给对象的存储空间。由于 Java 会完成自动的垃圾回收,不需要人工回收内存,所以 Java 不支持析构器。
当然,某些对象使用了内存之外的其他资源,例如,文件或使用系统资源的另一个对象的句柄。在这种情况下,当资源不再需要时,将其回收和再利用就十分重要。
如果一个资源一旦使用完就需要立即关闭,那么应当提供一个 close 方法来完成必要的清理工作。可以在对象使用完时调用这个 close 方法。
如果可以等到虚拟机退出,那么可以用方法 Runtime.addShutdownHook 增加一个”关闭钩”(shutdown hook)。在 Java 9 中,可以使用 Cleaner 类注册一个动作,当对象不再可达时(除了清洁器还能访问,其他对象都无法访问这个对象),就会完成这个动作。在实际中这些情况很少见。可以参见 API 文档来了解这两种方法的详细内容。
警告: 不要使用 finalize 方法来完成清理。这个方法原本要在垃圾回收器清理对象之前调用。不过,你并不能知道这个方法到底什么时候调用,而且该方法已经被废弃。
4.7 记录
有时,数据就只是数据,而面向对象程序设计提供的数据隐藏有些碍事。考虑一个类 Point,这个类描述平面上的一个点,有 x 和 y 坐标。
当然,可以如下创建一个类:
1 | |
这里隐藏了 x 和 y,然后通过获取方法来获得这些值,不过,这种做法对我们确实有好处吗?
我们将来想改变 Point 的实现吗?当然,还有极坐标,不过对于图形 API,你可能不会使用极坐标。在实际中,平面上的一点就用 x 和 y 坐标来描述。
为了更简洁地定义这些类,JDK 14 引入了一个预览特性:”记录”。最终版本在 JDK 16 中发布。
4.7.1 记录概念
记录(record)是一种特殊形式的类,其状态不可变,而且公共可读。可以如下将 Point 定义为一个记录:
1 | |
其结果是有以下实例字段的类:
1 | |
在 Java 语言规范中,一个记录的实例字段称为组件(component)。
这个类有一个构造器:
1 | |
和以下访问方法:
1 | |
注意,访问方法名为 x 和 y,而不是 getX 和 getY。(Java 中实例字段可以与方法同名,这是合法的。)
1 | |
注释: Java没有遵循 get 约定,因为那有些麻烦。对于布尔字段,通常使用 is 而不是 get。而且首字母大写可能有问题。如果一个类既有 x 字段又有 X 字段,会发生什么?有些程序员不太满意,因为他们原先的类不能轻松地变为记录。不过实际上,那些遗留类中,很多都是可变的,所以并不适合转换为记录。
除了字段访问方法,每个记录有3个自动定义的方法:toString、equals和hashCode。下一章会更多地了解这些方法。
警告: 对于这些自动提供的方法,也可以定义你自己的版本,只要它们有相同的参数和返回类型。例如,下面的定义就是合法的:
1 | |
不过,这并不是一个好主意。
可以为一个记录增加你自己的方法:
1 | |
与所有其他类一样,记录可以有静态字段和方法:
1 | |
不过,不能为记录增加实例字段:
1 | |
警告: 记录的实例字段自动为 final 字段。不过,它们可能是可变对象的引用。
1 | |
这样记录实例将是可变的:
1 | |
如果希望记录实例是不可变的,那么字段就不能使用可变的类型。
提示: 对于完全由一组变量表示的不可变数据,要使用记录而不是类。如果数据是可变的,或者数据表示可能随时间改变,则使用类。记录更易读、更高效,而且在并发程序中更安全。
4.7.2 构造器:标准、自定义和简洁
自动定义地设置所有实例字段的构造器称为标准构造器(canonical constructor)。还可以定义另外的自定义构造器(custom constructor)。这种构造器的第一个语句必须调用另一个构造器,所以最终会调用标准构造器。下面来看一个例子:
1 | |
这个记录有两个构造器:标准构造器和一个生成原点的无参数构造器。如果标准构造器需要完成额外的工作,那么可以提供你自己的实现:
1 | |
不过,实现标准构造器时,建议使用一种简洁(compact)形式。不用指定参数列表:
1 | |
简洁形式的主体是标准构造器的”前奏”。它只是在为实例字段 this.from 和 this.to 赋值之前修改参数变量 from 和 to 。不能在简洁构造器的主体中读取或修改实例字段。
4.8 包
Java允许使用**包(package)**将类组织在一个集合中。借助包可以方便地组织你的代码,并将你自己的代码与其他人提供的代码库分开。下面我们将介绍如何使用和创建包。
4.8.1 包名
**使用包的主要原因是确保类名的唯一性。**假如两个程序员不约而同地提供了 Employee 类,只要他们将自己的类放置在不同的包中,就不会产生冲突。事实上,为了保证包名的绝对唯一性,可以使用一个因特网域名(这显然是唯一的)以逆序的形式作为包名,然后对于不同的项目使用不同的子包。例如,考虑域名 horstmann.com 。如果逆序来写,就得到了包名 com.horstmann 。然后可以追加一个项目名,如 com.horstmann.corejava 。如果再把 Employee 类放在这个包里,那么这个类的”完全限定”名就是 com.horstmann.corejava.Employee 。
注释: 从编译器的角度来看,嵌套的包之间没有任何关系。例如,java.util 包与 java.util.jar 包毫无关系。每一个包都是独立的类集合。
4.8.2 类的导入
一个类可以使用所属包(这个类所在的包)中的所有类,以及其他包中的公共类(public class)。
我们可以采用两种方式访问另一个包中的公共类。第一种方式是使用完全限定名(fully qualified name),也就是包名后面跟着类名。例如:
1 | |
这显然很烦琐。更简单且更常用的方式是使用 import 语句。import 语句的关键是可以提供一种简写方式来引用包中各个类。一旦增加了 import 语句,在使用类时,就不必写出类的全名了。
可以使用 import 语句导入一个特定的类或者整个包。import 语句应该位于源文件的顶部(但位于 package 语句的后面)。例如,可以使用下面这条语句导入 java.time 包中的所有类。
1 | |
然后,就可以使用
1 | |
而不需要在前面加上包前缀。还可以导入一个包中的特定类:
1 | |
java.time.* 的语法比较简单,对代码的规模也没有任何负面影响。不过,如果能够明确地指出所导入的类,那么代码的读者就能更加准确地知道你使用了哪些类。
提示: 在 Eclipse 中,可以使用菜单选项 Source → Organize Imports。诸如 import java.util.*; 等语句将会自动扩展为一组特定的导入语句,如:
1 | |
这是一个十分便捷的特性。
但是,需要注意的是,只能使用星号 (*) 导入一个包,而不能使用 import java.* 或 import java.*.* 导入以 java 为前缀的所有包。
在大多数情况下,可以只导入你需要的包,并无须过多考虑。但在发生命名冲突的时候,就要注意包了。例如,java.util 和 java.sql 包都有 Date 类。假设在程序中导入了这两个包:
1 | |
在程序中使用 Date 类的时候,就会出现一个编译错误:
1 | |
此时,编译器无法确定你想使用的是哪一个 Date 类。可以增加一个特定的 import 语句来解决这个问题:
1 | |
如果这两个 Date 类都需要使用,又该怎么办呢?答案是,在每个类名的前面加上完整的包名。
1 | |
在包中定位类是编译器(compiler)的工作。类文件中的字节码总是使用完整的包名来引用其他类。
4.8.3 静态导入
有一种 import 语句允许导入静态方法和静态字段,而不只是类。例如,如果在源文件最上面添加一条指令:
1 | |
就可以使用 System 类的静态方法和静态字段,而不必加类名前缀:
1 | |
另外,还可以导入特定的方法或字段:
1 | |
实际上,是否有很多程序员想要用简写 System.out 或 System.exit,这一点很让人怀疑。这样写出的代码看起来不太清晰。不过,
1 | |
看起来则比
1 | |
简洁得多。
4.8.4 在包中增加类
要想将类放入包中,就必须将包名放在源文件的开头,即放在定义这个包中各个类的代码之前。例如,程序清单 4-8 中的文件 Employee.java 开头是这样的:
1 | |
如果没有在源文件中放置package语句,那么这个源文件中的类就属于无名包(unnamed package)。无名包没有包名。到目前为止,我们定义的所有类都在无名包中。
将源文件放到与完整包名匹配的子目录中。例如,com.horstmann.corejava 包中的所有源文件应该放置在子目录com/horstmann/corejava 中(Windows中则是 com\horstmann\corejava )。编译器将类文件也放在相同的目录结构中。
程序清单4-7 和程序清单4-8 中的程序分别放在两个包中:PackageTest 类属于无名包;Employee 类属于com.horstmann.corejava 包 。因此,Employee.java文件必须在子目录 com/horstmann/corejava 中。换句话说,目录结构如下所示:
1 | |
要想编译这个程序,只需切换到基目录,并运行以下命令
1 | |
编译器就会自动地查找文件 com/horstmann/corejava/Employee.java 并进行编译。
下面看一个更加实际的例子。在这里没有使用无名包,而是将类分别放在不同的包中(com.horstmann.corejava 和com.mycompany)。
1 | |
在这种情况下,仍然要从基目录编译和运行类,即包含 com 目录的目录:
1 | |
再次强调,编译器处理文件(带有文件分隔符和扩展名.java 的文件),而 Java 解释器加载类(带有.分隔符)。
提示: 从下一章开始,我们将对源代码使用包。这样一来,就可以为各章建立一个IDE项目,而不是各小节分别建立项目。
警告: 编译器在编译源文件的时候不检查目录结构。例如,假设一个源文件开头有以下指令:
1 | |
即使这个源文件不在子目录 com/mycompany下,这个文件也可以编译。如果它不依赖于其他包,就可以通过编译而不会出现编译错误。但是,最终的程序将无法运行,除非先将所有类文件移到正确的位置上。如果包与目录不匹配,虚拟机就找不到这些类。
4.8.5 包访问
前面已经见过访问修饰符 public 和 private。标记为 public 的部分可以由任意类使用;标记为 private 的部分只能由定义它们的类使用。如果没有指定 public 或 private,这个部分(类、方法或变量)可以由同一个包中的所有方法访问。
下面再来考虑程序清单 4-2。在这个程序中,没有将 Employee 类定义为公共类,因此只有在同一个包(在此是无名包)中的其他类(例如 EmployeeTest)可以访问这个类。对于类来说,这种默认方式是合乎情理的。但是,对于变量来说就有些不适宜了,变量必须显式地标记为 private,不然的话将默认为包可访问。显然,这样会破坏封装性。问题是人们经常忘记键入关键字 private。以 java.awt 包中的 Window 类为例(java.awt 包是 JDK 提供的源代码的一部分):
1 | |
请注意,这里的 warningString 变量不是 private!这意味着 java.awt 包中的所有类的方法都可以访问该变量,并将它设置为任意值(例如,”Trust me!”)。实际上,只有 Window 类的方法访问这个变量,因此本应该将它设置为私有变量才合适。可能是程序员敲代码时匆忙之中忘记 private 修饰符了?也可能没有人关心这个问题?已经 20 多年了,这个变量仍然不是私有变量。不仅如此,这个类还陆续增加了一些新的字段,而其中大约有一半也不是私有的。
这可能会成为一个问题。在默认情况下,包不是封闭的实体。也就是说,任何人都可以向包中添加更多的类。当然,有恶意或糟糕的程序员很可能利用包访问添加一些能够改变变量的代码。例如,在 Java 程序设计语言的早期版本中,只需要将以下这条语句放在类文件的开头,就可以很容易地在 java.awt 包中混入其他类:
1 | |
然后,把得到的类文件放置在类路径上某处的 java/awt 子目录下,这样就可以访问 java.awt 包的内部了。使用这一手段,完全可以修改警告字符串(如图 4-9 所示)。
从 1.2 版开始,JDK 的实现者修改了类加载器,明确地禁止加载包名以 “java.” 开头的用户自定义的类!当然,用户自定义的类无法从这种保护中受益。另一种机制是让 JAR 文件声明包为密封的(sealed),以防止第三方修改,但这种机制已经过时。现在应当使用模块封装包。我们会在卷 II 的第 9 章详细讨论模块。

图 4-9 在一个 applet 窗口中修改警告字符串
4.8.6 类路径
在前面已经看到,类存储在文件系统的子目录中。类的路径必须与包名匹配。
另外,类文件也可以存储在 JAR(Java 归档)文件中。在一个 JAR 文件中,可以包含多个压缩格式的类文件和子目录,这样既可以节省空间又可以改善性能。在程序中用到第三方的库时,你通常会得到一个或多个需要包含的 JAR 文件。
提示: JAR 文件使用 ZIP 格式组织文件和子目录。可以使用任何 ZIP 工具查看 JAR 文件。
为了使类能够被多个程序共享,需要做到下面几点:
- 把类文件放到一个目录中,例如 /home/user/classdir。需要注意,这个目录是包树状结构的基目录。如果希望增加 com.horstmann.corejava.Employee 类,那么 Employee.class 类文件就必须位于子目录 /home/user/classdir/com/horstmann/corejava 中。
- 将 JAR 文件放在一个目录中,例如 /home/user/archives。
- 设置类路径(class path)。类路径是所有包含类文件的路径的集合。
在 UNIX 环境中,类路径中的各项之间用冒号(:)分隔:
1 | |
而在 Windows 环境中,则以分号(;)分隔:
1 | |
不论是 UNIX 还是 Windows,都用句点(.)表示当前目录。
类路径包括:
- 基目录 /home/user/classdir 或 c:\classdir;
- 当前目录(.);
- JAR 文件 /home/user/archives/archive.jar 或 c:\archives\archive.jar。
从 Java 6 开始,可以为 JAR 文件目录指定一个通配符,如下:
1 | |
或者
1 | |
在 UNIX 中,* 必须转义以防止 shell 扩展。
archives 目录中的所有 JAR 文件(但不包括 .class 文件)都包含在这个类路径中。
由于总是会搜索 Java API 的类,所以不必显式地包含在类路径中。
警告: javac 编译器总是在当前目录中查找文件,但只有当类路径中包含 “ . “ 目录时,java 虚拟机才会查看当前目录。如果你没有设置类路径,那么没有什么问题,因为默认的类路径会包含 “ . “ 目录。但是如果你设置了类路径却忘记包含 “ . “ 目录,那么尽管你的程序可以没有错误地通过编译,但不能运行。
类路径所列出的目录和归档文件是搜寻类的起始点。下面看一个类路径示例:
1 | |
假定虚拟机要搜寻 com.horstmann.corejava.Employee 类的类文件。它首先要查看 Java API 类。显然,在那里找不到相应的类文件,所以转而查看类路径。它会查找以下文件:
- /home/user/classdir/com/horstmann/corejava/Employee.class
- com/horstmann/corejava/Employee.class(从当前目录开始)
- com/horstmann/corejava/Employee.class(/home/user/archives/archive.jar 中)
编译器查找文件要比虚拟机复杂得多。如果引用了一个类,而没有指定这个类的包,那么编译器将首先查找包含这个类的包。它会查看所有的 import 指令,确定其中是否包含这个类。例如,假定源文件包含指令:
1 | |
并且源代码引用了 Employee 类。编译器将尝试查找 java.lang.Employee(因为总是会默认导入 java.lang 包)、java.util.Employee、com.horstmann.corejava.Employee 和当前包中的 Employee。它会在类路径所有位置中搜索以上各个类。如果找到了一个以上的类,就会产生编译时错误(因为完全限定类名必须是唯一的,所以 import 语句的次序并不重要)。
编译器的任务不止这些,它还要查看源文件是否比类文件新。如果是这样的话,那么源文件就会自动地重新编译。在前面已经知道,只可以导入其他包中的公共类。一个源文件只能包含一个公共类,并且文件名与公共类名必须匹配。因此,编译器很容易找到公共类的源文件。不过,还可以从当前包中导入非公共类。这些类有可能在与类名不同的源文件中定义。如果从当前包中导入一个类,那么编译器就要搜索当前包中的所有源文件,查看哪个源文件定义了这个类。
4.8.7 设置类路径
最好使用 -classpath(或 -cp,或者 Java 9 中的 –class-path)选项指定类路径:
1 | |
或者
1 | |
整个指令必须写在一行中。将这样一个很长的命令行放在一个 shell 脚本或一个批处理文件中是个不错的主意。
利用 -classpath 选项设置类路径是首选的方法,另一种方法是通过设置 CLASSPATH 环境变量来指定类路径。具体细节依赖于所使用的 shell。在 Bourne Again shell(bash)中,命令如下:
1 | |
在 Windows shell 中,命令如下:
1 | |
直到退出 shell 为止,类路径设置均有效。
警告: 有人建议永久地设置 CLASSPATH 环境变量。一般来说这是一个糟糕的想法。人们有可能会忘记全局设置,因此,当他们的类没有正确地加载时,就会感到很奇怪。一个颇受诟病的示例是 Windows 中 Apple QuickTime 安装程序。很多年来,它都将 CLASSPATH 全局设置为指向它需要的一个 JAR 文件,而没有在类路径中包含当前目录。因此,当程序编译后却不能运行时,无数 Java 程序员不得不花费很多精力去解决这个问题。
警告: 过去,有人建议完全绕过类路径,将所有的 JAR 文件都放在 jre/lib/ext 目录中。这种机制在 Java 9 中已经过时,不过不管怎样这都是一个不好的建议。从扩展目录加载一些已经遗忘很久的类时,这会让人非常困惑。
注释: 在 Java 9 中,还可以从模块路径加载类。本书卷Ⅱ的第 9 章将讨论模块和模块路径。
4.9 JAR 文件
在将应用程序打包时,你希望只向用户提供一个单独的文件,而不是一个包含大量类文件的目录结构,Java 归档(JAR)文件就是为此目的而设计的。JAR 文件既可以包含类文件,也可以包含诸如图像和声音等其他类型的文件。此外,JAR 文件是压缩的,它使用了我们熟悉的 ZIP 压缩格式。
4.9.1 创建 JAR 文件
可以使用 jar 工具制作 JAR 文件(在默认的 JDK 安装中,这个工具位于 jdk/bin 目录下)。创建一个新 JAR 文件最常用的命令使用以下语法:
1 | |
例如:
1 | |
通常,jar 命令的格式如下:
1 | |
表 4-2 列出了 jar 程序的所有选项。它们类似于 UNIX tar 命令的选项。
表 4-2 jar 程序选项
| 选项 | 说明 |
|---|---|
| c | 创建一个新的或者空的存档文件并加入文件。如果指定的文件名是目录,jar 程序将会对它们进行递归处理 |
| C | 临时改变目录,例如:jar cvf jarFileName.jar -C classes *.class切换到 classes 子目录以便增加类文件 |
| e | 在清单文件中创建一个入口点(请参见 4.9.3 节) |
| f | 指定 JAR 文件名作为第二个命令行参数。如果没有这个参数,jar 命令会将结果写至标准输出(在创建 JAR 文件时)或者从标准输入读取输入(在解压或者列出 JAR 文件内容时) |
| i | 创建索引文件(用于加快大型归档中的查找) |
| m | 将一个清单文件添加到 JAR 文件中。清单文件是对归档内容和来源的一个说明。每个归档有一个默认的清单文件。但是,如果想验证归档文件的内容,可以提供你自己的清单文件 |
| M | 不为条目创建清单文件 |
| t | 显示内容表 |
| u | 更新一个已有的 JAR 文件 |
| v | 生成详细的输出 |
| x | 解压文件。如果提供一个或多个文件名,只解压这些文件;否则,解压所有文件 |
| 0 | 存储,但不进行 ZIP 压缩 |
可以将应用程序和代码库打包在 JAR 文件中。例如,如果想在一个 Java 程序中发送邮件,可以使用打包在文件 javax.mail.jar 中的一个库。
4.9.2 清单文件
除了类文件、图像和其他资源外,每个 JAR 文件还包含一个清单文件(manifest),用于描述归档文件的特殊特性。
清单文件被命名为 MANIFEST.MF,它位于 JAR 文件的一个特殊的 META-INF 子目录中。合法的最小清单文件极其简单:
1 | |
复杂的清单文件可能包含更多条目。这些清单条目被分组为多个节。第一节被称为主节(main section)。它作用于整个 JAR 文件。随后的条目可以指定命名实体的属性,如单个文件、包或者 URL。它们都必须以一个 Name 条目开始。节与节之间用空行分开。例如:
1 | |
要想编辑清单文件,需要将希望添加到清单文件中的行放到文本文件中,然后运行
1 | |
例如,要创建一个包含清单文件的 JAR 文件,应该运行
1 | |
要想更新一个已有的 JAR 文件的清单,则需要将增加的部分放置到一个文本文件中,然后执行以下命令:
1 | |
注释: 请参见 https://docs.oracle.com/javase/10/docs/specs/jar/jar.html 获得有关 JAR 文件和清单文件格式的更多信息。
4.9.3 可执行 JAR 文件
可以使用 jar 命令中的 e 选项指定程序的入口点,即通常调用 java 执行程序时指定的类:
1 | |
或者,可以在清单文件中指定程序的主类,包括以下形式的语句:
1 | |
不要为主类名加扩展名 .class。
警告: 清单文件的最后一行必须以换行符结束。否则,将无法正确地读取清单文件。常见的一个错误是创建了一个只包含 Main-Class 行而没有行结束符的文本文件。
不论使用哪一种方法,用户都可以简单地通过下面的命令来启动程序:
1 | |
取决于操作系统的配置,用户甚至可以通过双击 JAR 文件图标来启动应用程序。下面是各种操作系统的操作方式:
- 在 Windows 平台中,Java 运行时安装程序将为 “.jar” 扩展名创建一个文件夹联,会用 javaw -jar 命令启动文件(与 java 命令不同,javaw 命令不打开 shell 窗口)。
- 在 Mac OS X 平台中,操作系统能够识别 “.jar” 扩展名文件。双击 JAR 文件时就会执行 Java 程序。
在 Windows 平台中,可以使用第三方的包装器工具将 JAR 文件转换成 Windows 可执行文件。包装器是一个 Windows 程序,有大家熟悉的扩展名.exe,它可以查找和加载 Java 虚拟机(JVM),或者在没有找到 JVM 时会告诉用户应该做些什么。
4.9.4 多版本 JAR 文件
随着模块和包强封装的引入,之前可以访问的一些内部 API 不再可用。这可能要求库提供商为不同 Java 版本发布不同的代码。为此,Java 9 引入了多版本 JAR(multi-release JAR)。为了保证向后兼容,特定于版本的类文件放在 META-INF/versions 目录中:
1 | |
假设 Application 类使用了 CssParser 类,那么遗留版本的 Application.class 文件可以使用 com.sun.javafx.css.CssParser,而 Java 9 版本可以使用 javafx.css.CssParser。
Java 8 完全不知道 META-INF/versions 目录,它只会加载遗留的类。Java 9 读取这个 JAR 文件时,则会使用新版本。
要增加不同版本的类文件,可以使用 –release 标志:
1 | |
要从头构建一个多版本 JAR 文件,可以使用 -C 选项,对应每个版本要切换到一个不同的类文件目录:
1 | |
面向不同版本编译时,要使用 –release 标志和 -d 标志来指定输出目录:
1 | |
在 Java 9 中,-d 选项会创建这个目录(如果原先该目录不存在)。
–release 标志也是 Java 9 新增的。在较早的版本中,需要使用 -source、-target 和 -bootclasspath 标志。JDK 现在为之前的两个 API 版本提供了符号文件。在 Java 9 中,编译时可以将 –release 设置为 9、8 或 7。
多版本 JAR 并不适用于不同版本的程序或库。对于不同的版本,所有类的公共 API 都应当是一样的。多版本 JAR 的唯一作用是使你的某个特定版本的程序或库能够使用多个不同的 JDK 版本。如果你增加了功能或者改变了一个 API,就应当提供一个新版本的 JAR。
注释: javap 之类的工具并没有改造为可以处理多版本 JAR 文件。如果调用
1 | |
你会得到类的基本版本(毕竟,它与更新的版本应该有相同的公共 API)。如果必须查看更新的版本,则可以调用:
1 | |
4.9.5 关于命令行选项的说明
Java 开发包(JDK)的命令行选项一直以来都使用单个短横线加多字母选项名的形式,如:
1 | |
但 jar 命令是个例外,这个命令遵循经典的 tar 命令选项格式,而没有短横线:
1 | |
从 Java 9 开始,Java 工具开始转向一种更常用的选项格式,多字母选项名前面加两个短横线,另外对于常用的选项可以使用单字母快捷方式。例如,调用 Linux ls 命令时可以提供一个”human-readable”选项:
1 | |
或者
1 | |
在 Java 9 中,可以使用 --version 而不是 -version,另外可以使用 --class-path 而不是 -classpath。在本书卷Ⅱ的第 9 章中可以看到,--module-path 选项有一个快捷方式 -p。
详细内容可以参见 JEP 293 增强请求(http://openjdk.java.net/jeps/293)。在所有清理工作中,作者还提出要标准化选项参数。带 -- 和多字母选项的参数用空格或者一个等号 (=) 分隔:
1 | |
或
1 | |
单字母选项的参数可以用空格分隔,或者直接跟在选项后面:
1 | |
或
1 | |
警告: 后一种方式现在不能使用,而且一般来讲这也不是一个好主意。如果模块目录恰好是 arameters 或 rocessor,这就很容易与遗留的选项(parameters 或 processor)发生冲突,这又何必呢?
无参数的单字母选项可以组合在一起:
1 | |
警告: 目前不能使用这种方式,这肯定会带来混淆。假设 javac 有一个 -c 选项,那么 javac -cp 是指 javac -c -p 还是 -cp?
这就会带来一些混乱,希望过段时间能够解决这个问题。尽管我们想要远离这些古老的 jar 选项,但最好还是等到尘埃落定为妙。不过,如果你想做到最现代化,那么可以安全地使用 jar 命令的长选项:
1 | |
对于单字母选项,如果不组合,也是可以使用的:
1 | |
4.10 文档注释
JDK 包含一个很有用的工具,叫作 javadoc,它可以由源文件生成一个 HTML 文档。事实上,在第 3 章介绍的联机 API 文档就是通过对标准 Java 类库的源代码运行 javadoc 生成的。
如果在源代码中添加以特殊定界符 /** 开始的注释,那么你也可以很容易地生成一个看上去具有专业水准的文档。这是一种很好的方法,因为这样可以将代码与注释放在一个地方。应该知道,如果将文档存放在一个单独的文件中,随着时间的推移,代码和注释很可能出现不一致。不过,如果文档注释与源代码在同一个文件中,就可以很容易地同时修改源代码和注释,然后重新运行 javadoc。
4.10.1 注释的插入
javadoc 实用工具从下面几项中抽取信息:
- 模块;
- 包;
- 公共类与接口;
- 公共的和受保护的字段;
- 公共的和受保护的构造器及方法。
注释以 /** 开始,并以 */ 结束。
每个 /** . . . */ 文档注释包含标记以及之后紧跟着的自由格式文本(free-form text)。标记以 @ 开始,如 @since 或 @param。
自由格式文本的第一个句子应该是一个概要陈述。javadoc 工具自动地将这些句子抽取出来生成概要页。
在自由格式文本中,可以使用 HTML 修饰符,例如,用于强调的 <em>...</em>、用于着重强调的 <strong>...</strong>、用于项目符号列表的 <ul>/<li> 以及用于包含图像的 <img .../> 等。要键入等宽代码,需要使用 {@code ... } 而不是 <code>...</code>——这样一来,就不用操心对代码中的 < 字符转义了。
注释: 如果文档中有到其他文件的链接,如图像文件(例如,图表或用户界面组件的图像),就应该将这些文件放到包含源文件目录下的一个子目录 doc-files 中。javadoc 工具将从源目录将 doc-files 目录及其内容复制到文档目录中。在链接中需要使用 doc-files 目录,例如 <img src="doc-files/uml.png" alt="UML diagram"/>。
4.10.2 类注释
类注释必须放在 import 语句之后,class 定义之前。
下面是一个类注释的例子:
1 | |
注释: 没有必要在每一行的开始都添加 *,例如,以下注释同样是合法的:
1 | |
不过,大部分 IDE 会自动提供星号,而且换行改变时,还会重新放置星号。
4.10.3 方法注释
每个方法注释必须放在所描述的方法之前。除了通用标记之外,还可以使用下面的标记:
@param variable description
这个标记将给当前方法的”parameters”(参数)部分添加一个条目。这个描述可以占据多行,并且可以使用 HTML 标记。一个方法的所有 @param 标记必须放在一起。@return description
这个标记将给当前方法添加”returns”(返回)部分。这个描述可以跨多行,并且可以使用 HTML 标记。
@throws class description
这个标记将添加一个注释,表示这个方法有可能抛出异常。有关异常的详细内容将在第7章中讨论。
下面是一个方法注释的示例:
1 | |
4.10.4 字段注释
只需要对公共字段(通常指的是静态常量)增加文档注释。例如,
1 | |
4.10.5 通用注释
标记 @since text 会建立一个”since”(始于)条目。text(文本)可以是对引入这个特性的版本的描述。例如,@since 1.7.1。
类文档注释中可以使用下面的标记:
@author name
这个标记将建立一个”author”(作者)条目。可以有多个@author标记,每个@author标记对应一个作者。并不是非得使用这个标记,你的版本控制系统能够更好地跟踪作者。@version text
这个标记将建立一个”version”(版本)条目。这里的text可以是对当前版本的任何描述。
通过@see和@link标记,可以使用超链接,链接到javadoc文档的相关部分或外部文档。
标记@see reference将在”see also”(参见)部分增加一个超链接。它可以用于类中,也可以用于方法中。这里的reference(引用)可以有以下选择:
1 | |
第一种情况是最有用的。只要提供类、方法或变量的名字,javadoc就在文档中插入一个超链接。例如,
1 | |
会建立一个超链接,链接到 com.horstmann.corejava.Employee 类的 raiseSalary(double) 方法。可以省略包名,甚至把包名和类名都省去,这样一来,这会位于当前包或当前类。
需要注意,一定要使用井号(#),而不要使用句号(.)分隔类名与方法名(或类名与变量名)。Java 编译器自身可以熟练地确定句点在分隔包、子包、类、内部类以及方法和变量时的不同含义。但是 javadoc 工具就没有这么聪明了,因此必须对它提供帮助。
如果 @see 标记后面有一个 < 字符,就需要指定一个超链接。可以超链接到任何 URL。例如:
1 | |
在上述各种情况下,都可以指定一个可选的标签(label),这会显示为链接锚(link anchor)。如果省略了标签,则用户看到的锚就是目标代码名或 URL。
如果 @see 标记后面有一个双引号(”)字符,文本就会显示在”see also”部分。例如,
1 | |
可以为一个特性添加多个 @see 标记,但必须将它们放在一起。
如果愿意,可以在任何文档注释中放置指向其他类或方法的超链接。可以在注释中的任何位置插入一个形式如下的特殊标记:
1 | |
这里的特性描述规则与 @see 标记的规则相同。
最后,在 Java 9 中,还可以使用 {@index entry} 标记为搜索框增加一个条目。
4.10.6 包注释
可以直接将类、方法和变量的注释放置在 Java 源文件中,只要用 /**…*/ 文档注释界定就可以了。但是,要想产生包注释,就需要在每一个包目录中添加一个单独的文件。可以有如下两个选择:
提供一个名为 package-info.java 的 Java 文件。这个文件必须包含一个初始的 Javadoc 注释,以 /** 和 */ 界定,后面是一个 package 语句。它不能包含更多的代码或注释。
提供一个名为 package.html 的 HTML 文件,抽取标记
<body>...</body>之间的所有文本。
4.10.7 注释提取
在这里,假设你希望 HTML 文件将放在名为 docDirectory 的目录下。执行以下步骤:
切换到源文件目录,其中包含想要生成文档的源文件。如果有嵌套的包要生成文档,例如 com.horstmann.corejava,就必须切换到包含子目录 com 的目录(如果提供 overview.html 文件的话,这就是这个文件所在的目录)。
如果是一个包,应该运行命令:
1 | |
或者,如果要为多个包生成文档,运行:
1 | |
如果你的文件在无名包中,则应该运行:
1 | |
如果省略了 -d docDirectory 选项,HTML 文件就会提取到当前目录下。这样可能很混乱,因此我不提倡这种做法。
可以使用很多命令行选项对 javadoc 程序进行微调。例如,可以使用 -author 和 -version 选项在文档中包含 @author 和 @version 标记(默认情况下,这些标记会被省略)。另一个很有用的选项是 -link,用来为标准类添加超链接。例如,如果使用命令
1 | |
那么,所有的标准类的类都会自动地链接到 Oracle 网站的文档。
如果使用 -linksource 选项,那么每个源文件将会转换为 HTML(不对代码着色,但包含符号),并且每个类和方法名称变为指向源代码的超链接。
还可以为所有源文件提供一个概要注释。把它放在一个类似 overview.html 的文件中,运行 javadoc 工具,并提供命令行选项 -overview filename。将抽取标记 <body>...</body> 之间的所有文本。当用户从导航栏中选择”Overview”时,就会显示这些内容。
有关其他的选项,请查阅 javadoc 工具的联机文档 https://docs.oracle.com/javase/9/javadoc/javadoc.htm。
4.11 类设计技巧
我们不会面面俱到,也不希望过于沉闷,所以在这一章结束之前再简单地介绍几点技巧。应用这些技巧可以使你设计的类更能得到专业 OOP 圈子的认可。
一定要保证数据私有。
这是最重要的;绝对不要破坏封装性。有时候,可能需要编写一个访问方法或更改器方法,但是最好还是保持实例字段的私有性。很多惨痛的教训告诉我们,数据的表示形式很可能会改变,但它们的使用方式却不会经常变化。当数据保持私有时,表示形式的变化不会对类的使用者产生影响,而且也更容易检测 bug。一定要初始化数据。
Java 不会为你初始化局部变量,但是会对对象的实例字段进行初始化。最好不要依赖于系统的默认值,而是应该显式地初始化所有变量,可以提供默认值,也可以在所有构造器中设置默认值。不要在类中使用过多的基本类型。
其想法是要用其他的类,而不是使用多个相关的基本类型。这样会使类更易于理解,也更易于修改。例如,可以用一个名为 Address 的新类替换一个 Customer 类中的以下实例字段:
1 | |
这样一来,可以很容易地处理地址的变化,例如,可能需要处理国际地址。
不是所有的字段都需要单独的字段访问器和更改器。
你可能需要获得或设置员工的工资。而一旦构造了员工对象,肯定不需要更改雇用日期。另外,在对象中,常常包含一些不希望别人获得或设置的实例字段,例如,Address类中的州缩写数组。分解有过多职责的类。
这样说似乎有点含糊,究竟多少算是”过多”?每个人的看法都不同。但是,如果明显地可以将一个复杂的类分解成两个概念上更为简单的类,就应该进行分解。(但另一方面,也不要走极端。如果设计10个类,每个类只有一个方法,显然就有些矫枉过正了。)下面是一个反面的设计示例。
1 | |
实际上,这个类实现了两个独立的概念:一副牌(包含shuffle方法和draw方法)和一张牌(包含查看面值和花色的方法)。最好引入一个表示一张牌的Card类。现在有两个类,每个类分别完成自己的职责:
1 | |
类名和方法名要能够体现它们的职责。
变量应该有一个能够反映其含义的名字,类似地,类也应该如此(在标准类库中,确实存在着一些含义不明确的例子,如 Date 类实际上是一个描述时间的类)。对此有一个很好的惯例:类名应当是一个名词(Order),或者是前面有形容词修饰的名词(RushOrder),或者是有动名词(有”-ing”后缀)修饰的名词(例如,BillingAddress)。对于方法来说,要遵循标准惯例:访问器方法用小写 get 开头(getSalary),更改器方法用小写的 set 开头(setSalary)。
优先使用不可变的类。
LocalDate 类以及 java.time 包中的其他类是不可变的——没有方法能修改对象的状态。类似 plusDays 的方法并不会更改对象,而是会返回状态已修改的新对象。更改对象的问题在于,如果多个线程试图同时更新一个对象,就会发生并发更改,其结果是不可预料的。如果类是不可变的,就可以安全地在多个线程间共享其对象。
因此,要尽可能让类是不可变的,这是一个很好的想法。对于表示值的类,如一个字符串或一个时间点,这尤其容易。计算会生成新值,而不是更新原来的值。
当然,并不是所有类都应当是不可变的。如果员工加薪时让 raiseSalary 方法返回一个新的 Employee 对象,这会很奇怪。