0%

javag更多的类谜题

Java 谜题 Java 谜题 7——更多的类谜题

谜题 66:一件私事

在下面的程序中,子类的一个域具有与超类的一个域相同的名字。那么,这个程序会打印出

什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Base { 

public String className = "Base";

}

class Derived extends Base {

private String className = "Derived";

}

public class PrivateMatter {

public static void main(String[ ] args) {

System.out.println(new Derived().className);

}

}

对该程序的表面分析可能会认为它应该打印 Derived,因为这正是存储在每一个

Derived 实例的 className 域中的内容。 更深入一点的分析会认为 Derived 类不能编译,因为 Derived 中的 className

变量具有比 Base 中的 className 变量更具限制性的访问权限。

如果你尝试着编译该程序,就会发现这种分析也不正确。该程序确实不能编译,

但是错误却出在 PrivateMatter 中。

如果 className 是一个实例方法,而不是一个实例域,那么 Derived.className()

将覆写 Base.className(),而这样的程序是非法的。一个覆写方法的访问修饰

符所提供的访问权限与被覆写方法的访问修饰符所提供的访问权限相比,至少要

一样多[JLS 8.4.8.3]。

因为 className 是一个域,所以 Derived.className 隐藏(hide)了

Base.className,而不是覆盖了它[JLS 8.3]。对一个域来说,当它要隐藏另一

个域时,如果隐藏域的访问修饰符提供的访问权限比被隐藏域的少,尽管这么做

不可取的,但是它确实是合法的。事实上,对于隐藏域来说,如果它具有与被隐

藏域完全无关的类型,也是合法的:即使 Derived.className 是

GregorianCalendar 类型的,Derived 类也是合法的。

在我们的程序中的编译错误出现在 PrivateMatter 类试图访问

Derived.className 的时候。尽管 Base 有一个公共域 className,但是这个域没

有被继承到 Derived 类中,因为它被 Derived.className 隐藏了。在 Derived

类内部,域名 className 引用的是私有域 Derived.className。因为这个域被声

明为是 private 的,所以它对于 PrivateMatter 来说是不可访问的。因此,编译

器产生了类似下面这样的一条错误信息:

PrivateMatter.java:11: className has private access in Derived

System.out.println(new Derived().className);

^

请注意,尽管在 Derived 实例中的公共域 Base.className 被隐藏了,但是我们

还是可以通过将 Derived 实例转型为 Base 来访问到它。下面版本的

PrivateMatter 就可以打印出 Base:

1
2
3
4
5
6
7
8
9
public class PrivateMatter { 

public static void main(String[] args) {

System.out.println(((Base)new Derived()).className);

}

}

这说明了覆写与隐藏之间的一个非常大的区别。一旦一个方法在子类中被覆写,

你就不能在子类的实例上调用它了(除了在子类内部,通过使用 super 关键字来

方法)。然而,你可以通过将子类实例转型为某个超类类型来访问到被隐藏的域,

在这个超类中该域未被隐藏。 如果你想让这个程序打印 Derived,也就是说,你想展示覆写行为,那么你可以

用公共方法来替代公共域。在任何情况下,这都是一个好主意,因为它提供了更

好的封装[EJ Item 19]。下面的程序版本就使用了这项技术,并且能够打印出我

们所期望的 Derived:

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
class Base { 

public String getClassName() {

return "Base";

}

}

class Derived extends Base {

public String getClassName() {

return "Derived";

}

}

public class PrivateMatter {

public static void main(String[] args) {

System.out.println(new Derived().getClassName());

}

}

请注意,我们将 Derived 类中的 getClassName 方法声明成了 public 的,尽管在

最初的程序中与其相对应的域是私有的。就像前面提到的那样,覆写方法的访问

修饰符与它要覆写的方法的访问修饰符相比,所具有的限制性不能有任何降低。

本谜题的教训是隐藏通常都不是一个好主意。Java 语言允许你去隐藏变量、嵌

套类型,甚至是静态方法(就像在谜题 48 所展示的那样),但是你不能认为你

就应该去隐藏。隐藏的问题在于它将导致读者头脑的混乱。你正在使用一个被隐

藏实体,或者是正在使用一个执行了隐藏的实体吗?要避免这类混乱,只需避免

隐藏。

如果一个类要隐藏一个域,而用来隐藏该域的域具有的可访问性比被隐藏域更具

限制性,就像我们最初的程序那样,那么这就违反了包容性(subsumption)原

则,即大家所熟知的 Liskov 置换原则(Liskov Substitution Principle)

[Liskov87]。这项原则叙述道,你能够对基类所作的任何事,都同样能够作用于

其子类。包容性是面向对象编程的自然心理模型的一个不可分割的部分。无论何

