对 象 与 类
4.1 面向对象程序设计概述
面向对象程序设计(简称 OOP) 是当今主流的程序设计范型, 它已经取代了 20 世纪 70 年代的“ 结构化” 过程化程序设计开发技术。Java 是完全面向对象的, 必须熟悉 OOP 才能 够编写 Java 程序。
面向对象的程序是由对象组成的, 每个对象包含对用户公开的特定功能部分和隐藏的实现部分。程序中的很多对象来自标准库,还有一些是自定义的。究竟是自己构造对象, 还是从外界购买对象完全取决于开发项目的预算和时间。但是, 从根本上说, 只要对象能够满足要求,就不必关心其功能的具体实现过程。在 OOP 中, 不必关心对象的具体实现,只要能够满足用户的需求即可。
传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题。一旦确定了这些过程, 就要开始考虑存储数据的方式。这就是 Pascal 语言的设计者 Niklaus Wirth 将其著作命名为《算法 + 数据结构 = 程序 (Algorithms + Data Structures = Programs, Prentice Hall, 1975 ) 的原因。需要注意的是,在 Wirth 命名的书名中, 算法是第一位的,数据结构是第二位的,这就明确地表述了程序员的工作方式。 首先要确定如何操作数据, 然后再决定如何组织数据, 以便于数据操作。 而 OOP 却调换了这个次序, 将数据放在第一位,然后再考虑操作数据的算法。
对于一些规模较小的问题, 将其分解为过程的开发方式比较理想。而面向对象更加适用于解决规模较大的问题。耍想实现一个简单的 Web 浏览器可能需要大约 2000 个过程,这些过程可能需要对一组全局数据进行操作。采用面向对象的设计风格, 可能只需要大约 100 个 类,每个类平均包含 20 个方法(如图 4-1所示。) 后者更易于程序员掌握, 也容易找到 bug 假设给定对象的数据出错了,在访问过这个数据项的 20 个方法中查找错误要比在 2000 个过 程中查找容易得多。
4.1.1 类
类( class) 是构造对象的模板或蓝图。我们可以将类想象成制作小甜饼的切割机,将对象想象为小甜饼。由类构造(construct) 对象的过程称为创建类的实例 (instance )。
正如前面所看到的, 用 Java 编写的所有代码都位于某个类的内部。标准的 Java 库提供 了几千个类,可以用于用户界面设计、日期、 日历和网络程序设计。尽管如此,还是需要在 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)
•送货地址(Shippingaddress)
•付 款 ( Payment )
•账户(Account)
这些名词很可能成为类 Item、 Order 等。
接下来, 查看动词:商品被添加到订单中, 订单被发送或取消, 订单货款被支付。对于每一个动词如:“ 添加”、“ 发送”、“ 取消” 以及“ 支付”, 都要标识出主要负责完成相应动作 的对象。例如,当一个新的商品添加到订单中时, 那个订单对象就是被指定的对象, 因为它 知道如何存储商品以及如何对商品进行排序。也就是说,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 )。用软件工程的术语来说,就是 让类之间的耦合度最小。
聚合(aggregation ), 即“ has-a ” 关系, 是一种具体且易于理解的关系。例如, 一个 Order 对象包含一些 Item 对象。聚合关系意味着类 A 的对象包含类 B 的对象。
注释: 有些方法学家不喜欢聚合这个概念,而更加喜欢使用“ 关联” 这个术语。从建模 的角度看, 这是可以理解的。但对于程序员来说,“ has-a” 显得更加形象。喜欢使用聚合的另一个理由是关联的标准符号不易区分, 请参看表 4-1。
继承( inheritance ), 即“ is-a” 关系, 是一种用于表示特殊与一般关系的。例如,Rush Order类由 Order 类继承而来。在具有特殊性的 RushOrder 类中包含了一些用于优先处理的特殊方法, 以及一个计算运费的不同方法;而其他的方法, 如添加商品、 生成账单等都是从 Order 类继承来的。一般而言, 如果类 A 扩展类 B, 类 A 不但包含从类 B 继承的方法,还会 拥有一些额外的功能(下一章将详细讨论继承,其中会用较多的篇幅讲述这个重要的概念)。
很多程序员采用 UML ( Unified Modeling Language , 统一建模语言)绘制类图,用来描述类之间的关系。图 4-2 就是这样一个例子。类用矩形表示,类之间的关系用带有各种修饰 的箭头表示。表 4-1 给出了 UML 中最常见的箭头样式。
4.2 使用预定义类
在 Java 中, 没有类就无法做任何事情, 我们前面曾经接触过几个类。然而,并不是所有的类都具有面向对象特征。例如,Math 类。在程序中,可以使用 Math 类的方法, 如 Math, random, 并只需要知道方法名和参数(如果有的话,) 而不必了解它的具体实现过程。这正是 封装的关键所在,当然所有类都是这样。但遗憾的是,Math 类只封装了功能,它不需要也不必隐藏数据。由于没有数据,因此也不必担心生成对象以及初始化实例域。
下一节将会给出一个更典型的类—Date 类,从中可以看到如何构造对象, 以及如何调 用类的方法。
4.2.1 对象与对象变量
要想使用对象,就必须首先构造对象, 并指定其初始状态。然后,对对象应用方法。
在 Java 程序设计语言中, 使用构造器(constructor ) 构造新实例。构造器是一种特殊的方法, 用来构造并初始化对象。下面看一个例子。 在标准 Java 库中包含一个 Date 类。它的 对象将描述一个时间点, 例如:“ December 31, 1999, 23:59:59 GMT”。
注释: 你可能会感到奇怪: 为什么用类描述时间, 而不像其他语言那样用一个内置 的 ( built-in) 类型? 例如, 在 Visual Basic 中有一个内置的 date 类型, 程序员可以采用 #6/1/1995# 格式指定日期。从表面上看, 这似乎很方便, 因为程序员使用内置的 date 类型, 而不必为设计类而操心。但实际上, Visual Basic 这样设计的适应性如何呢? 在有些地区日期表示为月 / 日 / 年, 而另一些地区表示为日 / 月 / 年。语言设计者是否能够预见这些问题 呢? 如果没有处理好这类问题,语言就有可能陷入混乱, 对此感到不满的程序员也会丧失使用这种语言的热情。如果使用类, 这些设计任务就交给了类库的设计者。如果类设计的不完善, 其他的操作员可以很容易地编写自己的类, 以便增强或替代( replace) 系统提供的类(作为这个问题的印证:Java 的日期类库有些混乱, 已经重新设计了两次)。
构造器的名字应该与类名相同。因此 Date 类的构造器名为 Date。要想构造一个 Date 对 象, 需要在构造器前面加上 new 操作符,如下所示:
new Date()
这个表达式构造了一个新对象。这个对象被初始化为当前的日期和时间。
这个表达式构造了一个新对象。这个对象被初始化为当前的日期和时间。 如果需要的话, 也可以将这个对象传递给一个方法:
System.out.printTn(new Date());
或者, 也可以将一个方法应用于刚刚创建的对象。Date 类中有一个 toString 方法。这 个方法将返回日期的字符串描述。下面的语句可以说明如何将 toString 方法应用于新构造的 Date 对象上。
String s = new Date().toString();
在这两个例子中, 构造的对象仅使用了一次。通常, 希望构造的对象可以多次使用, 因 此,需要将对象存放在一个变量中:
Date birthday = new Date();
图 4-3显示了引用新构造的对象变量 birthday。
在对象与对象变量之间存在着一个重要的区别。例如, 语句
Date deadline; // deadline doesn't refer to any object
定义了一个对象变量 deadline, 它 可 以 引 用 Date 类型的对象。但是,一定要认识到: 变量 deadline 不是一个对象, 实际上也没有引用对象。此时,不能将任何 Date 方法应用于这个变量上。语句
s = deadline.toStringO; // not yet
将产生编译错误。 必须首先初始化变量 deadline, 这里有两个选择。当然,可以用新构造的对象初始化这 个变量:
deadline = new Date();
也让这个变量引用一个已存在的对象:
deadline = birthday;
现在,这两个变量引用同一个对象(请参见图 4-4 )。
一定要认识到: 一个对象变量并没有实际包含一个对象,而仅仅引用一个对象。
在 Java 中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用。new 操作符的返回值也是一个引用。下列语句:
Date deadline = new Date();
有两个部分。表达式 new Date() 构造了一个 Date 类型的对象, 并且它的值是对新创建对象的 引用。这个引用存储在变量 deadline 中。
可以显式地将对象变量设置为 null, 表明这个对象变量目前没有引用任何对象。
deadline = null;
...
if (deadline != null)
System.out.println(deadline);
如果将一个方法应用于一个值为 null 的对象上,那么就会产生运行时错误。
birthday = null;
String s = birthday.toString(); // runtime error!
局部变量不会自动地初始化为 null,而必须通过调用 new 或将它们设置为 null 进行初始化。
C++ 注释:很多人错误地认为 Java 对象变量与 C++ 的引用类似。然而,在 C++ 中没有 空引用, 并且引用不能被赋值。可以将 Java 的对象变量看作 C++ 的对象指针。
例如,
Date birthday; // Java
实际上,等同于
Date* birthday; // C++
一旦理解了这一点, 一切问题就迎刃而解了。 当然,一个 Date* 指针只能通过调用 new 进行初始化。就这一点而言,C++与 Java 的语法几乎是一样的
Date* birthday = new Date(); // C++
如果把一个变量的值賦给另一个变量, 两个变量就指向同一个日期,即它们是同一 个对象的指针。 在 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 类所提供的日期处理并没有太大的用途。Java 类库的设计者认为: 像“December 31, 1999, 23:59:59" 这样的日期表示法只是阳历的固有习惯。这种特定的描述法 遵循了世界上大多数地区使用的 Gregorian 阳历表示法。但是, 同一时间点采用中国的农历 表示和采用希伯来的阴历表示就很不一样,对于火星历来说就更不可想象了。
注释: 有史以来,人类的文明与历法的设计紧紧地相连, 日历给日期命名、 给太阳和月亮的周期排列次序。有关世界上各种日历的有趣解释, 从法国革命的日历到玛雅人计算 曰期的方法等, 请参看 Nachum Dershowitz 和 Edward M. Reingold 编写的《 Calendrical Calculations》第 3 版(剑桥大学出版社,2007 年)。
类库设计者决定将保存时间与给时间点命名分开。所以标准 Java 类库分别包含了两个类: 一个是用来表示时间点的 Date 类;另一个是用来表示大家熟悉的日历表示法的 LocalDate 类。 Java SE 8引入了另外一些类来处理日期和时间的不同方面一有关内容参见卷 II 第 6 章。
将时间与日历分开是一种很好的面向对象设计。通常,最好使用不同的类表示不同的概念。
不要使用构造器来构造 LocalDate 类的对象。实际上,应当使用静态工厂方法 (factory method) 代表你调用构造器。下面的表达式
Local Date.now()
会构造一个新对象,表示构造这个对象时的日期。
可以提供年、 月和日来构造对应一个特定日期的对象:
LocalDate.of(1999, 12, 31)
当然, 通常都希望将构造的对象保存在一个对象变量中:
LocalDate newYearsEve = Local Date.of(1999, 12, 31);
一旦有 了一个 LocalDate 对象, 可以用方法 getYear、 getMonthValue 和 getDayOfMonth 得到年、月和日:
int year = newYearsEve.getYearO; // 1999
int month = newYearsEve.getMonthValueO; // 12
int day = newYearsEve.getDayOfMonth(); // 31
看起来这似乎没有多大的意义, 因为这正是构造对象时使用的那些值。不过,有时可能 某个日期是计算得到的,你希望调用这些方法来得到更多信息。例如, plusDays 方法会得到 一个新的 LocalDate, 如果把应用这个方法的对象称为当前对象,这个新日期对象则是距当 前对象指定天数的一个新日期:
LocalDate aThousandDaysLater = newYearsEve.piusDays(1000):
year = aThousandDaysLater.getYearO;// 2002
month = aThousandDaysLater.getMonthValueO; // 09
day = aThousandDaysLater.getDayOfMonth(); // 26
LocalDate 类封装了实例域来维护所设置的日期。如果不查看源代码, 就不可能知道类内部的日期表示。当然, 封装的意义在于,这一点并不重要, 重要的是类对外提供的方法。
注释:实际上,Date 类还有 getDay、getMonth 以及 getYear 等方法, 然而并不推荐使用这些方法。 当类库设计者意识到某个方法不应该存在时, 就把它标记为不鼓励使用。
类库设计者意识到应当单独提供类来处理日历,不过在此之前这些方法已经是 Date 类的一部分了。Java 1.1 中引入较早的一组日历类时,Date 方法被标为废弃不用。 虽然 仍然可以在程序中使用这些方法,不过如果这样做, 编译时会出现警告。 最好还是不要 使用这些废弃不用的方法, 因为将来的某个类库版本很有可能将它们完全删除。
4.2.2 更改器方法与访问器方法
再来看上一节中的 plusDays 方法调用:
LocalDate aThousandDaysLater = newYearsEve.plusDays(1000);
这个调用之后 newYeareEve 会有什么变化? 它会改为 1000 天之后的日期吗? 事实上,并没 有。plusDays 方法会生成一个新的 LocalDate 对象,然后把这个新对象赋给 aThousandDaysLater 变量。原来的对象不做任何改动。 我们说 plusDays 方法没有更改调用这个方法的对象。(这类 似于第 3章中见过的 String 类的 toUpperCase 方法。在一个字符串上调用 toUpperCase 时,这 个字符串仍保持不变,会返回一个将字符大写的新字符串。)
Java 库的一个较早版本曾经有另一个类来处理日历,名为 GregorianCalendar。 可以如下 为这个类表示的一个日期增加 1000 天:
Cregori anCalendar someDay = new CregorianCalendar(1999, 11, 31);
//Odd feature of that class: month numbers go from 0 to 11
someDay.add(Calendar.DAY_0F_M0NTH, 1000);
与 LocalDate.plusDays 方法不同,GregorianCalendar.add 方法是一个更改器方法 ( mutator method ) 调用这个方法后,someDay 对象的状态会改变。可以如下査看新状态:
year = someDay.get(Calendar.YEAR); // 2002
month = someDay.get(Calendar.MONTH)+ 1; // 09
day = someDay.get(Ca1endar.DAY_0F_M0NTH); // 26
正是因为这个原因,我们将变量命名为 someDay 而不是 newYearsEve 调用这个更改 器方法之后,它不再是新年前夜。
相反, 只 访 问 对 象 而 不 修 改 对 象 的 方 法 有 时 称 为 访 问 器 方 法 。例 如, LocalDate.getYear 和 GregorianCalendar.get 就是访问器方法。
C++ 注释: 在 C++ 中, 带有 const 后缀的方法是访问器方法;默认为更改器方法。但是, 在 Java 语言中, 访问器方法与更改器方法在语法上没有明显的区别。
下面用一个应用 LocalDate 类的程序来结束本节内容的论述。这个程序将显示当前月的日历,其格式为:
Mon Tue Wed Thu Fri Sat Sun
1 2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26* 27 28 29
30
当前的日用一个 * 号标记。可以看到,这个程序需要解决如何计算某月份的天数以及一个给 定日期相应是星期几。
下面看一下这个程序的关键步骤。首先, 构造了一个日历对象, 并用当前的日期和时间 进行初始化。
LocalDate date = LocalDate.now ();
下面获得当前的月和日。
int month = date.getMonthValue ();
int today = date.getDayOfMonth();
然后, 将 date 设置为这个月的第一天, 并得到这一天为星期几。
date = date.minusDays (today - 1); // Set to start of month
DayOfWeek weekday = date.getDayOfWeek();
int value = weekday .getValue(); // 1 = Monday , .. . 7 = Sunday
变量 weekday 设置为 DayOfWeek 类型的对象。我们调用这个对象的 getValue 方法来得 到星期几的一个数值。这会得到一个整数, 这里遵循国际惯例, 即周末是一周的末尾, 星期 一就返冋 1, 星期二返回 2, 依此类推。星期日则返回 7。
注意,日历的第一行是缩进的, 使得月份的第一天指向相应的星期几。下面的代码会打 印表头和第一行的缩进:
System . out.println("Mon Tue Wed Thu Fri Sat Sun");
for (int i = 1; i < value ; i++)
System,out .print(" ");
现在我们来打印日历的主体。进入一个循环, 其中 date 遍历一个月中的每一天。
每次迭代时, 打印 日期值。 如果 date 是当前日期, 这个日期则用一个 *标记。接下来, 把 date 推进到下一天。如果到达新的一周的第一天, 则换行打印:
while (date .getMonthValueO == month)
{
System.out.printf("%3d" , date.getDayOfMonth());
if (date.getDayOfMonth() == today)
System.out.print("*");
else
System.out.print(" ");
date = date.plusDays(l);
if (date.getDayOfWeekQ .getValueQ = 1) System.out.println();
}
什么时候结束呢? 我们不知道这个月有几天, 是 31 天、30 天、29 天还是 28 天。实际上, 只要 date 还在当月就要继续迭代。
可以看到, 利用 LocalDate 类可以编写一个口历程序, 能处理星期几以及各月天数不同 等复杂问题。你并不需要知道 LocalDate 类如何计算月和星期几。只需要使用这个类的接口, 如 plusDays 和 getDayOfWeek 等方法。
这个示例程序的重点是向你展示如何使用一个类的接口来完成相当复杂的任务, 而无需了解实现细节。
API java.time.LocalDate 8
• static Local Time now( ) 构造一个表示当前日期的对象。
• static LocalTime of(int year, int month , int day ) 构造一个表示给定日期的对象。
• int getYear( )
• int getMonthValue( )
• int getDayOfMonth( )
得到当前日期的年、 月和曰。
• DayOfWeek getDayOfWeek
得到当前日期是星期几, 作为 DayOfWeek 类的一个实例返回。 调用 getValue 来得到 1 ~ 7 之间的一个数, 表示这是星期几, 1 表示星期一, 7 表示星期日。
• Local Date piusDays(int n )
• Local Date minusDays(int n)
生成当前日期之后或之前 n 天的日期。
4.3 用户自定义类
在第 3 章中, 已经开始编写了一些简单的类。但是, 那些类都只包含一个简单的 main 方法。现在开始学习如何设计复杂应用程序所需要的各种主力类( workhorse class) 。通常, 这些类没有 main 方法, 却有自己的实例域和实例方法。 要想创建一个完整的程序, 应该将若干类组合在一起, 其中只有一个类有 main 方法。
4.3.1 Employee类
在 Java 中, 最简单的类定义形式为:
class ClassName
{
field1
field2
constructor1
constructor2
...
method1
method2
...
}
下面看一个非常简单的 Employee 类。 在编写薪金管理系统时可能会用到。
class Employee
{
// instance fields
private String name ;
private double salary;
private Local Date hireDay; // constructor
public Employee(String n , double s, int year, int month , int day)
{
name = n;
salary = s;
hireDay = Local Date.of(year, month, day);
} // a method
public String getNameO
{
return name;
}
// more methods
...
}
这里将这个类的实现细节分成以下几个部分, 并分別在稍后的几节中给予介绍。 下面先看看 程序清单 4-2, 这个程序显示了一个 Employee 类的实际使用。(略过)
在这个程序中,构造了一个 Employee 数组, 并填入了三个雇员对象:
Employee [] staff = new Employee[3];
staff[0] = new Employee("Carl Cracker", ...);
staff[1] = new Employee("Harry Hacker", . . .);
staff[2] = new Employee("Tony Tester", . . .);
接下来,利用 Employee 类的 raiseSalary方法将每个雇员的薪水提高 5%:
for (Employee e : staff)
e.raiseSalary(5);
最后,调用 getName 方法、getSalary方法和 getHireDay 方法将每个雇员的信息打印出来:
for (Employee e : staff)
System.out.println("name=" + e.getName()
+ ",salary=" + e.getSalary()
+ ",hireDay=" + e.getHireDay());
注意,在这个示例程序中包含两个类:Employee类和带有 public 访问修饰符的 EmployeeTest 类。EmployeeTest 类包含了 main 方法,其中使用了前面介绍的指令。
源文件名是 EmployeeTest.java,这是因为文件名必须与 public 类的名字相匹配。在一个 源文件中, 只能有一个公有类,但可以有任意数目的非公有类。
接下来,当编译这段源代码的时候, 编译器将在目录下创建两个类文件:EmployeeTest. class 和 Employee.class。
将程序中包含 main方法的类名提供给字节码解释器, 以便启动这个程序:
java EmployeeTest
字节码解释器开始运行 EmployeeTest 类的 main方法中的代码。在这段代码中,先后构 造了三个新 Employee 对象, 并显示它们的状态。
4.3.2 多个源文件的使用
在程序清单 4-2 中, 一个源文件包含了两个类。许多程序员习惯于将每一个类存在一个 单独的源文件中。例如,将 Employee 类存放在文件 Employee.java 中, 将 EmployeeTest 类存 放在文件 EmployeeTest.java 中。
如果喜欢这样组织文件, 将可以有两种编译源程序的方法。一种是使用通配符调用 Java编译器:
javac Employee*.java
于是,所有与通配符匹配的源文件都将被编译成类文件。或者键人下列命令:
javac EmployeeTest.java
读者可能会感到惊讶,使用第二种方式,并没有显式地编译 Employee.java。 然而,当 Java 编 译器发现 EmployeeTest.java 使用了 Employee 类时会查找名为 Employee.class 的文件。如果没有找 到这个文件, 就会自动地搜索 Employee.java, 然后,对它进行编译。更重要的是:如果 Employee. java 版本较已有的 Employee.dass 文件版本新,Java 编译器就会自动地重新编译这个文件。
注释: 如果熟悉 UNIX 的“ make” 工具(或者是 Windows 中的“ nmake” 等工具,) 可 以认为 Java 编译器内置了“ make” 功能。
4.3.3 剖析 Employee 类
下面对 Employee 类进行剖析。首先从这个类的方法开始。 通过查看源代码会发现,这 个类包含一个构造器和 4 个方法:
public Employee(String n , double s, int year, int month , int day)
public String getName()
public double getSalary()
public Local Date getHi reDay()
public void raiseSalary(double byPercent)
这个类的所有方法都被标记为 public。 关键字 public 意味着任何类的任何方法都可以调用这 些方法(共有 4 种访问级别, 将在本章稍后和下一章中介绍)。
接下来,需要注意在 Employee 类的实例中有三个实例域用来存放将要操作的数据:
private String name;
private double salary;
private LocalDate hireDay;
关键字 private 确保只有 Employee 类自身的方法能够访问这些实例域, 而其他类的方法不能 够读写这些域。
注释: 可以用 public 标记实例域, 但这是一种极为不提倡的做法 . , public 数据域允许程 序中的任何方法对其进行读取和修改。, 这就完全破坏了封装。 任何类的任何方法都可以 修改 public 域, 从我们的经验来看, 某些代码将使用这种存取权限, 而这并不我们所希望的, 因此, 这里强烈建议将实例域标记为 private。
最后, 请注意, 有两个实例域本身就是对象: name 域是 String 类对象, hireDay 域是 LocalDate 类对象。这种情形十分常见:类通常包括类型属于某个类类型的实例域。
4.3.4 从构造器开始
下面先看看 Employee 类的构造器:
public Employee(String n, double s, int year, int month, int day)
{
name = n;
salary = s;
LocalDate hireDay = Local Date.of(year, month, day);
}
可以看到, 构造器与类同名。在构造 Employee 类的对象时, 构造器会运行,以便将实例域 初始化为所希望的状态。
例如, 当使用下面这条代码创建 Employee 类实例时:
new Eraployee("James Bond", 100000, 1950, 1, 1)
将会把实例域设置为:
name = "James Bond";
salary = 100000;
hireDay = LocalDate.of(1950, 1, 1); // January 1, 1950
构造器与其他的方法有一个重要的不同。构造器总是伴随着 new 操作符的执行被调用, 而不能对一个已经存在的对象调用构造器来达到重新设置实例域的目的。例如,
janes.Employee("James Bond", 250000, 1950, 1, 1) // ERROR
将产生编译错误。
本章稍后还会更加详细地介绍有关构造器的内容。现在只需要记住:
• 构造器与类同名
• 每个类可以有一个以上的构造器
• 构造器可以有 0 个、1 个或多个参数
• 构造器没有返回值
• 构造器总是伴随着 new 操作一起调用
C++ 注释:Java 构造器的工作方式与 C++ —样。但是, 要记住所有的 Java 对象都是在堆中构造的, 构造器总是伴随着 new 操作符一起使用。C++ 程序员最易犯的错误就是忘记 new 操作符:
Employee number007("James Bond", 100000, 1950, 1, 1);
// C++, not Java
这条语句在 C++ 中能够正常运行,但在 Java 中却不行。
警告: 请注意, 不要在构造器中定义与实例域重名的局部变量。例如, 下面的构造器将 无法设置 salary。
public Employee(String n, double s, ...)
{
String name = n; // Error
double salary = s; // Error
}
这个构造器声明了局部变量 name 和 salary。这些变量只能在构造器内部访问。这些变量屏蔽了同名的实例域。有些程序设计者(例如, 本书的作者)常常不假思索地写出 这类代码, 因为他们已经习惯增加这类数据类型。这种错误很难被检查出来, 因此, 必须注意在所有的方法中不要命名与实例域同名的变量。
4.3.5 隐式参数与显式参数
方法用于操作对象以及存取它们的实例域。例如,方法:
public void raiseSalary(double byPercent)
{
double raise = salary * byPercent / 100;
salary += raise;
}
将调用这个方法的对象的 salary 实例域设置为新值。看看下面这个调用:
number007. raiseSalary(5);
它的结果将 number007.salary 域的值增加 5%。具体地说,这个调用将执行下列指令:
double raise = nuaber007.salary * 5 / 100;
nuiber007.salary += raise;
raiseSalary 方法有两个参数。 第一个参数称为隐式 ( implicit ) 参数, 是出现在方法名前的 Employee 类对象。第二个参数位于方法名后面括号中的数值,这是一个显式 ( explicit) 参 数 ( 有些人把隐式参数称为方法调用的目标或接收者。)
可以看到,显式参数是明显地列在方法声明中的, 例如 double byPercent。隐式参数没有出现在方法声明中。
在每一个方法中, 关键字 this 表示隐式参数。 如果需要的话,可以用下列方式编写 raiseSalary 方法:
public void raiseSalary(double byPercent)
{
double raise = this.salary * byPercent / 100;
this.salary += raise;
}
有些程序员更偏爱这样的风格,因为这样可以将实例域与局部变量明显地区分开来。
C++ 注释:在 C++ 中, 通常在类的外面定义方法:
void Employee::raiseSalary(double byPercent) // C++, not Java
{
...
}
如果在类的内部定义方法, 这个方法将自动地成为内联(inline) 方法
class Employee
{
...
int getNameQ { return name; } // inline in C++
}
在 Java 中, 所有的方法都必须在类的内部定义, 但并不表示它们是内联方法。是否将某个方法设置为内联方法是 Java 虚拟机的任务。即时编译器会监视调用那些简洁、经常被调用、 没有被重载以及可优化的方法。
4.3.6 封装的优点
最后,再仔细地看一下非常简单的 getName 方法、 getSalary 方法和 getHireDay 方法。
public String getName()
{
return name;
}
public double getSalaryO
{
return salary;
}
public LocalDate getHireDay()
{
return hireDay;
}
这些都是典型的访问器方法。由于它们只返回实例域值, 因此又称为域访问器。
将 name、 salary 和 hireDay 域标记为 public , 以此来取代独立的访问器方法会不会更容 易些呢?
关键在于 name 是一个只读域。一旦在构造器中设置完毕,就没有任何一个办法可以对它进行修改,这样来确保 name 域不会受到外界的破坏。
虽然 salary 不是只读域,但是它只能用 raiseSalary 方法修改。特别是一旦这个域值出现了错误, 只要调试这个方法就可以了。如果 salary 域是 public 的,破坏这个域值的捣乱者有可能会出没在任何地方。
在有些时候, 需要获得或设置实例域的值。因此,应该提供下面三项内容:
• 一 个私有的数据域;
• 一 个公有的域访问器方法;
• 一个公有的域更改器方法。
这样做要比提供一个简单的公有数据域复杂些, 但是却有着下列明显的好处:
首先, 可以改变内部实现,除了该类的方法之外,不会影响其他代码。例如,如果将存储名字的域改为:
String firstName;
String lastName;
那么 getName 方法可以改为返回
firstName + " " + 1astName
对于这点改变, 程序的其他部分完全不可见。
当然, 为了进行新旧数据表示之间的转换,访问器方法和更改器方法有可能需要做许多工作。但是, 这将为我们带来了第二点好处:更改器方法可以执行错误检查, 然而直接对域进行赋值将不会进行这些处理。例如, setSalary 方法可以检查薪金是否小于 0。
警告: 注意不要编写返回引用可变对象的访问器方法。在 Employee 类中就违反了这个设 计原则, 其中的 getHireDay 方法返回了一个 Date 类对象:
class Employee
{
private Date hireDay ;
...
public Date getHireDayO
{
return hireDay; // Bad
}
...
}
LocalDate 类没有更改器方法, 与之不同, Date 类有一个更改器方法 setTime, 可以 在这里设置毫秒数。
Date 对象是可变的, 这一点就破坏了封装性! 请看下面这段代码:
Employee harry = . .
Date d = harry.getHireDayO ;
double tenYearsInMilliSeconds = 10 * 365.25 * 24 * 60 * 60 * 1000;
d.setTime(d.getTime() - (long) tenYearsInMilliSeconds);
// let's give Harry ten years of added seniority
出错的原因很微妙。d 和 harry.hireDay 引用同一个对象(请参见图 4-5 )。 更改器方法就可以自动地改变这个雇员对象的私有状态!
如果需要返回一个可变对象的引用, 应该首先对它进行克隆(clone )。对象 clone 是 指存放在另一个位置上的对象副本。 有关对象 clone 的详细内容将在第 6 章中讨论。 下 面是修改后的代码:
class Employee
{
...
public Date getHireDay()
{
return (Date) hireDay.clone(); // Ok
}
...
}
凭经验可知, 如果需要返回一个可变数据域的拷贝,就应该使用 clone。
4.3.7 基于类的访问权限
从前面已经知道,方法可以访问所调用对象的私有数据。一个方法可以访问所属类的所有对象的私有数据,这令很多人感到奇怪!例如,下面看一下用来比较两个雇员的 equals 方法。
class Employee
{
...
public boolean equals(Employee other)
{
return name.equals(other.name);
}
}
典型的调用方式是
if (harry,equals(boss)) . . .
这个方法访问 harry 的私有域, 这点并不会让人奇怪,然而, 它还访问了 boss 的私有域。这是合法的, 其原因是 boss 是 Employee 类对象, 而 Employee 类的方法可以访问 Employee 类 的任何一个对象的私有域。
C++ 注释:C++ 也有同样的原则。方法可以访问所属类的私有特性( feature ), 而不仅限于访问隐式参数的私有特性。
4.3.8 私有方法
在实现一个类时,由于公有数据非常危险, 所以应该将所有的数据域都设置为私有的。然 而,方法又应该如何设计呢? 尽管绝大多数方法都被设计为公有的,但在某些特殊情况下,也可能将它们设计为私有的。有时,可能希望将一个计算代码划分成若干个独立的辅助方法。通 常, 这些辅助方法不应该成为公有接口的一部分,这是由于它们往往与当前的实现机制非常紧 密, 或者需要一个特别的协议以及一个特别的调用次序。最好将这样的方法设计为 private 的。
在 Java 中,为了实现一个私有的方法, 只需将关键字 public 改为 private 即可。
对于私有方法, 如果改用其他方法实现相应的操作, 则不必保留原有的方法。如果数据的表达方式发生了变化,这个方法可能会变得难以实现, 或者不再需要。然而,只要方法是私有的,类的设计者就可以确信:它不会被外部的其他类操作调用,可以将其删去。如果方法是公有的, 就不能将其删去,因为其他的代码很可能依赖它。
4.3.9 final实例域
可以将实例域定义为 final。 构建对象时必须初始化这样的域。也就是说, 必须确保在每 一个构造器执行之后,这个域的值被设置, 并且在后面的操作中, 不能够再对它进行修改。 例如,可以将 Employee 类中的 name 域声明为 final, 因为在对象构建之后,这个值不会再被修改, 即没有 setName 方法。
class Employee
{
private final String name;
…
}
final 修饰符大都应用于基本 (primitive ) 类型域,或不可变(immutable) 类的域(如果类中的每个方法都不会改变其对象, 这种类就是不可变的类。例如,String类就是一个不可变的类)。
对于可变的类, 使用 final 修饰符可能会对读者造成混乱。例如,
private final StringBuiIcier evaluations;
在 Employee 构造器中会初始化为
evaluations = new StringBuilder();
final 关键字只是表示存储在 evaluations 变量中的对象引用不会再指示其他 StringBuilder 对象。不过这个对象可以更改:
public void giveGoldStar()
{
evaluations.append(LocalDate.now() + ": Gold star!\n");
}
4.4 静态域与静态方法
在前面给出的示例程序中,main 方法都被标记为 static 修饰符。下面讨论一下这个修饰 符的含义。
4.4.1 静态域
如果将域定义为 static, 每个类中只有一个这样的域。而每一个对象对于所有的实例域 却都有自己的一份拷贝。例如, 假定需要给每一个雇员賦予唯一的标识码。这里给 Employee 类添加一个实例域 id 和一个静态域 nextld:
class Employee
{
private static int nextld = 1;
private int id;
...
}
现在, 每一个雇员对象都有一个自己的 id 域, 但这个类的所有实例将共享一个 nextld 域。换句话说, 如果有 1000 个 Employee 类的对象, 则有 1000 个实例域 id。但是, 只有一 个静态域 nextld。即使没有一个雇员对象, 静态域 nextld 也存在。它属于类,而不属于任何独立的对象。
注释:在绝大多数的面向对象程序设计语言中, 静态域被称为类域。术语“ static” 只是 沿用了 C++ 的叫法, 并无实际意义。
下面实现一个简单的方法:
public void setld()
{
id = nextld;
nextld++;
}
假定为 harry 设定雇员标识码:
harry.setId();
harry 的 id 域被设置为静态域 nextld 当前的值,并且静态域 nextld 的值加 1:
harry.id = Employee.nextld;
Eip1oyee.nextId++;
4.4.2 静态常量
静态变量使用得比较少,但静态常量却使用得比较多。例如, 在 Math 类中定义了一个 静态常量:
public class Math
{
...
public static final double PI = 3.14159265358979323846;
...
}
在程序中,可以采用 Math.PI 的形式获得这个常量。
如果关键字 static 被省略, PI 就变成了 Math 类的一个实例域。需要通过 Math 类的对象 访问 PI,并且每一个 Math 对象都有它自己的一份 PI 拷贝。
另一个多次使用的静态常量是 System.out。它在 System 类中声明:
public class System
{
...
public static final PrintStream out = . . .;
...
}
前面曾经提到过,由于每个类对象都可以对公有域进行修改,所以,最好不要将域设计为 public。然而, 公有常量(即 final 域)却没问题。因为 out 被声明为 final, 所以,不允许再将其他打印流赋给它:
System.out = new PrintStrean(. . .); // Error out is fina
注释: 如果查看一下 System 类, 就会发现有一个 setOut 方法, 它可以将 System.out 设 置为不同的流。 读者可能会感到奇怪, 为什么这个方法可以修改 final 变量的值。原因在 于, setOut 方法是一个本地方法, 而不是用 Java 语言实现的。本地方法可以绕过 Java 语 言的存取控制机制。这是一种特殊的方法, 在自己编写程序时, 不应该这样处理。
4.4.3 静态方法
静态方法是一种不能向对象实施操作的方法 。例如 Math 类的 pow 方法就是一个静态方法。表达式Math.pow(x, a)
计算幂x的a次方。在运算时,不使用任何Math对象。换句话说,没有隐式的参数。
可以认为静态方法是没有 this 参数的方法(在一个非静态的方法中,this参数标识这个方法的隐式参数,参见4.3.5节)
Employee 类的静态方法不能访问Id实例域,因为它不能操作对象。但是,静态方法可以访问自身类中的静态域。下面是使用这种静态方法的一个示例:
public static int getNextld()
{
return nextld; // returns static field
}
可以通过类名调用这个方法:
int n = Employee.getNextldO;
这个方法可以省略关键字static吗? 答案是肯定的。但是,需要通过 Employee 类对象的引用调用这个方法。
注释: 可以使用对象调用静态方法。例如, 如果 harry 是一个 Employee 对象, 可以用 harry.getNextId( ) 代替 Employee.getNextId( ) 。不过,这种方式很容易造成混淆,其原因 是 getNextld 方法计算的结果与 harry 毫无关系。我们建议使用类名, 而不是对象来调用 静态方法。
在下面两种情况下使用静态方法 :
• 一 个方法不需要访问对象状态,其所需参数都是通过显式参数提供(例如:Math.pow)。
• 一个方法只需要访问类的静态域(例如:Employee.getNextld)。
C++ 注释:Java 中的静态域与静态方法在功能上与 C++ 相同。但是, 语法书写上却稍有所不同。在 C++ 中, 使用::操作符访问自身作用域之外的静态域和静态方法, 如 Math::PI 。
术语“ static” 有一段不寻常的历史。起初,C 引入关键字 static 是为了表示退出一 个块后依然存在的局部变量在这种情况下, 术语“ static” 是有意义的: 变量一直存在,当再次进入该块时仍然存在。随后, static 在 C 中有了第二种含义, 表示不能被其他文件 访问的全局变量和函数。 为了避免引入一个新的关键字, 关键字 static 被重用了。最后, C++ 第三次重用了这个关键字,与前面赋予的含义完全不一样, 这里将其解释为:属于类且不属于类对象的变量和函数。这个含义与 Java 相同。
4.4.4 工厂方法
静态方法还有另外一种常见的用途。类似 LocalDate 和 NumberFormat 的类使用静态工厂方法 (factory method) 来构造对象。你已经见过工厂方法 LocalDate.now 和 LocalDate.of。 NumberFormat 类如下使用工厂方法生成不同风格的格式化对象:
NumberFormat currencyFormatter = NumberFormat.getCurrencylnstance();
NumberFormat percentFormatter = NumberFormat.getPercentlnstance();
double x = 0.1;
System.out.println(currencyFormatter.format(x)); // prints SO.10
System.out.println(percentFomatter.format(x)); // prints 10%
为什么 NumberFormat 类不利用构造器完成这些操作呢? 这主要有两个原因:
• 无法命名构造器。构造器的名字必须与类名相同。但是, 这里希望将得到的货币实例 和百分比实例采用不用的名字。
• 当使用构造器时,无法改变所构造的对象类型。而 Factory 方法将返回一个 DecimalFormat 类对象,这是 NumberFormat 的子类(有关继承的详细内容请参看第 5 章)。
4.4.5 main方法
需要注意,不需要使用对象调用静态方法。例如,不需要构造 Math 类对象就可以调用 Math.pow。
同理, main 方法也是一个静态方法。
public class Application
{
public static void main(StringD args)
{
// construct objects here
...
}
}
main 方法不对任何对象进行操作。事实上,在启动程序时还没有任何一个对象。静态的 main 方法将执行并创建程序所需要的对象。
提示: 每一个类可以有一个 main 方法。这是一个常用于对类进行单元测试的技巧。例 如, 可以在 Employee 类中添加一个 main 方法:
class Employee
{
public Employee(String n, double s, int year, int month, int day)
{
name = n;
salary = s;
LocalDate hireDay = Local Date.now(year, month , day) ;
}
...
public static void main(String[] args) // unit test
{
Employee e = new Employee("Romeo", 50000, 2003, 3, 31);
e .raiseSalary(lO);
System.out.println(e .getName() + " " + e.getSalary());
}
...
}
如果想要独立地测试 Employee 类, 只需要执行: java Employee
如果 Employee 类是一个更大型应用程序的一部分, 就可以使用下面这条语句运行程序: java Application
4.5 方法参数
首先回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语。按值调用 (call by value) 表示方法接收的是调用者提供的值。而按引用调用 ( call by reference) 表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。“ 按… … 调用”(call by) 是一个标准的计算机科学术语, 它用来描述各种程序设计语言(不只是 Java ) 中方法参数的传递方式(事实上,以前还有按 名调用 ( call by name ), Algol 程序设计语言是最古老的高级程序设计语言之一, 它使用的就 是这种参数传递方式。不过,对于今天, 这种传递方式已经成为历史)。
Java 程序设计语言总是采用按值调用。也就是说, 方法得到的是所有参数值的一个拷贝,特别是,方法不能修改传递给它的任何参数变量的内容。
例如, 考虑下面的调用:
double percent = 10;
harry.raiseSalary(percent):
不必理睬这个方法的具体实现, 在方法调用之后, percent 的值还是 10。
下面再仔细地研究一下这种情况。假定一个方法试图将一个参数值增加至 3 倍:
public static void tripieValue(double x) // doesn't work
{
x = 3 *x;
}
然后调用这个方法:
double percent = 10;
tripieValue(percent);
不过,并没有做到这一点。调用这个方法之后,percent 的值还是 10。下面看一下具体的执行 过程:
1 ) x 被初始化为 percent 值的一个拷贝(也就 是 10 ) 。
2 ) x 被乘以 3后等于 30。 但是 percent 仍然 是 10 (如图 4-6 所示)。
3 ) 这个方法结束之后,参数变量 x 不再使用。
然而,方法参数共有两种类型:
• 基本数据类型(数字、布尔值)。
• 对象引用。
读者已经看到,一个方法不可能修改一个基本数据类型的参数。而对象引用作为参数就不同了,可以很容易地利用下面这个方法实现将 一个雇员的薪金提高两倍的操作:
public static void tripleSalary (Employee x) // works
{
x.raiseSa1ary(200);
}
当调用
harry = new Employee(. . .);
tripleSalary(harry);
时,具体的执行过程为:
1 ) x 被初始化为 harry 值的拷贝,这里是一个对象的引用。
2 ) raiseSalary 方法应用于这个对象引用。x 和 harry 同时引用的那个 Employee 对象的薪金提高了 200%。
3 ) 方法结束后,参数变量 x 不再使用。当然,对象变量 harry 继续引用那个薪金增至 3 倍的雇员对象(如图 4-7 所示)。
读者已经看到,实现一个改变对象参数状态的方法并不是一件难事。理由很简单, 方法 得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。
很多程序设计语言(特别是, C++ 和 Pascal) 提供了两种参数传递的方式:值调用和引用调用。有些程序员(甚至本书的作者)认为 Java 程序设计语言对对象采用的是引用调用, 实际上, 这种理解是不对的。由于这种误解具有一定的普遍性,所以下面给出一个反例来详细地阐述一下这个问题。
首先,编写一个交换两个雇员对象的方法:
public static void swap(Employee x , Employee y) // doesn't work
{
Employee temp = x;
x = y;
y = temp;
}
如果 Java 对对象采用的是按引用调用,那么这个方法就应该能够实现交换数据的效果:
Employee a = new Employee("Alice", . . .);
Employee b = new Employee("Bob", . . .);
swap(a, b);
// does a now refer to Bob, b to Alice?
但是,方法并没有改变存储在变量 a 和 b 中的对象引用。swap 方法的参数 x 和 y 被初始 化为两个对象引用的拷贝,这个方法交换的是这两个拷贝。
// x refers to Alice, y to Bob
Employee temp = x;
x = y;
y = temp;
// now x refers to Bob, y to Alice
最终,白费力气。在方法结束时参数变量 x 和 y 被丢弃了。原来的变量 a 和 b 仍然引用 这个方法调用之前所引用的对象(如图 4-8 所示)。
这个过程说明:Java 程序设计语言对对象采用的不是引用调用,实际上, 对象引用是按 值传递的。
下面总结一下 Java 中方法参数的使用情况:
• 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
• 一个方法可以改变一个对象参数的状态。
• 一个方法不能让对象参数引用一个新的对象。
程序清单 4-4 中的程序给出了相应的演示。在这个程序中, 首先试图将一个值参数的值 提高两倍,但没有成功:
Testing tripleValue:
Before: percent=10.0
End of method: x:30.0
After: percent=10.0
随后, 成功地将一个雇员的薪金提高了两倍:
Testing tripleSalary:
Before: salary=50000.0
End of method: salary=150000.0
After: salary=150000.0
方法结束之后, harry 引用的对象状态发生了改变。这是因为这个方法可以通过对象引用 的拷贝修改所引用的对象状态。
最后,程序演示了 swap 方法的失败效果:
Testing swap:
Before: a=Alice
Before: b=Bob
End of method: x=Bob
End of method: y=Alice
After: a=Alice
After: b=Bob
可以看出, 参数变量 X 和 y 交换了, 但是变量 a 和 b 没有受到影响。
C++ 注释:C++ 有值调用和引用调用。 引用参数标有 & 符号。 例如, 可以轻松地实现 void tripleValue(double& x) 方法或 void swap(Employee& x, Employee& y) 方法实现修改 它们的引用参数的目的。
4.6 对象构造
前面已经学习了编写简单的构造器,可以定义对象的初始状态。但是,由于对象构造非常重要,所以 Java 提供了多种编写构造器的机制。下面将详细地介绍这些机制。
4.6.1 重载
有些类有多个构造器。例如, 可以如下构造一个空的 StringBuilder 对象:
StringBuilder messages = new StringBuilderO;
或者, 可以指定一个初始字符串:
StringBuilder todoList = new StringBuilder('To do:\n");
这种特征叫做重载( overloading。) 如果多个方法(比如, StringBuilder 构造器方法)有相同的名字、 不同的参数,便产生了重载。编译器必须挑选出具体执行哪个方法,它通过用 各个方法给出的参数类型与特定方法调用所使用的值类型进行匹配来挑选出相应的方法。如果编译器找不到匹配的参数, 就会产生编译时错误,因为根本不存在匹配, 或者没有一个比其他的更好。(这个过程被称为重载解析(overloading resolution)。)
注释:Java 允许重载任何方法, 而不只是构造器方法。因此,要完整地描述一个方法, 需要指出方法名以及参数类型。这叫做方法的签名(signature)。例如, String 类有 4 个 称为 indexOf 的公有方法。它们的签名是
ndexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)
返回类型不是方法签名的一部分。也就是说, 不能有两个名字相同、 参数类型也相 同却返回不同类型值的方法。
4.6.2 默认域初始化
如果在构造器中没有显式地给域赋予初值,那么就会被自动地赋为默认值: 数值为 0、 布尔值为 false、 对象引用为 null。然而,只有缺少程序设计经验的人才会这样做。确实, 如果不明确地对域进行初始化,就会影响程序代码的可读性。
注释: 这是域与局部变量的主要不同点。 必须明确地初始化方法中的局部变量。但是, 如果没有初始化类中的域, 将会被自动初始化为默认值( 0、false 或 null )。
例如, 仔细看一下 Employee 类。 假定没有在构造器中对某些域进行初始化, 就会默认 地将 salary 域初始化为 0, 将 name 和 hireDay 域初始化为 null。 但是,这并不是一种良好的编程习惯。 如果此时调用 getName 方法或 getHireDay 方法, 则会得到一个 null 引用,这应该不是我们所希望的结果:
LocalDate h = harry.getHireDay();
int year = h.getYearQ;// throws exception if h is null
4.6.3 无参数的构造器
很多类都包含一个无参数的构造函数,对象由无参数构造函数创建时, 其状态会设置为适当的默认值。 例如, 以下是 Employee 类的无参数构造函数:
public Employee0
{
name =
salary = 0;
hireDay = LocalDate,now();
}
如果在编写一个类时没有编写构造器, 那么系统就会提供一个无参数构造器。这个构造器将所有的实例域设置为默认值。于是, 实例域中的数值型数据设置为 0、 布尔型数据设置 为 false、 所有对象变量将设置为 null。 如果类中提供了至少一个构造器, 但是没有提供无参数的构造器, 则在构造对象时如果 没有提供参数就会被视为不合法。例如, 在程序清单 4-2 中的 Employee 类提供了一个简单 的构造器:
Employee(String name, double salary, int y, int ra , int d)
对于这个类,构造默认的雇员属于不合法。也就是, 调用e = new Eraployee() ; 将会产生错误。
警告: 请记住,仅当类没有提供任何构造器的时候, 系统才会提供一个默认的构造器。如果在编写类的时候, 给出了一个构造器, 哪怕是很简单的, 要想让这个类的用户能够采用下列方式构造实例:
new ClassName()
就必须提供一个默认的构造器 ( 即不带参数的构造器)。 当然, 如果希望所有域被赋 默认值, 可以采用下列格式:
public ClassName()
{
)
4.6.4 显式域初始化
通过重载类的构造器方法,可以采用多种形式设置类的实例域的初始状态。确保不管怎 样调用构造器,每个实例域都可以被设置为一个有意义的初值,这是一种很好的设计习惯。
可以在类定义中, 直接将一个值赋给任何域。例如:
class Employee
{
private String name ="";
...
}
在执行构造器之前,先执行赋值操作。当一个类的所有构造器都希望把相同的值赋予某个特定的实例域时,这种方式特别有用。
初始值不一定是常量值。在下面的例子中, 可以调用方法对域进行初始化。仔细看一下 Employee 类,其中每个雇员有一个 id 域。可以使用下列方式进行初始化:
class Employee
{
private static int nextld;
private int id = assignld();
private static int assignId()
{
int r = nextld;
nextld++;
return r;
}
...
}
C++ 注释:在 C++ 中, 不能直接初始化类的实例域。所有的域必须在构造器中设置。但 是,有一个特殊的初始化器列表语法,如下所示:
Employee::Employee(String n,double s, int y, int m, int d) // C++
: name(n),
salary(s),
hireDay(y,m, d)
{
}
C++ 使用这种特殊的语法来调用域构造器。在 Java 中没有这种必要, 因为对象没有子对象, 只有指向其他对象的指针。
4.6.5 参数名
在编写很小的构造器时(这是十分常见的,) ,常常在参数命名上出现错误。
通常, 参数用单个字符命名:
public Employee(String n, double s)
{
name=n;
salary=s;
}
但这样做有一个缺陷:只有阅读代码才能够了解参数 n 和参数 s 的含义。
有些程序员在每个参数前面加上一个前缀“ a”:
public Employee(String aNaie, double aSalary)
{
name = aName ;
salary = aSalary;
}
这样很清晰。每一个读者一眼就能够看懂参数的含义。
还一种常用的技巧,它基于这样的事实:参数变量用同样的名字将实例域屏蔽起来。 例 如,如果将参数命名为 salary, salary 将引用这个参数, 而不是实例域。 但是,可以采用 this. salary 的形式访问实例域。回想一下,this 指示隐式参数, 也就是所构造的对象。下面是一个示例:
public Employee(String name, double salary)
{
this.name = name;
this,salary = salary;
}
C++ 注释:在 C++ 中, 经常用下划线或某个固定的字母(一般选用 m 或 x ) 作为实例域 的前缀例如,salary 域可能被命名为 salary、mSalary 或 xSalary Java 程序员通常不 这样做,
4.6.6 调用另一个构造器
关键字 this 引用方法的隐式参数。然而,这个关键字还有另外一个含义。
如果构造器的第一个语句形如 this(...), 这个构造器将调用同一个类的另一个构造器。下 面是一个典型的例子:
public Employee(double s)
{
// calls Employee(String, double)
this("Employee #" + nextld, s);
nextld++;
}
当调用 new Employee(60000) 时, Employee(double) 构造器将调用 Employee(String,double) 构造器。
采用这种方式使用 this 关键字非常有用, 这样对公共的构造器代码部分只编写一次 即可。
C++ 注释: 在 Java 中, this 引用等价于 C++ 的 this 指针。但是, 在 C++ 中, 一个构造 器不能调用另一个构造器。 在 C++ 中, 必须将抽取出的公共初始化代码编写成一个独立 的方法。
4.6.7 初始化块
前面已经讲过两种初始化数据域的方法:
• 在构造器中设置值
• 在声明中赋值
实际上,Java 还有第三种机制, 称为初始化块(initializationblock)。在一个类的声明中, 可以包含多个代码块。只要构造类的对象,这些块就会被执行。例如,
class Employee
{
private static int nextld; private int id;
private String name;
private double salary; // object initialization block
{
id = nextld;
nextld++;
} public Employee(String n, double s)
{
name=n;
salary = s;
}
public Employee()
{
name ="";
salary = 0;
}
...
}
在这个示例中,无论使用哪个构造器构造对象,id 域都在对象初始化块中被初始化。首先运行初始化块,然后才运行构造器的主体部分。
这种机制不是必需的,也不常见。通常会直接将初始化代码放在构造器中。
注释: 即使在类的后面定义, 仍然可以在初始化块中设置域。但是, 为了避免循环定义, 不要读取在后面初始化的域。 具体的规则请参看 Java 语言规范的 8.3.2.3 节 ( http://docs. oracle.com/javase/specs) 。 这个规则的复杂度足以使编译器的实现者头疼, 因此建议将初始化块放在域定义之后。
由于初始化数据域有多种途径,所以列出构造过程的所有路径可能相当混乱。下面是调用构造器的具体处理步骤:
1 ) 所有数据域被初始化为默认值(0、false 或 null)。
2 ) 按照在类声明中出现的次序, 依次执行所有域初始化语句和初始化块。
3 ) 如果构造器第一行调用了第二个构造器,则执行第二个构造器主体。
4 ) 执行这个构造器的主体。
当然,应该精心地组织好初始化代码,这样有利于其他程序员的理解。 例如, 如果让类 的构造器行为依赖于数据域声明的顺序, 那就会显得很奇怪并且容易引起错误。
可以通过提供一个初始化值, 或者使用一个静态的初始化块来对静态域进行初始化。前 面已经介绍过第一种机制:
private static int nextld = 1;
如果对类的静态域进行初始化的代码比较复杂,那么可以使用静态的初始化块。 将代码放在一个块中,并标记关键字 static。下面是一个示例。其功能是将雇员 ID 的起 始值赋予一个小于 10 000 的随机整数。
// static initialization block
static
{
Random generator = new Random();
nextld = generator.nextlnt(10000);
}
在类第一次加载的时候, 将会进行静态域的初始化。与实例域一样,除非将它们显式地 设置成其他值, 否则默认的初始值是 0、 false 或 null。 所有的静态初始化语句以及静态初始化块都将依照类定义的顺序执行。
注释:让人惊讶的是, 在 JDK 6 之前, 都可以用 Java 编写一个没有 main 方法的“ Hello, World” 程序。
public class Hello
{
static
{
System.out.println("Hel1o, World");
}
}
当用 java Hello 调用这个类时, 就会加载这个类, 静态初始化块将会打印“ Hello, World" 。在此之后,会显示一个消息指出 main 未定义。 从 Java SE 7 以后,java 程序首先会检查是否有一个 main 方法。
本节讨论的很多特性:
•重载构造器
•用 this(...) 调用另一个构造器
•无参数构造器
•对象初始化块
•静态初始化块
•实例域初始化
API java.util.Random 1.0
•Random( )
构造一个新的随机数生成器。
• int nextlnt(int n)1.2
返回一个 0 ~ n-1之间的随机数。
4.6.8 对象析构与 finalize 方法
有些面向对象的程序设计语言,特别是 C++, 有显式的析构器方法,其中放置一些当对象不再使用时需要执行的清理代码。在析构器中, 最常见的操作是回收分配给对象的存储空间。由于 Java 有自动的垃圾回收器,不需要人工回收内存, 所以 Java 不支持析构器。
当然,某些对象使用了内存之外的其他资源, 例如,文件或使用了系统资源的另一个对象的句柄。在这种情况下,当资源不再需要时, 将其回收和再利用将显得十分重要。
可以为任何一个类添加 finalize 方法。finalize 方法将在垃圾回收器清除对象之前调用。 在实际应用中,不要依赖于使用 finalize 方法回收任何短缺的资源, 这是因为很难知道这个方法什么时候才能够调用。
注释: 有个名为 System.runFinalizersOnExit(true) 的方法能够确保 finalizer 方法在 Java 关闭前被调用。不过,这个方法并不安全,也不鼓励大家使用。有一种代替的方法是使用方法 Runtime.addShutdownHook 添加“ 关闭钓” (shutdown hook), 详细内容请参看 API 文档。
如果某个资源需要在使用完毕后立刻被关闭, 那么就需要由人工来管理。对象用完时, 可以应用一个 close 方法来完成相应的清理操作。7.2.5 节会介绍如何确保这个方法自动得到 调用。
4.7 包
Java 允许使用包( package)将类组织起来。借助于包可以方便地组织自己的代码,并将 自己的代码与别人提供的代码库分开管理。
标准的 Java 类库分布在多个包中,包括 java.lang、java.util 和java.net 等。标准的 Java 包具有一个层次结构。如同硬盘的目录嵌套一样,也可以使用嵌套层次组织包。所有标准的 Java 包都处于java 和 javax 包层次中。
使用包的主要原因是确保类名的唯一性。假如两个程序员不约而同地建立了 Employee 类。只要将这些类放置在不同的包中, 就不会产生冲突。事实上,为了保证包名的绝对 唯一性, Sun 公司建议将公司的因特网域名(这显然是独一无二的) 以逆序的形式作为包名,并且对于不同的项目使用不同的子包。例如, horstmann.com 是本书作者之一注册的域 名。逆序形式为 com.horstmann。这个包还可以被进一步地划分成子包, 如 com.horstmann. corejava。
从编译器的角度来看, 嵌套的包之间没有任何关系。例如,java.util 包与java.util.jar 包 毫无关系。每一个都拥有独立的类集合。
4.7.1 类的导入
一个类可以使用所属包中的所有类, 以及其他包中的公有类( public class) 。我们可以 采用两种方式访问另一个包中的公有类。第一种方式是在每个类名之前添加完整的包名。 例如:
java.tiie.LocalDate today = java.tine.Local Date.now();
这显然很繁琐。更简单且更常用的方式是使用 import 语句。import 语句是一种引用包含 在包中的类的简明描述。一旦使用了 import 语句,在使用类时,就不必写出包的全名了。
可以使用 import 语句导入一个特定的类或者整个包。import 语句应该位于源文件的顶部 (但位于 package 语句的后面)。例如, 可以使用下面这条语句导人 java.util 包中所有的类。
import java.util .*;
然后, 就可以使用
LocalDate today = Local Date.now();
而无须在前面加上包前缀。还可以导人一个包中的特定类:
import java.time.LocalDate;
java.time.* 的语法比较简单,对代码的大小也没有任何负面影响。当然, 如果能够明确 地指出所导入的类, 将会使代码的读者更加准确地知道加载了哪些类。
提示:在Eclipse中,可以使用菜单选项Source->Organize Imports。Package语句,如import java.util.*,将会自动地扩展指定的导入列表,如:
import java.util .ArrayList;
import java.util .Date;
这是一个十分便捷的特性。
但是, 需要注意的是, 只能使用星号(*) 导入一个包, 而不能使用 import java.* 或 import java.*.* 导入以 java 为前缀的所有包。
在大多数情况下, 只导入所需的包, 并不必过多地理睬它们。 但在发生命名冲突的时 候,就不能不注意包的名字了。例如,java.util 和 java.sql 包都有日期( Date) 类。如果在程序中导入了这两个包:
import java.util .*;
import java.sql .*;
在程序使用 Date 类的时候, 就会出现一个编译错误:
Date today; // Error java.util.Date or java.sql .Date?
此时编译器无法确定程序使用的是哪一个 Date 类。可以采用增加一个特定的 import 语句来 解决这个问题:
import java.util .*;
import java.sql .*;
import java.util .Date;
如果这两个 Date 类都需要使用,又该怎么办呢? 答案是,在每个类名的前面加上完整的包名。
java.util .Date deadline = new java.util .Date();
java.sql .Date today = new java.sql .Date(...);
在包中定位类是编译器 ( compiler) 的工作。 类文件中的字节码肯定使用完整的包名来引用其他类。
4.7.2 静态导入
import 语句不仅可以导入类,还增加了导入静态方法和静态域的功能。
例如,如果在源文件的顶部, 添加一条指令:
import static java.lang.System.*;
就可以使用 System 类的静态方法和静态域,而不必加类名前缀:
out.println("Goodbye, World!"); // i.e., System.out
exit(0); //i.e., System.exit
另外,还可以导入特定的方法或域
import static java.lang.System.out;
实际上,是否有更多的程序员采用 System.out 或 System.exit 的简写形式,似乎是一件值 得怀疑的事情。这种编写形式不利于代码的清晰度。不过,
sqrt(pow(x, 2) + pow(y, 2))
看起来比
Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))
清晰得多。
4.7.3 将类放入包中
要想将一个类放入包中, 就必须将包的名字放在源文件的开头,包中定义类的代码之前。例如,程序清单 4-7中的文件 Employee.java 开头是这样的:
package com.horstmann.corejava;
public class Employee
{
...
}
如果没有在源文件中放置 package 语句, 这个源文件中的类就被放置在一个默认包 ( defaulf package ) 中。默认包是一个没有名字的包。在此之前,我们定义的所有类都在默认包中。
将包中的文件放到与完整的包名匹配的子目录中。例如,com.horstmann.corejava 包 中的所有源文件应该被放置在子目录 com/horstmann/corejava ( Windows 中 com\horstmann\ corejava) 中。编译器将类文件也放在相同的目录结构中。
程序清单 4-6 和程序清单 4-7中的程序分放在两个包中: PackageTest 类放置在默认包中; Employee 类放置在 com.horstmann.corejava 包中。因此, Employee.java 文件必须包含在子目 录 com/horstmann/ corejava 中。换句话说, 目录结构如下所示:
要想编译这个程序, 只需改变基目录,并运行命令
javac PackageTest.java
编译器就会自动地查找文件 com/horstmann/corejava/Employee.java 并进行编译。
下面看一个更加实际的例子。在这里不使用默认包, 而是将类分别放在不同的包中 ( com. horstmann.corejava 和 com.mycompany) 。
在这种情况下,仍然要从基目录编译和运行类,即包含 com 目录:
javac com/myconipany/Payrol1App.java
java com.mycompany.PayrollApp
需要注意,编译器对文件 (带有文件分隔符和扩展名 .java 的文件)进行操作。而 Java 解释器加载类(带有 . 分隔符 )。
提示:从下一章开始, 我们将对源代码使用包。这样一来,就可以为各章建立一个IDE 工程, 而不是各小节分别建立工程„
警告: 编译器在编译源文件的时候不检查目录结构。例如,假定有一个源文件开头有下列语句:
package com.mycompany;
即使这个源文件没有在子目录 com/mycompany 下, 也可以进行编译。如果它不依赖于其他包, 就不会出现编译错误。但是, 最终的程序将无法运行, 除非先将所有类文件移到正确的位置上。 如果包与目录不匹配, 虚拟机就找不到类。
4.7.4 包作用域
前面已经接触过访问修饰符 public 和 private。标记为 public 的部分可以被任意的类使 用;标记为 private 的部分只能被定义它们的类使用。如果没有指定 public 或 private , 这 个 部 分(类、方法或变量)可以被同一个包中的所有方法访问。
下面再仔细地看一下程序清单 4-2。在这个程序中, 没有将 Employee 类定义为公有类, 因此只有在同一个包(在此是默认包)中的其他类可以访问,例如 EmployeeTest。对于类来 说,这种默认是合乎情理的。但是, 对于变量来说就有些不适宜了, 因此变量必须显式地标 记为 private, 不然的话将默认为包可见。显然, 这样做会破坏封装性。问题主要出于人们经 常忘记键人关键字 private。 在 java.awt 包中的 Window 类就是一个典型的示例。java.awt 包 是 JDK 提供的部分源代码:
public class Window extends Container
{
String warningString;
...
}
请注意,这里的 wamingString 变量不是 private ! 这 意 味 着 java.awt 包中的所有类的方 法都可以访问该变量, 并将它设置为任意值(例如,“ Trust me!”)。实际上,只有 Window 类的方法访问它,因此应该将它设置为私有变量。我们猜测可能是程序员匆忙之中忘记键入 private 修饰符了(为防止程序员内疚, 我们没有说出他的名字, 感兴趣的话,可以查看一下 源代码)。
注释: 奇怪的是, 这个问题至今还没有得到糾正,即使我们在这本书的 9 个版本中已经指出了这一点。很明显,类库的实现者并没有读这本书。不仅如此,这个类还增加了一 些新域, 其中大约一半也不是私有的。
这真的会成为一个问题吗? 答案是:视具体情况而定。在默认情况下,包不是一个封闭 的实体。也就是说, 任何人都可以向包中添加更多的类。当然,有敌意或低水平的程序员很可能利用包的可见性添加一些具有修改变量功能的代码。例如,在 Java 程序设计语言的早期 版本中, 只需要将下列这条语句放在类文件的开头,就可以很容易地将其他类混入 java.awt 包中:
package java.awt;
然后 把结果类文件放置在类路径某处的 java/awt 子目录下,就可以访问 java.awt 包的内部了。使用这一 手段, 可以对警告框进行设置(如图 4-9 所示)。 从 1.2 版开始, JDK 的实现者修改了类加载器, 明确地禁止加载用户自定义的、 包名以“ java.” 开始的 类!当然,用户自定义的类无法从这种保护中受益。然 而,可以通过包密封 ( package sealing) 机制来解决将 各种包混杂在一起的问题。如果将一个包密封起来, 就 不能再向这个包添加类了。在第 9 章中,将介绍制作包含密封包的 JAR 文件的方法。
4.8 类路径
在前面已经看到,类存储在文件系统的子目录中。类的路径必须与包名匹配。
另外, 类文件也可以存储在 JAR(Java 归档)文件中。在一个 JAR 文件中, 可以包含 多个压缩形式的类文件和子目录, 这样既可以节省又可以改善性能。在程序中用到第三方 ( third-party ) 的库文件时,通常会给出一个或多个需要包含的 JAR 文件。JDK 也提供了许多 的 JAR 文件, 例如,在 jre/lib/rt.jar 中包含数千个类库文件。有关创建 JAR 文件的详细内容 将在第 9 章中讨论。
提示:JAR 文件使用 ZIP 格式组织文件和子目录。可以使用所有 ZIP 实用程序查看内部 的 rt.jar 以及其他的 JAR 文件。
为了使类能够被多个程序共享,需要做到下面几点:
1 ) 把类放到一个目录中, 例如 /home/user/classdir。需要注意, 这个目录是包树状结构 的基目录。如果希望将 com.horstmann.corejava.Employee 类添加到其中,这个 Employee.class 类文件就必须位于子目录 /home/user/classdir/com/horstmann/corejava 中。
2 ) 将 JAR 文件放在一个目录中,例如:/home/user/archives。
3 ) 设置类路径(classpath)。类路径是所有包含类文件的路径的集合。
在 UNIX 环境中, 类路径中的不同项目之间采用冒号 (:) 分隔:
/home/user/classdir:.:/home/use r/archives/archive.jar
而在 Windows 环境中,则以分号(;)分隔:
c:\classdir;.;c:\archi»es\archive.jar
在上述两种情况中, 句点(.)表示当前目录。
类路径包括:
•基目录 /home/user/classdiir或 c:\classes;
•当前目录 (.)
•JAR 文件 /home/user/archives/archive.jar或 c:\archives\archive.jar。
从 Java SE 6 开始,可以在 JAR 文件目录中指定通配符,如下:
/home/user/dassdir:.:/home/user/archives/'*'
或者
c:\classdir;.;c:\archives\*
但在 UNIX 中,禁止使用 * 以防止 shell 命令进一步扩展。
在 archives 目录中的所有 JAR 文件(但不包括 .class 文件)都包含在类路径中。
由于运行时库文件( rt.jar 和在 jre/lib 与 jre/lib/ext 目录下的一些其他的 JAR 文件) 会被 自动地搜索, 所以不必将它们显式地列在类路径中。
警告:javac 编译器总是在当前的目录中查找文件, 但 Java 虚拟机仅在类路径中有目录的时候才查看当前目录。如果没有设置类路径, 那也并不会产生什么问题, 默认的 类 路 径 包 含 目 录 . 然 而 如 果 设 置 了 类 路 径 却 忘 记 了 包 含 目 录, 则 程 序 仍 然 可 以通过编译, 但不能运行。
类路径所列出的目录和归档文件是搜寻类的起始点。下面看一个类路径示例:
/home/user/classdir:.:/home/user/archives/archive.jar
假定虚拟机要搜寻 com.horstmann.corejava.Employee 类文件。它首先要查看存储在 jre/ lib 和jre/lib/ext 目录下的归档文件中所存放的系统类文件。显然,在那里找不到相应的类文件,然后再查看类路径。然后查找以下文件:
•/home/user/classdir/com/horstmann/corejava/Employee.class
•com/horstmann/corejava/Employee.class 从当前目录开始
•com/horstmann/corejava/Employee.class inside /home/user/archives/archive.jar
编译器定位文件要比虚拟机复杂得多。如果引用了一个类,而没有指出这个类所在的包, 那么编译器将首先查找包含这个类的包,并询查所有的 import 指令,确定其中是否包含了被引用的类。例如, 假定源文件包含指令:
import java.util.*;
import com.horstmann.corejava.*;
并且源代码引用了 Employee 类。 编译器将试图查找 java.lang.Employee (因为java.lang 包被 默认导入)、java.util.Employee、 com.horstmann.corejava.Employee 和当前包中的 Employee。对这个类路径的所有位置中所列出的每一个类进行逐一查看。如果找到了一个以上的类, 就 会产生编译错误(因为类必须是唯一的,而 import 语句的次序却无关紧要)。
编译器的任务不止这些,它还要查看源文件( Source files) 是否比类文件新。如果是这 样的话,那么源文件就会被自动地重新编译。在前面已经知道,仅可以导入其他包中的公有 类。一个源文件只能包含一个公有类,并且文件名必须与公有类匹配。因此, 编译器很容易定位公有类所在的源文件。当然, 也可以从当前包中导入非公有类。这些类有可能定义在与 类名不同的源文件中。如果从当前包中导入一个类, 编译器就要搜索当前包中的所有源文件, 以便确定哪个源文件定义了这个类。
4.8.1 设置类路径
最好采用 -classpath (或 -cp) 选项指定类路径:
java -classpath /home/user/dassdir: .:/home/user/archives/archive.jar MyProg
或者
java -classpath c:\classdir; .;c:\archives\archive.jar MyProg
整个指令应该书写在一行中。将这样一个长的命令行放在一个 shell 脚本或一个批处理文件中是一个不错的主意。
利用 -dasspath 选项设置类路径是首选的方法, 也可以通过设置 CLASSPATH 环境变量完成这个操作。其详细情况依赖于所使用的 shell。在 Bourne Again shell ( bash) 中, 命令格 式如下:
export CLASSPATH=/home/user/classdir:.:/ home/user/archives/archive.jar
在 Windows shell, 命令格式如下:
set CLASSPATH=c:\classdir;.;c:\archives\archive.jar
直到退出 shell 为止,类路径设置均有效。
警告: 有人建议将 CLASSPATH 环境变量设置为永久不变的值。 总的来说这是一个很糟糕的主意。人们有可能会忘记全局设置, 因此, 当使用的类没有正确地加载进来时,会 感到很奇怪。一个应该受到谴责的示例是 Windows 中 Apple 的 QuickTime 安装程序。它 进行了全局设置, CLASSPATH 指向一个所需要的 JAR 文件, 但并没有在类路径上包含当前路径。 因此, 当程序编译后却不能运行时, 众多的 Java 程序员花费了很多精力去解决这个问题。
警告: 有人建议绕开类路径, 将所有的文件放在 jre/lib/ext 路径。这是一个极坏的主意, 其原因主要有两个: 当手工地加载其他的类文件时, 如果将它们存放在扩展路径上, 则 不能正常地工作(有关类加载器的详细信息, 请参看卷 2 第 9 章)。此外, 程序员经常会 忘记 3 个月前所存放文件的位置。 当类加载器忽略了曾经仔细设计的类路径时, 程序员 会毫无头绪地在头文件中查找。事实上,加载的是扩展路径上已长时间遗忘的类。
4.9 文档注释
JDK 包含一个很有用的工具,叫做javadoc, 它可以由源文件生成一个 HTML 文档。事 实上, 在第 3 章讲述的联机 API 文档就是通过对标准 Java 类库的源代码运行 javadoc 生 成的。
如果在源代码中添加以专用的定界符 /**开始的注释, 那么可以很容易地生成一个看上 去具有专业水准的文档。这是一种很好的方式,因为这种方式可以将代码与注释保存在一个 地方。如果将文档存入一个独立的文件中, 就有可能会随着时间的推移, 出现代码和注释不 一致的问题。然而,由于文档注释与源代码在同一个文件中,在修改源代码的同时, 重新运 行 javadoc 就可以轻而易举地保持两者的一致性。
4.9.1 注释的插入
javadoc 实用程序(utility) 从下面几个特性中抽取信息:
•包
•公有类与接口
•公有的和受保护的构造器及方法
•公有的和受保护的域
在第 5 章中将介绍受保护特性, 在第 6 章将介绍接口。
应该为上面几部分编写注释、 注释应该放置在所描述特性的前面。注释以 /**开始,并 以 */ 结束。 每个 /** . . . */ 文档注释在标记之后紧跟着*格式文本( free-form text )。标记由 @开始, 如@author 或@param。
*格式文本的第一句应该是一个概要性的句子。javadoc 实用程序自动地将这些句子抽取出来形成概要页。
在*格式文本中,可以使用 HTML 修饰符, 例如,用于强调的<em>...</em>、用于着重强调的<strong>...</strong>以及包含图像的<img...>等。不过,一定不要使用<h1>或<hr>,因为它们会与文档的格式产生冲突。若要键入等宽代码、需使用{@code...}而不是<code>...</code>---这样一来,就不用操心对代码中的<字符转义了。
注释: 如果文档中有到其他文件的链接, 例如, 图像文件(用户界面的组件的图表或图像等), 就应该将这些文件放到子目录 doc-files 中。javadoc 实用程序将从源目录拷贝这 些目录及其中的文件到文档目录中。在链接中需要使用 doc-files 目录, 例如:<img src="doc-files/uml.png" alt="UML diagram"> 。
4.9.2 类注释
类注释必须放在 import 语句之后,类定义之前。
下面是一个类注释的例子:
/**
* A {code Card} object represents a playing card , such
* as "Queen of Hearts". A card has a suit (Diamond , Heart ,
* Spade or Club) and a value (1 = Ace , 2 . . . 10, 11 = Jack,
* 12 = Queen , 13 = King)
*/
public class Card
{
...
}
注释:没有必要在每一行的开始用星号 *, 例如, 以下注释同样是合法的:
/**
A <code>Card< /code> object represents a playing card , such
as "Queen of Hearts". A card has a suit (Diamond, Heart ,
Spade or Club) and a value (1 = Ace , 2 . . . 10, 11 = jack,
12 = Queen, 13 = King) .
*/
然而, 大部分 IDE 提供了自动添加星号 *, 并且当注释行改变时, 自动重新排列这 些星号的功能。
4.9.3 方法注释
每一个方法注释必须放在所描述的方法之前。除了通用标记之外, 还可以使用下面的标记:
• @param 变量描述
这个标记将对当前方法的“ param” (参数)部分添加一个条目。这个描述可以占据多行, 并可以使用 HTML 标记。一个方法的所有 @param 标记必须放在一起。
• @return 描述
这个标记将对当前方法添加“ return” (返回)部分。这个描述可以跨越多行, 并可以 使用 HTML 标记。
• throws 类描述
这个标记将添加一个注释, 用于表示这个方法有可能抛出异常。 有关异常的详细内容 将在第 10 章中讨论。
下面是一个方法注释的示例:
/**
* Raises the salary of an employee.
* @param byPercent the percentage by which to raise the salary (e.g. 10 means 10%)
* @return the amount of the raise
*/
public double raiseSal ary(double byPercent)
{
double raise = salary * byPercent / 100;
salary += raise;
return raise;
}
4.9.3 域注释
只需要对公有域(通常指的是静态常量)建立文档。 例如,
/**
* The "Hearts" card suit
*/
public static final int HEARTS = 1;
4.9.5 通用注释
下面的标记可以用在类文档的注释中。
•@author 姓名
这个标记将产生一个 ** author" (作者)条目。可以使用多个 @author 标记,每个 @ author 标记对应一个作者。
•@version 文本
这个标记将产生一个“ version”(版本)条目。这里的文本可以是对当前版本的任何描 述。
下面的标记可以用于所有的文档注释中。
•@since 文本
这个标记将产生一个“ since” (始于)条目。这里的 text 可以是对引入特性的版本描述。例如, @since version 1.7.10
•@deprecated 文本
这个标记将对类、 方法或变量添加一个不再使用的注释。文本中给出了取代的建议。 例如,
@deprecated Use setVIsible(true)
instead
通过 @see 和@link标记,可以使用超级链接, 链接到 javadoc 文档的相关部分或外 部文档。
•@see 引用
这个标记将在“ see also” 部分增加一个超级链接。它可以用于类中,也可以用于方法中。这里的引用可以选择下列情形之一:
package.class#feature label
<a href="...">label</a>
"text"
第一种情况是最常见的。只要提供类、 方法或变量的名字,javadoc 就在文档中插入 一个超链接。例如,
@see com.horstmann.corejava.Employee#raiseSalary(double)
建立一个链接到 com.horstmann.corejava.Employee 类的 raiseSalary(double) 方法的超 链接。 可以省略包名, 甚至把包名和类名都省去,此时,链接将定位于当前包或当前类。
需要注意,一定要使用井号(#,) 而不要使用句号(.)分隔类名与方法名,或类 名与变量名。Java 编译器本身可以熟练地断定句点在分隔包、 子包、 类、内部类与方 法和变量时的不同含义。但是 javadoc 实用程序就没有这么聪明了,因此必须对它提 供帮助。
如果 @see 标记后面有一个 < 字符,就需要指定一个超链接。可以超链接到任何 URL。例如:
@see <a href="www.horstmann.com/corejava. html">The Core ]ava home page</a>
在上述各种情况下, 都可以指定一个可选的标签( label ) 作为链接锚(link anchor)。 如果省略了 label , 用户看到的锚的名称就是目标代码名或 URL。
如果@see 标记后面有一个双引号(")字符,文本就会显示在 “ see also” 部分。 例如,
@see "Core Java 2 volume 2"
可以为一个特性添加多个 @see 标记,但必须将它们放在一起。
•如果愿意的话, 还可以在注释中的任何位置放置指向其他类或方法的超级链接, 以及 插入一个专用的标记, 例如,
{@link package,class#feature label ]
这里的特性描述规则与@see 标记规则一样。
4.9.6 包与概述注释
可以直接将类、 方法和变量的注释放置在 Java 源文件中, 只要用 /** . . . */ 文档注释界 定就可以了。但是, 要想产生包注释,就需要在每一个包目录中添加一个单独的文件。可以 有如下两个选择:
1 ) 提供一个以 package.html 命名的 HTML 文件。在标记<body>...</body>之间的所有文本都会被抽取出来。
2 ) 提供一个以 package-info.java 命名的 Java 文件。这个文件必须包含一个初始的以 /** 和 */ 界定的 Javadoc 注释, 跟随在一个包语句之后。它不应该包含更多的代码或注释。
还可以为所有的源文件提供一个概述性的注释。这个注释将被放置在一个名为 overview. html 的文件中,这个文件位于包含所有源文件的父目录中。标记<body>...</body>之间的所有文本将被抽取出来。当用户从导航栏中选择“ Overview” 时,就会显示出这些注释内容。
4.9.7 注释的抽取
这里,假设 HTML 文件将被存放在目录 docDirectory 下。执行以下步骤:
1 ) 切换到包含想要生成文档的源文件目录。 如果有嵌套的包要生成文档, 例如 com. horstmann.corejava, 就必须切换到包含子目录 com 的目录(如果存在 overview.html 文件的 话, 这也是它的所在目录)。
2 ) 如果是一个包,应该运行命令:
javadoc -d docDirectory nameOfPackage
或对于多个包生成文档,运行:
javadoc -d docDirectory nameOfPackage\ nameOfPackage . . .
如果文件在默认包中, 就应该运行:
javadoc -d docDirectory *.java
如果省略了 -d docDirectory 选项, 那 HTML 文件就会被提取到当前目录下。这样有可能 会带来混乱,因此不提倡这种做法。
可以使用多种形式的命令行选项对 javadoc 程序进行调整。例如, 可以使用 -author 和 -version 选项在文档中包含@author 和@version 标记(默认情况下,这些标记会被省 略)。另一个很有用的选项是 -link, 用来为标准类添加超链接。例如, 如果使用命令
javadoc -link http://docs.oracle.eom/:javase/8/docs/api *.java
那么,所有的标准类库类都会自动地链接到 Oracle 网站的文档。
如果使用 -linksource 选项,则每个源文件被转换为 HTML (不对代码着色,但包含行编 号,) 。并且每个类和方法名将转变为指向源代码的超链接。 有关其他的选项, 请查阅 javadoc 实用程序的联机文档,http://docs.orade.com/javase /8/ docs/guides/javadoc。
注释: 如果需要进一步的定制,例如, 生成非 HTML 格式的文档, 可以提供自定义的 doclet, 以便生成想要的任何输出形式。显然, 这是一种特殊的需求, 有关细节内容请查阅 http://docs.oracle.com/javase/8/docs/guides/javadoc/doclet/overview.html 的联机文档。
4.10 类设计技巧
我们不会面面俱到, 也不希望过于沉闷, 所以这一章结束之前, 简单地介绍几点技巧。 应用这些技巧可以使得设计出来的类更具有 OOP 的专业水准。
1. 一定要保证数据私有
这是最重要的;绝对不要破坏封装性。有时候, 需要编写一个访问器方法或更改器方法, 但是最好还是保持实例域的私有性。很多惨痛的经验告诉我们, 数据的表示形式很可能会改 变, 但它们的使用方式却不会经常发生变化。当数据保持私有时, 它们的表示形式的变化不 会对类的使用者产生影响, 即使出现 bug 也易于检测。
2. 一定要对数据初始化
Java 不对局部变量进行初始化, 但是会对对象的实例域进行初始化。最好不要依赖于系 统的默认值, 而是应该显式地初始化所有的数据, 具体的初始化方式可以是提供默认值, 也 可以是在所有构造器中设置默认值。
3. 不要在类中使用过多的基本类型
就是说,用其他的类代替多个相关的基本类型的使用。这样会使类更加易于理解且易于 修改。例如, 用一个称为 Address 的新的类替换一个 Customer 类中以下的实例域:
private String street;
private String city;
private String state;
private int zip;
这样, 可以很容易处理地址的变化, 例如, 需要增加对国际地址的处理。
4. 不是所有的域都需要独立的域访问器和域更改器
或许, 需要获得或设置雇员的薪金。 而一旦构造了雇员对象, 就应该禁止更改雇用日期,并且在对象中,常常包含一些不希望别人获得或设置的实例域, 例如, 在 Address 类中, 存放州缩写的数组。
5.将职责过多的类进行分解
这样说似乎有点含糊不清, 究竟多少算是“ 过多” ? 每个人的看法不同。但是,如果明显地可以将一个复杂的类分解成两个更为简单的类,就应该将其分解(但另一方面,也不要 走极端。设计 10 个类,每个类只有一个方法,显然有些矫枉过正了)。
6. 类名和方法名要能够体现它们的职责
与变量应该有一个能够反映其含义的名字一样, 类也应该如此(在标准类库中, 也存在 着一些含义不明确的例子,如:Date 类实际上是一个用于描述时间的类)。 命名类名的良好习惯是采用一个名词(Order )、 前面有形容词修饰的名词( RushOrder ) 或动名词(有“ -ing” 后缀)修饰名词(例如, BillingAddress)。对于方法来说,习惯是访问器方法用小写 get 开头 ( getSalary ), 更改器方法用小写的 set 开头(setSalary )
7.优先使用不可变的类
LocalDate 类以及 java.time 包中的其他类是不可变的---没有方法能修改对象的状态。 类似 plusDays 的方法并不是更改对象,而是返回状态已修改的新对象。
更改对象的问题在于, 如果多个线程试图同时更新一个对象,就会发生并发更改。其结 果是不可预料的。如果类是不可变的,就可以安全地在多个线程间共享其对象。 因此, 要尽可能让类是不可变的, 这是一个很好的想法。对于表示值的类, 如一个字符 串或一个时间点,这尤其容易。计算会生成新值, 而不是更新原来的值。
当然,并不是所有类都应当是不可变的。如果员工加薪时让 raiseSalary 方法返回一个新 的 Employee 对象, 这会很奇怪。
本章介绍了 Java 这种面向对象语言的有关对象和类的基础知识。 为了真正做到面向对 象,程序设计语言还必须支持继承和多态。Java 提供了对这些特性的支持, 具体内容将在下 一章中介绍。