时,只要违反了这项原则,就会对程序的理解造成困难。还有其它数种用另一个

域来隐藏某个域的方法也会违反包容性:例如,两个域具有不同的类型;一个域

是静态的而另一个域不是;一个域是 final 的而另一个域不是;一个域是常量而

另一个域不是;以及两个域都是常量但是它们具有不同的值。 对于语言设计者而言,应该考虑消除隐藏的可能性:例如,使所有的域都隐含地

是私有的。如果这样做显得过于严苛,那么至少应该考虑对隐藏进行限制,以使

其遵守包容性原则。

总之,当你在声明一个域、一个静态方法或一个嵌套类型时,如果其名字与基类

中相对应的某个可访问的域、方法或类型相同,就会发生隐藏。隐藏是容易产生

混乱的:违反包容性的隐藏域在某种意义上是特别有害的。更一般地讲,除了覆

写之外,要避免名字重用。

谜题 67:对字符串上瘾

一个名字可以被用来引用位于不同包内的多个类。下面的程序就是在探究当你重用了一个平

台类的名字时,会发生什么。你认为它会做些什么呢?尽管这个程序属于那种让你通常一看

到就会感到尴尬的程序,但是你还是应该继续下去,把门锁上,把百叶窗拉上,然后试试看:

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
public class StrungOut { 

public static void main(String[] args) {

String s = new String("Hello world");

System.out.println(s);

}

}

class String {

private final java.lang.String s;

public String(java.lang.String s) {

this.s = s;

}

public java.lang.String toString() {

return s;

}

}

如果说这个程序有点让人讨厌的话,它看起来还是相当简单的。在未命名包中的

String 类就是一个 java.lang.String 实例的包装器,看起来该程序应该打印

Hello world。如果你尝试着运行该程序,你会发现你运行不了它,VM 将弹出了

一个像下面这样的错误消息:

Exception in thread “main” java.lang.NoSuchMethodError: main

但是它肯定是一个 main 方法的:它就白纸黑字地写在那里。为什么 VM 找不到它

呢?

VM 不能找到 main 方法是因为它并不在那里。尽管 StrungOut 有一个被命名为

main 的方法,但是它却具有错误的签名。一个 main 方法必须接受一个单一的字

符串数组参数[JVMS 5.2]。VM 努力要告诉我们的是 StrungOut.main 接受的是由我们的 String 类所构成的数组,它无论如何都与 java.lang.String 没有任何关

系。

如果你确实需要编写自己的字符串类,看在老天爷的份上,千万不要称其为

String。要避免重用平台类的名字,并且千万不要重用 java.lang 中的类名,因

为这些名字会被各处的程序自动加载。程序员习惯于看到这些名字以无限定的形

式出现,并且会很自然地认为这些名字引用的是我们所熟知的 java.lang 中的

类。如果你重用了这些名字的某一个,那么当这个名字在其自己的包内被使用时,

该名字的无限定形式将会引用到新的定义上。

要订正该程序,只需为这个非标准的字符串类挑选一个合理的名字即可。该程序

下面的这个版本很明显是正确的,而且它比最初的版本要更易于理解。它将打印

出如你所期望的 Hello World:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class StrungOut { 

public static void main(String[ ] args) {

MyString s = new MyString("Hello world");

System.out.println(s);

}

}

class MyString {

private final java.lang.String s;

public MyString(java.lang.String s) { this.s = s;}

public java.lang.String toString() { return s;}

}

宽泛地讲,本谜题的教训就是要避免重用类名,尤其是 Java 平台类的类名。千

万不要重用 java.lang 包内的类名,相同的教训也适用于类库的设计者。Java

平台的设计者已经在这个问题上栽过数次了,著名的例子有 java.sql.Date,它

与 java.util.Date 和 org.omg.CORBA.Object 相冲突。与在本章中的许多其他谜

题一样,这个教训是有关你在除了覆写之外的其他情况应该避免名字重用这一原

则的一个具体实例。对平台实现者来说,其教训是诊断信息应该清晰地解释失败

的原因。VM 应该可以很容易地将没有任何具有正确签名的 main 方法的情况与根

本就没有任何 main 方法的情况区分开。

谜题 68:灰色的阴影

下面的程序在相同的范围内具有两个名字相同的声明,并且没有任何明显的方式可以在它们

二者之间做选择。这个程序会打印 Black 吗?它会打印 White 吗?甚至,它是合法的吗?

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
public class ShadesOfGray { 

public static void main(String[] args){

System.out.println(X.Y.Z);

} }

class X {

static class Y {

static String Z = "Black";

}

static C Y = new C();

}

class C {

String Z = "White";

}

没有任何显而易见的方法可以确定该程序应该打印 Black 还是 White。编译器通

常会拒绝模棱两可的程序,而这个程序看起来肯定是模棱两可的。因此,它似乎

应该是非法的。如果你试着运行它,就会发现它是合法的,并且会打印出 White。

你怎样才能事先了解这一切呢?

可以证明,在这样的上下文环境中,有一条规则决定着程序的行为,即当一个变

量和一个类型具有相同的名字,并且它们位于相同的作用域时,变量名具有优先

权[JLS 6.5.2]。变量名将遮掩(obscure)类型名[JLS 6.3.2]。相似地,变量

名和类型名可以遮掩包名。这条规则真的是相当地晦涩,任何依赖于它的程序都

极有可能使它的读者晕头转向。

幸运的是,遵守标准的 Java 命名习惯的程序继续从来都不会遇上这个问题。类

应该以一个大写字母开头,以 MixedCase 的形式书写;变量应该以一个小写字母

开头,以 mixedCase 的形式书写;而常量应该以一个大写字母开头,以 ALL_CAPS

的方式书写。单个的大写字母只能用于类型参数,就像在泛型接口 Map<K,V>中

那样。包名应该以 lower.case 的方式命名[JLS 6.8]。

为了避免常量名与类名的冲突,在类名中应该将首字母缩拼词当作普通的词处理

[EJ Item 38]。例如,一个表示全局唯一标识符的类应该被命名为 Uuid,而不

是 UUID,尽管其首字母缩拼词通常被写为 UUID。(Java 平台库就违反了这项建

议,因为它具有 UUID、URL 和 URI 这样的类名。)为了避免变量名与包名的冲突,

请不要使用顶层的包名或领域名作为变量的名字,特别是不要将一个变量命名为

com、org、net、edu、java 或 javax。

要想移除 ShadesOfGray 这个程序中的所有不明确性,只需以遵守命名习惯的方

式对其重写即可。很明显,下面的程序将打印 Black。作为一种附加的好处,当

你大声朗读这个程序时,听起来还最初的那个程序是完全一样的。

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
public class ShadesOfGray { 

public static void main(String[ ] args){

System.out.println(Ex.Why.Z);

} }

class Ex {

static class Why {

static String Z = "Black";

}

static See y = new See();

}

class See {

String Z = "White";

}

总之,应该遵守标准的命名习惯以避免不同的命名空间之间的冲突,还有一个原

因就是如果你违反这些习惯,那么你的程序将让人难以辨认。同样,为了避免变

量名与通用的顶层包名相冲突,请使用 MixedCase 风格的类名,即使其名字是首

字母缩拼词也应如此。通过遵守这些规则,你就可以确保你的程序永远不会遮掩

类名或包名。再次说明一下,这里列举的仍然是你应该在覆写之外的情况中避免

名字重用的一个实例。对语言设计者来说,应该考虑去消除遮掩的可能性。C#

是通过将域和嵌套类置于相同的命名空间来实现这一点的。

谜题 69:黑色的渐隐

假设你不能修改前一个谜题(谜题 68)中的 X 和 C 这两个类。你能否编写一个类,其 main

方法将读取 X.Y 类中的 Z 域的值,然后打印它。注意,不能使用反射。

本谜题初看起来是不可能实现的。毕竟,X.Y 类被具有相同名字的一个域给遮掩

了,因此对其命名的尝试将引用到该域上。

事实上,我们是可以引用到一个被遮掩的类型名的,其技巧就是在某一种特殊的

语法上下文环境中使用该名字,在该语法上下文环境中允许出现一个类型但是不

允许出现一个变量。在转型表达式的括号中间的部分就是这样一种上下文环境。

下面的程序通过使用这种技术解决了这个谜题,并且将打印出我们所期望的

Black:

1
2
3
4
5
6
7
8
9
public class FadeToBlack { 

public static void main(String[] args){

System.out.println(((X.Y)null).Z);

}

}

请注意,我们是用一个具有 X.Y 类型的表达式来访问 X.Y 类的 Z 域的。就像我们

在谜题 48 和 54 中所看到的,用一个表达式而不是类型名来访问一个静态成员是

合法的,但却是一种有问题的用法。 不借助这种有问题的用法,而是通过在一个类声明的 extends 子句中使用一个被

遮掩的类这种方式,你也可以解决本谜题。因为基类总是一种类型,出现在

extends 子句中的名字从来都不会被解析为变量名。下面的程序就展示了这项技

术,它也会打印出 Black:

1
2
3
4
5
6
7
8
9
10
11
public class FadeToBlack { 

static class Xy extends X.Y{ }

public static void main(String[ ] args){

System.out.println(Xy.Z);

}

}

如果你使用的 5.0 或更新的版本,那么通过在一个类型变量声明的 extends 子句

中使用 X.Y 这种方式,你也可以解决本谜题:

1
2
3
4
5
6
7
8
9
public class FadeToBlack { 

public static <T extends X.Y> void main(String[] args){

System.out.println(T.Z);

}

}

总之,要解决由类型被变量遮掩而引发的问题,需要按照标准的命名习惯来重命

名类型和变量,就像在谜题 68 中所讨论的那样。如果做不到这一点,那么你应

该在只允许类型名的上下文环境中使用被遮掩的类型名。幸运的话,你将永远不

需要凭借这种对程序的变形来解决问题,因为大多数的类库作者都很明智,他们

都避免了必需使用这种变形的有问题的用法。然而,如果你确实发现自己身处这

种境地,那么你最好是要了解这个问题需要解决。

谜题 70:一揽子交易

下面这个程序设计在不同的包中的两个类的交互,main 方法位于 hack.TypeIt 中。那么,这

个程序会打印什么呢?

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
31
32
33
34
35
36
37
38
39
40
41
package hack; 

import click.CodeTalk;

public class TypeIt {

private static class ClickIt extends CodeTalk {

void printMessage() {

System.out.println("Hack");

}

}

public static void main(String[ ] args) {

ClickIt clickit = new ClickIt();

clickit.doIt(); }

}

package click;

public class CodeTalk {

public void doIt() {

printMessage();

}

void printMessage() {

System.out.println("Click");

}

}

本谜题看起来很直观。Hack.TypeIt 的 main 方法对 TypeIt.ClickIt 类实例化,

然后调用其 doIt 方法,该方法是从 CodeTalk 继承而来。接着,该方法调用

printMessage 方法,它在 TypeIt.ClickIt 中被声明为打印 Hack。然而,如果你

运行该程序,它打印的将是 Click。怎么会这样呢?

上面的分析做出了一个不正确的假设,即 Hack.TypeIt.ClickIt.printMessage

方法覆写了 click.CodeTalk.printMessage 方法。一个包内私有的方法不能被位

于另一个包中的某个方法直接覆写[JLS 8.4.8]。在程序中的这两个 twoMessage

方法是无关的,它们仅仅是具有相同的名字而已。当程序在 hack 包内调用

printMessage 方法时,运行的是 hack.TypeIt.ClickIt.printMessage 方法。这

个方法将打印 Click,这也就解释了我们所观察到的行为。

如果你想让 hack.TypeIt.ClickIt 中的 printMessage 方法覆写在

Click.CodeTalk 中的该方法,那么你必须在 Click.CodeTalk 中的该方法声明之

前添加 protected 或 public 修饰符。要使该程序能够编译,你还必须在

hack.TypeIt.ClickIt 的覆写声明的前面添加一个修饰符,该修饰符与你在

Click.CodeTalk 的 printMessage 方法上放置的修饰符相比,所具备的限制性不

能更多[JLS 8.4.8.3]。换句话说,两个 printMessage 方法可以都被声明为是

public 的,也可以都被声明为是 protected 的,或者,超类中的方法被声明为

是 protected,而子类中的方法被声明为是 public 的。无论你执行了上述三种

修改中的任何一种,该程序都将打印 Hack,从而表明确实发生了覆写。

总之,包内私有的方法不能直接被包外的方法声明所覆写。尽管包内私有的访问

权限和覆写结合到一起会导致某种混乱,但是 Java 当前的行为是允许使用包的,

以支持比单个的类更大的抽象封装。包内私有的方法是它们所属包的实现细节,

在包外重用它们的名字是不会对包内产生任何影响的。

谜题 71:进口税

在 5.0 版中,Java 平台引入了大量的可以使操作数组变得更加容易的工具。下面这个谜题使用了变量参数、自动包装、静态导入(请查看 http://java.sun.com/j2se/5.0/docs/guide/language

[Java-5.0])以及便捷方法 Arrays.toString(请查看谜题 60)。那么,这个程序会打印什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import static java.util.Arrays.toString; 

class ImportDuty {

public static void main(String[ ] args) {

printArgs(1, 2, 3, 4, 5);

}

static void printArgs(Object... args) {

System.out.println(toString(args));

}

}

你可能会期望该程序打印[1,2,3,4,5],实际上它确实会这么做,只要它能编译。

令人沮丧的是,看起来编译器找不到恰当的 toString 方法:

ImportDuty.java:9:Object.toString()can’t be applied to(Object[])

System.out.println(toString(args));

^

是不是编译器的理解力太差了?为什么它会尝试着去应用 Object.toString()

呢?它与调用参数列表并不匹配,而 Arrays.toString(Object[ ])却可以完全

匹配。

编译器在选择在运行期将被调用的方法时,所作的第一件事就是在肯定能找到该

方法的范围内挑选[JLS 15.12.1]。编译器将在包含了具有恰当名字的方法的最

小闭合范围内进行挑选,在我们的程序中,这个范围就是 ImportDuty 类,它包

含了从 Object 继承而来的 toString 方法。在这个范围中没有任何可以应用于

toString(args)调用的方法,因此编译器必须拒绝该程序。

换句话说,我们想要的 toString 方法没有在调用点所处的范围内。导入的

toString 方法被 ImportDuty 从 Object 那里继承而来的具有相同名字的方法所

遮蔽(shade)了[JLS 6.3.1]。遮蔽与遮掩(谜题 68)非常相像,二者的关键

区别是一个声明只能遮蔽类型相同的另一个声明:一个类型声明可以遮蔽另一个

类型声明,一个变量声明可以遮蔽另一个变量声明,一个方法声明可以遮蔽另一

个方法声明。与其形成对照的是,变量声明可以遮掩类型和包声明,而类型声明

也可以遮掩包声明。

当一个声明遮蔽了另一个声明时,简单名将引用到遮蔽声明中的实体。在本例中,

toString 引用的是从 Object 继承而来的 toString 方法。简单地说,本身就属

于某个范围的成员在该范围内与静态导入相比具有优先权。这导致的后果之一就

是与 Object 的方法具有相同名字的静态方法不能通过静态导入工具而得到使

用。

既然你不能对 Arrays.toString 使用静态导入,那么你就应该用一个普通的导入

声明来代替。下面就是 Arrays.toString 应该被正确使用的方式:

1
2
3
4
5
6
7
8
9
10
11
import java.util.Arrays; 

class ImportDuty {

static void printArgs(Object... args) {

System.out.println(Arrays.toString(args));

}

}

如果你特别强烈地想避免显式地限定 Arrays.toString 调用,那么你可以编写你

自己的私有静态转发方法:

1
2
3
4
5
private static String toString(Object[] a) { 

return Arrays.toString(a);

}

静态导入工具所专门针对的情况是:程序中会重复地使用另一个类的静态元素,

而每一次用到的时候都进行限定又会使程序变得乱成一锅粥。在这类情况中,静

态导入工具可以显著地提高可读性。这比通过实现接口来继承其常量要安全得

多,而实现接口这种做法是你从来都不应该采用的 [EJ Item 17]。然而,滥用

静态导入工具也会损害可读性,因为这会使得静态成员的类在何处被使用显得非

常不清晰。应该有节制地使用静态导入,只有在非常需要的情况下才应该使用它

们。

对 API 设计者来说,要意识到当某个方法的名字已经出现在某个作用域内时,静

态导入工具并不能被有效地作用于该方法上。这意味着静态导入不能用于那些与

通用接口中的方法共享方法名的静态方法,而且也从来不能用于那些与 Object

中的方法共享方法名的静态方法。再次说明一下,本谜题所要说明的仍然是你在

覆写之外的情况中使用名字重用通常都会产生混乱。我们通过重载、隐藏和遮掩

看清楚了这一点,现在我们又通过遮蔽看到了同样的问题。

谜题 72:终极危难

本谜题旨在检验当你试图隐藏一个 final 域时将要发生的事情。下面的程序将做些什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Jeopardy { 

public static final String PRIZE = "$64,000";

}

public class DoubleJeopardy extends Jeopardy {

public static final String PRIZE = "2 cents";

public static void main(String[ ] args) {

System.out.println(DoubleJeopardy.PRIZE);

}

} 因为在 Jeopardy 中的 PRIZE 域被声明为是 public 和 final 的,你可能会认为

Java 语言将阻止你在子类中重用该域名。毕竟,final 类型的方法不能被覆写或

隐藏。如果你尝试着运行该程序,就会发现它可以毫无问题地通过编译,并且将

打印 2 cents。出什么错了呢?

可以证明,final 修饰符对方法和域而言,意味着某些完全不同的事情。对于方

法,final 意味着该方法不能被覆写(对实例方法而言)或者隐藏(对静态方法

而言)[JLS 8.4.3.3]。对于域,final 意味着该域不能被赋值超过一次[JLS

8.3.1.2]。关键字相同,但是其行为却完全不相关。

在该程序中,final 域 DoubleJeopardy.PRIZE 隐藏了 final 域 Jeopardy.PRIZE,

其净损失达到了$63,999.98。尽管我们可以隐藏一个域,但是通常这都是一个不

好的念头。就像我们在谜题 66 中所讨论的,隐藏域可能会违反包容性,并且会

混淆我们对类型与其成员之间的关系所产生的直觉。

如果你想保证在 Jeopardy 类中的奖金可以保留到子类中,那么你应该用一个

final 方法来代替 final 域:

1
2
3
4
5
6
7
8
9
10
11
class Jeopardy { 

private static final String PRIZE = "$64,000";

public static final String prize() {

return PRIZE;

}

}

对语言设计者来说,其教训是应该避免在不相关的概念之间重用关键字。一个关

键字应该只在密切相关的概念之间重用,这样可以帮助程序员构建关于易混淆的

语言特性之间的关系的印象。在 Java 的 final 关键字这一案例中,重用就导致

了混乱。应该注意的是,作为一种有年头的语言来说,在无关的概念之间重用关

键字是它的一种自然趋势,这样做可以避免引入新的关键字,而引入新的关键字

会对语言的稳定性造成极大的损害。当语言设计者在考虑该怎么做时,总是在两

害相权取其轻。

总之,要避免在无关的变量或无关的概念之间重用名字。对无关的概念使用有区

别的名字有助于让读者和程序员区分这些概念。

谜题 73:你的隐私正在公开

私有成员,即私有方法、域和类型这些概念的幕后思想是它们只是实现细节:一个类的实现

者可以随意地添加一个新的私有成员,或者修改和移除一个旧的私有成员,而不需要担心对

该类的客户造成任何损害。换句话说,私有成员被包含它们的类完全封装了。

遗憾的是,在这种严密的盔甲保护中仍然存在细小的裂缝。例如,序列化就可以

打破这种封装。如果使一个类成为可序列化的,并且接受缺省的序列化形式,那么该类的私有实例域将成为其导出 API 的一部分[EJ Item 54,55]。当客户正在

使用现有的被序列化对象时,对私有表示的修改将会导致异常或者是错误的行

为。

但是编译期的错误又会怎么样呢?你能否写出一个 final 的“库”类和“客

户”类,这两者都可以毫无问题地通过编译,然后在库类中添加一个私有成员,

使得库类仍然能够编译,而客户类却再也不能编译了?

如果你的解谜方案是要对库类添加一个私有构造器,以抑制通过缺省的公共构造

器而创建实例的行为,那么你只是一知半解。本谜题要求你添加一个私有成员,

严格地讲,构造器不是成员[JLS 6.4.3]。

本谜题有数个解谜方案,其中一个是使用遮蔽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package library; 

public final class Api {

// private static class String{ }

public static String newString() {

return new String();

}

}

package client;

import library.Api;

public class Client {

String s = Api.newString();

}

如上编写,该程序就可以毫无问题地通过编译。如果我们不注释掉 library.Api

中的局部类 String 的私有声明,那么 Api.newString 方法就再也不会返回

java.lang.String 类型了,因此变量 Client.s 的初始化将不能通过编译:

1
2
3
4
5
6
7
client/Client.java:4: incompatible types 

found: library.Api.String, required: java.lang.String

String s = Api.newString();

^

尽管我们所做的文本修改仅仅是添加了一个私有类声明,但是我们间接地修改了

一个现有公共方法的返回类型,而这是一个不兼容的 API 修改,因为我们修改了

一个被导出 API 所使用的名字的含义。

这种解谜方案的数个变种也都可以实现这个目的。被遮蔽类型也可以来自一个外

围类而不是来自 java.lang;你可以遮蔽一个变量而不是一个类型,而被遮蔽变

量可以来自一个 static import 声明或者是来自一个外围类。 不修改类库的某个被导出成员的类型也可以解决本谜题。下面就是这样的一个解

谜方案,它使用的是隐藏而不是遮蔽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package library; 

class ApiBase {

public static final int ANSWER = 42;

}

public final class Api extends ApiBase() {

// private static final int ANSWER = 6 * 9;

}

package client;

import library.Api;

public class Client {

int answer = Api.ANSWER;

}

如上编写,该程序就可以毫无问题地通过编译。如果我们不注释掉 library.Api

中的私有声明,那么客户类将不能通过编译:

1
2
3
4
5
client/Client.java:4: ANSWER has private access in library.Api 

int answer = Api.ANSWER;

^

这个新的私有域 Api.ANSWER 隐藏了公共域 ApiBase.ANSWER,而这个域本来是应

该被继承到 Api 中的。因为新的域被声明为是 private 的,所以它不能被 Client

访问。这种解谜方案的数个变种也都可以实现这个目的。你可以用隐藏一个实例

域去替代隐藏一个静态域,或者用隐藏一个类型去替代隐藏一个域。

你还可以用遮掩来解决本谜题。所有的解谜方案都是通过重用某个名字来破坏客

户类。重用名字是危险的;应该避免隐藏、遮蔽和遮掩。是不是对此已经耳熟能

详了?很好!

谜题 74:同一性的危机

下面的程序是不完整的,它缺乏对 Enigma 的声明,这个类扩展自 java.lang.Object。请为

Enigma 提供一个声明,它可以使该程序打印 false:

1
2
3
4
5
6
7
8
9
public class Conundrum { 

public static void main(String[] args) {

Enigma e = new Enigma();

System.out.println(e.equals(e));

}

} 噢,还有一件事:你不能覆写 equals 方法。

乍一看,这似乎不可能实现。因为 Object.equals 方法将测试对象的同一性,通

过 Enigma 传递给 equals 方法的对象肯定是与其自身相同的。如果你不能覆写

Object.equals 方法,那么 main 方法必然打印 true,对吗?

别那么快下结论,伙计。尽管本谜题禁止你覆写(override)Object.equals 方

法,但是你是可以重载(overload)它的,这也就引出了下面的解谜方案:

1
2
3
4
5
6
7
8
9
10
11
final class Enigma { 

// Don’t do this!

public Boolean equals(Enigma other){

return false;

}

}

尽管这个声明能够解决本谜题,但是它的做法确实非常不好的。它违反了谜题

58 的建议:如果同一个方法的两个重载版本都可以应用于某些参数,那么它们

应该具有相同的行为。在本例中,e.equals(e)和 e.equals((Object)e)将返回

不同的结果,其潜在的混乱是显而易见的。

然而,有一种解谜方案是不会违反这项建议的:

1
2
3
4
5
6
7
8
9
10
11
final class Enigma { 

public Enigma() {

System.out.println(false);

System.exit(0);

}

}

可能会有些争论,这个解谜方案似乎违背了本谜题的精神:能够产生我们想要的

输出的 println 调用出现在了构造器中,而不是在 main 方法中。然而,它确实

解决了这个谜题,你不得不承认它很伶俐。

这里的教训,可以参阅前面的 8 个谜题和谜题 58。如果你重载了一个方法,那

么一定要确保所有的重载版本行为一致。

谜题 75:头还是尾?

这个程序的行为在 1.4 版和 5.0 版的 Java 平台上会有些变化。这个程序在这些版本上会分别

做些什么呢?(如果你只能访问 5.0 版本的平台,那么你可以在编译的时候使用-source 1.4

标记,以此来模拟 1.4 版的行为。)

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.Random; 

public class CoinSide { private static Random rnd = new Random();

public static CoinSide flip() {

return rnd.nextBoolean() ?

Heads.INSTANCE : Tails.INSTANCE;

}

public static void main(String[ ] args) {

System.out.println(flip());

}

}

class Heads extends CoinSide {

private Heads() { }

public static final Heads INSTANCE = new Heads();

public String toString() {

return "heads";

}

}

class Tails extends CoinSide {

private Tails() { }

public static final Tails INSTANCE = new Tails();

public String toString() {

return "tails";

}

}

该程序看起来根本没有使用 5.0 版的任何新特性,因此很难看出来为什么它们在

行为上应该有差异。事实上,该程序在 1.4 或更早版本的平台上是不能编译的:

1
2
3
4
5
6
7
8
9
10
11
CoinSide.java:7: 

incompatible types for ?: neither is a subtype of the other

second operand: Heads

third operand : Tails

return rnd.nextBoolean() ?

^

条件操作符(?:)的行为在 5.0 版本之前是非常受限的[JLS2 15.25]。当第二

个和第三个操作数是引用类型时,条件操作符要求它们其中的一个必须是另一个

的子类型。Heads 和 Tails 彼此都不是对方的子类型,所以这里就产生了一个错

误。为了让这段代码能够编译,你可以将其中一个操作数转型为二者的公共超类:

1
return rnd.nextBooleam() ?

(CoinSide)Heads.INSTANCE : Tails.INSTANCE; 在 5.0 或更新的版本中,Java 语言显得更加宽大了,条件操作符在第二个和第

三个操作数是引用类型时总是合法的。其结果类型是这两种类型的最小公共超

类。公共超类总是存在的,因为 Object 是每一个对象类型的超类型。在实际使

用中,这种变化的主要结果就是条件操作符做正确的事情的情况更多了,而给出

编译期错误的情况更少了。对于我们当中的语言菜鸟来说,作用于引用类型的条

件操作符的结果所具备的编译期类型与在第二个和第三个操作数上调用下面的

方法的结果相同:

1
T choose(T a,T b) { }

本谜题所展示的问题在 1.4 和更早的版本中发生得相当频繁,迫使你必须插入只

是为了遮掩你的代码的真实目的而进行的转型。这就是说,该谜题本身是人为制

造的。在 5.0 版本之前,使用类型安全的枚举模式来编写 CoinSide 对程序员来

说会显得更自然一些[EJ Item 21]:

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
31
32
33
34
35
36
37
38
39
import java.util.Random; 

public class CoinSide {

public static final CoinSide HEADS = new CoinSide("heads");

public static final CoinSide TAILS = new CoinSide("tails");

private final String name;

private CoinSide(String name) {

this.name = name;

}



public String toString() {

return name;

}

private static Random rnd = new Random();

public static CoinSide flip() {

return rnd.nextBoolean() ? HEADS : TAILS;

}

public static void main(String[] args) {

System.out.println(flip());

}

}

在 5.0 或更新的版本中,自然会将 CoinSide 当作是一个枚举类型来编写:

1
2
3
4
5
6
7
8
9
10
11
public enum CoinSide { 

HEADS, TAILS;

public String toString() { return name().toLowerCase();

}

// flip 和 main 与上面的 1.4 版上的实现一样

}

本谜题的教训是:应该升级到最新的 Java 平台版本上。较新的版本都包含许多

让程序员更轻松的改进,你并不需要费力去学习怎样利用所有的新特性,有些新

特性不需要你付出任何努力就可以给你带来实惠。对语言和类库的设计者来说,

得到的教训是:不要让程序员去做那些语言或类库本可以帮他们做的事。

名字重用的术语表

覆写(override)

一个实例方法可以覆写(override)在其超类中可访问到的具有相同签名的所有实例方法[JLS

8.4.8.1],从而使能了动态分派(dynamic dispatch);换句话说,VM 将基于实例的运行期类

型来选择要调用的覆写方法[JLS 15.12.4.4]。覆写是面向对象编程技术的基础,并且是唯一

没有被普遍劝阻的名字重用形式:

1
2
3
4
5
6
7
8
9
10
11
class Base { 

public void f() { }

}

class Derived extends Base {

public void f() { } // overrides Base.f()

}

隐藏(hide)

一个域、静态方法或成员类型可以分别隐藏(hide)在其超类中可访问到的具有相同名字(对

方法而言就是相同的方法签名)的所有域、静态方法或成员类型。隐藏一个成员将阻止其被

继承[JLS 8.3, 8.4.8.2, 8.5]:

1
2
3
4
5
6
7
8
9
10
11
class Base { 

public static void f() { }

}

class Derived extends Base {

private static void f() { } // hides Base.f()

}

重载(overload)

在某个类中的方法可以重载(overload)另一个方法,只要它们具有相同的名字和不同的签

名。由调用所指定的重载方法是在编译期选定的[JLS 8.4.9, 15.12.2]:

1
2
3
4
5
6
7
class CircuitBreaker { 

public void f(int i) { } // int overloading

public void f(String s) { } // String overloading

}

遮蔽(shadow)

一个变量、方法或类型可以分别遮蔽(shadow)在一个闭合的文本范围内的具有相同名字

的所有变量、方法或类型。如果一个实体被遮蔽了,那么你用它的简单名是无法引用到它的;

根据实体的不同,有时你根本就无法引用到它[JLS 6.3.1]:

1
2
3
4
5
6
7
8
9
10
11
12
13
class WhoKnows { 

static String sentence = "I don't know.";

public static woid main(String[ ] args) {

String sentence = “I know!”; // shadows static field

System.out.println(sentence); // prints local variable

}

}

尽管遮蔽通常是被劝阻的,但是有一种通用的惯用法确实涉及遮蔽。构造器经常

将来自其所在类的某个域名重用为一个参数,以传递这个命名域的值。这种惯用

法并不是没有风险,但是大多数 Java 程序员都认为这种风格带来的实惠要超过

其风险:

1
2
3
4
5
6
7
8
9
10
11
class Belt { 

private final int size;

public Belt(int size) { // Parameter shadows Belt.size

this.size = size;

}

}

遮掩(obscure)

一个变量可以遮掩具有相同名字的一个类型,只要它们都在同一个范围内:如果这个名字被

用于变量与类型都被许可的范围,那么它将引用到变量上。相似地,一个变量或一个类型可

以遮掩一个包。遮掩是唯一一种两个名字位于不同的名字空间的名字重用形式,这些名字空

间包括:变量、包、方法或类型。如果一个类型或一个包被遮掩了,那么你不能通过其简单

名引用到它,除非是在这样一个上下文环境中,即语法只允许在其名字空间中出现一种名字。

遵守命名习惯就可以极大地消除产生遮掩的可能性[JLS 6.3.2, 6.5]:public class Obscure {

1
2
3
4
5
6
7
8
9
10
11
 static String System; // Obscures type java.lang.System 

public static void main(String[ ] args) {

// Next line won't compile: System refers to static field

System.out.println(“hello, obscure world!”);

}

}