Java 谜题 Java 谜题 2——字符谜题
谜题 11:最后的笑声
下面的程序将打印出什么呢?
1 | public class LastLaugh{ |
你可能会认为这个程序将打印 HaHa。该程序看起来好像是用两种方式连接了 H
和 a,但是你所见为虚。如果你运行这个程序,就会发现它打印的是 Ha169。那
正如我们所期望的,第一个对 System.out.print 的调用打印的是 Ha:它的参数
是表达式”H”+”a”,显然它执行的是一个字符串连接。而第二个对
System.out.print 的调用就是另外一回事了。问题在于’H’和’a’是字符型字面
常量,因为这两个操作数都不是字符串类型的,所以 + 操作符执行的是加法而
不是字符串连接。
编译器在计算常量表达式’H’+’a’时,是通过我们熟知的拓宽原始类型转换将两
个具有字符型数值的操作数(’H’和’a’)提升为 int 数值而实现的。从 char 到
int 的拓宽原始类型转换是将 16 位的 char 数值零扩展到 32 位的 int。对于’H’,
char 数值是 72,而对于’a’,char 数值是 97,因此表达式’H’+’a’等价于 int
常量 72 + 97,或 169。
站在语言的立场上,若干个 char 和字符串的相似之处是虚幻的。语言所关心的
是,char 是一个无符号 16 位原始类型整数——仅此而已。对类库来说就不尽如
此了,类库包含了许多可以接受 char 参数,并将其作为 Unicode 字符处理的方
法。
那么你应该怎样将字符连接在一起呢?你可以使用这些类库。例如,你可以使用
一个字符串缓冲区:
1 | StringBuffer sb = new StringBuffer(); |
产生的拖沓冗长的代码。 你可以通过确保至少有一个操作数为字符串类型,来
强制 + 操作符去执行一个字符串连接操作,而不是一个加法操作。这种常见的
惯用法用一个空字符串(””)作为一个连接序列的开始,如下所示:
1 | System.out.println("" + 'H' + 'a'); |
这种惯用法可以确保子表达式都被转型为字符串。尽管这很有用,但是多少有一
点难看,而且它自身可能会引发某些混淆。你能猜到下面的语句将会打印出什么
吗?如果你不能确定,那么就试一下:
1 | System.out.print("2 + 2 = " + 2+2); |
如果使用的是 JDK 5.0,你还可以使用
1 | System.out.printf("%c%c", 'H', 'a'); |
总之,使用字符串连接操作符使用格外小心。+ 操作符当且仅当它的操作数中至
少有一个是 String 类型时,才会执行字符串连接操作;否则,它执行的就是加
法。如果要连接的没有一个数值是字符串类型的,那么你可以有几种选择:
• 预置一个空字符串;
• 将第一个数值用 String.valueOf 显式地转换成一个字符串;
• 使用一个字符串缓冲区;
• 或者如果你使用的 JDK 5.0,可以用 printf 方法。
这个谜题还包含了一个给语言设计者的教训。操作符重载,即使在 Java 中只在
有限的范围内得到了支持,它仍然会引起混淆。为字符串连接而重载 + 操作符
可能就是一个已铸成的错误。
谜题 12:ABC
这个谜题要问的是一个悦耳的问题,下面的程序将打印什么呢?
1 | public class ABC{ |
可能大家希望这个程序打印出 ABC easy as 123。遗憾的是,它没有。如果你运
行它,就会发现它打印的是诸如 ABC easy as [C@16f0472 之类的东西。为什么
这个输出会如此丑陋? 尽管 char 是一个整数类型,但是许多类库都对其进行了特殊处理,因为 char
数值通常表示的是字符而不是整数。例如,将一个 char 数值传递给 println 方
法会打印出一个 Unicode 字符而不是它的数字代码。字符数组受到了相同的特殊
处理:println 的 char[]重载版本会打印出数组所包含的所有字符,而
String.valueOf和StringBuffer.append的 char[]重载版本的行为也是类似的。
然而,字符串连接操作符在这些方法中没有被定义。该操作符被定义为先对它的
两个操作数执行字符串转换,然后将产生的两个字符串连接到一起。对包括数组
在内的对象引用的字符串转换定义如下[JLS 15.18.1.1]:
如果引用为 null,它将被转换成字符串”null”。否则,该转换的执行就像是不
用任何参数调用该引用对象的 toString 方法一样;但是如果调用 toString 方法
的结果是 null,那么就用字符串”null”来代替。
那么,在一个非空 char 数组上面调用 toString 方法会产生什么样的行为呢?数
组是从 Object 那里继承的 toString 方法[JLS 10.7],规范中描述到:“返回一
个字符串,它包含了该对象所属类的名字,‘@’符号,以及表示对象散列码的一
个无符号十六进制整数”[Java-API]。有关 Class.getName 的规范描述到:在
char[]类型的类对象上调用该方法的结果为字符串”[C”。将它们连接到一起就形
成了在我们的程序中打印出来的那个丑陋的字符串。
有两种方法可以订正这个程序。你可以在调用字符串连接操作之前,显式地将一
个数组转换成一个字符串:
1 | System.out.println(letters + " easy as " + |
或者,你可以将 System.out.println 调用分解为两个调用,以利用 println 的
char[]重载版本:
1 | System.out.print(letters + " easy as "); |
请注意,这些订正只有在你调用了 valueOf 和 println 方法正确的重载版本的情
况下,才能正常运行。换句话说,它们严格依赖于数组引用的编译期类型。
下面的程序说明了这种依赖性。看起来它像是所描述的第二种订正方式的具体实
现,但是它产生的输出却与最初的程序所产生的输出一样丑陋,因为它调用的是
println 的 Object 重载版本,而不是 char[]重载版本。
1 | class ABC2{ |
} 总之,char 数组不是字符串。要想将一个 char 数组转换成一个字符串,就要调
用 String.valueOf(char[])方法。某些类库中的方法提供了对 char 数组的类似
字符串的支持,通常是提供一个 Object 版本的重载方法和一个 char[]版本的重
载方法,而之后后者才能产生我们想要的行为。
对语言设计者的教训是:char[]类型可能应该覆写 toString 方法,使其返回数
组中包含的字符。更一般地讲,数组类型可能都应该覆写 toString 方法,使其
返回数组内容的一个字符串表示。
谜题 13:畜牧场
George Orwell 的《畜牧场(Animal Farm)》一书的读者可能还记得老上校的
宣言:“所有的动物都是平等的。”下面的 Java 程序试图要测试这项宣言。那
么,它将打印出什么呢?
1 | public class AnimalFarm{ |
对该程序的表面分析可能会认为它应该打印出 Animal are equal: true。毕竟,
pig和 dog都是final的 string类型变量,它们都被初始化为字符序列“length:
10”。换句话说,被 pig 和 dog 引用的字符串是且永远是彼此相等的。然而,==
操作符测试的是这两个对象引用是否正好引用到了相同的对象上。在本例中,它
们并非引用到了相同的对象上。
你可能知道 String 类型的编译期常量是内存限定的。换句话说,任何两个 String
类型的常量表达式,如果标明的是相同的字符序列,那么它们就用相同的对象引
用来表示。如果用常量表达式来初始化 pig 和 dog,那么它们确实会指向相同的
对象,但是 dog 并不是用常量表达式初始化的。既然语言已经对在常量表达式中
允许出现的操作作出了限制,而方法调用又不在其中,那么,这个程序就应该打
印 Animal are equal: false,对吗?
嗯,实际上不对。如果你运行该程序,你就会发现它打印的只是 false,并没有
其它的任何东西。它没有打印 Animal are equal: 。它怎么会不打印这个字符
串字面常量呢?毕竟打印它才是正确的呀!谜题 11 的解谜方案包含了一条暗示:
+ 操作符,不论是用作加法还是字符串连接操作,它都比 == 操作符的优先级高。
因此,println 方法的参数是按照下面的方式计算的:
1 | System.out.println(("Animals are equal: " + pig) == dog); |
这个布尔表达式的值当然是 false,它正是该程序的所打印的输出。 有一个肯定能够避免此类窘境的方法:在使用字符串连接操作符时,总是将非平
凡的操作数用括号括起来。更一般地讲,当你不能确定你是否需要括号时,应该
选择稳妥地做法,将它们括起来。如果你在 println 语句中像下面这样把比较部
分括起来,它将产生所期望的输出 Animals are equal: false :
1 | System.out.println("Animals are equal: " + (pig == dog)); |
可以论证,该程序仍然有问题。
如果可以的话,你的代码不应该依赖于字符串常量的内存限定机制。内存限定机
制只是设计用来减少虚拟机内存占有量的,它并不是作为程序员可以使用的一种
工具而设计的。就像这个谜题所展示的,哪一个表达式会产生字符串常量并非总
是很显而易见。
更糟的是,如果你的代码依赖于内存限定机制实现操作的正确性,那么你就必须
仔细地了解哪些域和参数必定是内存限定的。编译器不会帮你去检查这些不变
量,因为内存限定的和不限定的字符串使用相同的类型(String)来表示的。这
些因在内存中限定字符串失败而导致的 bug 是非常难以探测到的。
在比较对象引用时,你应该优先使用 equals 方法而不是 == 操作符,除非你需
要比较的是对象的标识而不是对象的值。通过把这个教训应用到我们的程序中,
我们给出了下面的 println 语句,这才是它应该具有的模样。很明显,在用这种
方式订正了该程序之后,它将打印出 true:
1 | System.out.println("Animals are equal: " + pig.equals(dog)); |
这个谜题对语言设计者来说有两个教训。
• 字符串连接的优先级不应该和加法一样。这意味着重载 + 操作符来执行
字符串连接是有问题的,就像在谜题 11 中提到的一样。
• 还有就是,对于不可修改的类型,例如 String,其引用的等价性比值的
等价性更加让人感到迷惑。也许 == 操作符在被应用于不可修改的类型时
应该执行值比较。要实现这一点,一种方法是将 == 操作符作为 equals
方法的简便写法,并提供一个单独的类似于 System.identityHashCode
的方法来执行引用标识的比较。
谜题 14:转义字符的溃败 转义字符的溃败
下面的程序使用了两个 Unicode 的转义字符,它们是用其十六进制代码来表示
Unicode 字符。那么,这个程序会打印什么呢?
1 | public class EscapeRout{ |
对该程序的一种很肤浅的分析会认为它应该打印出 26,因为在由两个双引号
“a\u0022.length()+\u0022b”标识的字符串之间总共有 26 个字符。
稍微深入一点的分析会认为该程序应该打印 16,因为两个 Unicode 转义字符每
一个在源文件中都需要用 6 个字符来表示,但是它们只表示字符串中的一个字
符。因此这个字符串应该比它的外表看其来要短 10 个字符。 如果你运行这个程
序,就会发现事情远不是这么回事。它打印的既不是 26 也不是 16,而是 2。
理解这个谜题的关键是要知道:Java 对在字符串字面常量中的 Unicode 转义字
符没有提供任何特殊处理。编译器在将程序解析成各种符号之前,先将 Unicode
转义字符转换成为它们所表示的字符[JLS 3.2]。因此,程序中的第一个 Unicode
转义字符将作为一个单字符字符串字面常量(”a”)的结束引号,而第二个
Unicode 转义字符将作为另一个单字符字符串字面常量(”b”)的开始引号。程
序打印的是表达式”a”.length()+”b”.length(),即 2。
如果该程序的作者确实希望得到这种行为,那么下面的语句将要清楚得多:
1 | System.out.println("a".length()+"b".length()); |
更有可能的情况是该作者希望将两个双引号字符置于字符串字面常量的内部。使
用 Unicode 转义字符你是不能实现这一点的,但是你可以使用转义字符序列来实
现[JLS 3.10.6]。表示一个双引号的转义字符序列是一个反斜杠后面紧跟着一个
双引号(\”)。如果将最初的程序中的 Unicode 转义字符用转义字符序列来替
换,那么它将打印出所期望的 16:
1 | System.out.println("a\".length()+\"b".length()); |
许多字符都有相应的转义字符序列,包括单引号(')、换行(\n)、制表符(\t)
和反斜线(\)。你可以在字符字面常量和字符串字面常量中使用转义字符序列。
实际上,你可以通过使用被称为八进制转义字符的特殊类型的转义字符序列,将
任何 ASCII 字符置于一个字符串字面常量或一个字符字面常量中,但是最好是尽
可能地使用普通的转义字符序列。
普通的转义字符序列和八进制转义字符都比 Unicode 转义字符要好得多,因为与
Unicode 转义字符不同,转义字符序列是在程序被解析为各种符号之后被处理
的。
ASCII 是字符集的最小公共特性集,它只有 128 个字符,但是 Unicode 有超过
65,000 个字符。一个 Unicode 转义字符可以被用来在只使用 ASCII 字符的程序
中插入一个Unicode字符。一个Unicode转义字符精确地等价于它所表示的字符。
Unicode 转义字符被设计为用于在程序员需要插入一个不能用源文件字符集表
示的字符的情况。它们主要用于将非 ASCII 字符置于标识符、字符串字面常量、
字符字面常量以及注释中。偶尔地,Unicode 转义字符也被用来在看起来颇为相
似的数个字符中明确地标识其中的某一个,从而增加程序的清晰度。 总之,在字符串和字符字面常量中要优先选择的是转义字符序列,而不是
Unicode 转义字符。Unicode 转义字符可能会因为它们在编译序列中被处理得过
早而引起混乱。不要使用 Unicode 转义字符来表示 ASCII 字符。在字符串和字符
字面常量中,应该使用转义字符序列;对于除这些字面常量之外的情况,应该直
接将 ASCII 字符插入到源文件中。
谜题 15:令人晕头转向的 令人晕头转向的 Hello
下面的程序是对一个老生常谈的例子做出了稍许的变化之后的版本。那么,它会
打印出什么呢?
1 | /** |
这个谜题看起来相当简单。该程序包含了两条语句,第一条打印 Hell,而第二
条在同一行打印 o world,从而将两个字符串有效地连接在了一起。因此,你可
能期望该程序打印出 Hello world。但是很可惜,你犯了错,实际上,它根本就
通不过编译。
问题在于注释的第三行,它包含了字符\units。这些字符以反斜杠(\)以及紧
跟着的字母 u 开头的,而它(\u)表示的是一个 Unicode 转义字符的开始。遗憾
的是,这些字符后面没有紧跟四个十六进制的数字,因此,这个 Unicode 转义字
符是病构的,而编译器则被要求拒绝该程序。Unicode 转义字符必须是良构的,
即使是出现在注释中也是如此。
在注释中插入一个良构的 Unicode 转义字符是合法的,但是我们几乎没有什么理
由去这么做。程序员有时会在 JavaDoc 注释中使用 Unicode 转义字符来在文档中
生成特殊的字符。
1 | // Unicode 转义字符在 JavaDoc 注释中有问题的用法 |
这项技术表示了Unicode转义字符的一种没什么用处的用法。在 Javadoc注释中,
应该使用 HTML 实体转义字符来代替 Unicode 转义字符:
1 | /** |
前面的两个注释都应该是的在文档中出现的名字为“Peter der Ahé”,但是后
一个注释在源文件中还是可理解的。
可能你会感到很诧异,在这个谜题中,问题出在注释这一信息来源自一个实际的
bug 报告。该程序是机器生成的,这使得我们很难追踪到问题的源头
——IDL-to-Java 编译器。为了避免让其他程序员也陷入此境地,在没有将
Windows 文件名进行预先处理,以消除的其中的反斜杠的情况下,工具应该确保
不将 Windows 文件名置于所生成的 Java 源文件的注释中。
总之,要确保字符\u 不出现在一个合法的 Unicode 转义字符上下文之外,即使
是在注释中也是如此。在机器生成的代码中要特别注意此问题。
谜题 16:行打印程序
行分隔符(line separator)是为用来分隔文本行的字符或字符组合而起的名字,
并且它在不同的平台上是存在差异的。在 Windows 平台上,它是 CR 字符(回车)
和紧随其后的 LF 字符(换行)组成的,而在 UNIX 平台上,通常单独的 LF 字符
被当作换行字符来引用。下面的程序将这个字符传递给了 println 方法,那么,
它将打印出什么呢?它的行为是否是依赖于平台的呢?
1 | public class LinePrinter{ |
这个程序的行为是平台无关的:它在任何平台上都不能通过编译。如果你尝试着
去编译它,就会得到类似下面的出错信息:
1 | LinePrinter.java:3: ';' expected |
如果你和大多数人一样,那么这条信息对界定问题是毫无用处的。
这个谜题的关键就是程序第三行的注释。与最好的注释一样,这条注释也是一种
准确的表达,遗憾的是,它有一点准确得过头了。编译器不仅会在将程序解析成
为符号之前把 Unicode 转义字符转换成它们所表示的字符(谜题 14),而且它
是在丢弃注释和空格之前做这些事的[JLS 3.2]。 这个程序包含了一个 Unicode 转移字符(\u000A),它位于程序唯一的注释行中。
就像注释所陈述的,这个转义字符表示换行符,编译器将在丢弃注释之前适时地
转换它。遗憾的是,这个换行符是表示注释开始的两个斜杠符之后的第一个行终
结符(line terminator),因此它将终结该注释[JLS 3.4]。所以,该转义字符
之后的字(is Unicode representation of linefeed (LF))就不是注释的一部
分了,而它们在语法上也不是有效的。
订正该程序的最简单的方式就是在注释中移除 Unicode 转义字符,但是更好的方
式是用一个转义字符序列而不是一个十六进制整型字面常量来初始化 c,从而消
除使用注释的必要:
1 | public class LinePrinter{ |
只要这么做了,程序就可以编译并运行,但是这仍然是一个有问题的程序:它是
平台相关的,这正是本谜题所要表达的真正意图。在某些平台上,例如 UNIX,
它将打印出两个完整的行分隔符;但是在其它一些平台上,例如 Windows,它就
不会产生这样的行为。尽管这些输出用肉眼看起来是一样的,但是如果它们要被
存储到文件中,或是输出到后续的其它处理程序中,那就很容易引发问题。
如果你想打印两行空行,你应该调用 println 两次。如果使用的是 JDK 5.0,那
么你可以用带有格式化字符串”%n%n”的 printf 来代替 println。%n 的每一次出
现都将导致 printf 打印一个恰当的、与平台相关的行分隔符。
我们希望,上面三个谜题已经使你信服:Unicode 转义字符绝对会产生混乱。教
训很简单:除非确实是必需的,否则就不要使用 Unicode 转义字符。它们很少是
必需的。
谜题 17:嗯?
下面的是一个合法的 Java 程序吗?如果是,它会打印出什么呢?
1 | \u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020\u0020 |
这当然是一个合法的 Java 程序!这不是很显而易见吗?它会打印 Hello World。
噢,可能是不那么明显。事实上,该程序根本让人无法理解。每当你没必要地使
用了一个 Unicode 转义字符时,都会使你的程序的可理解性更缺失一点,而该程
序将这种做法发挥到了极致。如果你很好奇,可以看看下面给出的该程序在
Unicode 转义字符都被转换为它们所表示的字符之后的样子:
1 | public |
下面给出了将其进行格式化整理之后的样子:
1 | public class Ugly { |
这个谜题的教训是:仅仅是因为你可以不以应有的方式去进行表达。或者说,如
果你这么做会造成损害,那么就请不要这么做!更严肃地讲,这个谜题是对前面
三个教训的补充:Unicode 转义字符只有在你要向程序中插入用其他任何方式都
无法表示的字符时才是必需的,除此之外的任何情况都不应该避免使用它们。
Unicode 转义字符降低了程序的清晰度,并且增加了产生 bug 的可能性。
对语言的设计者来说,也许使用 Unicode 转义字符来表示 ASCII 字符应该被定义
为是非法的。这样就可以使得在谜题 14、15 和 17(本谜题)中的程序非法,从
而消除了大量的混乱。这个限制对程序员并不会造成任何困难。
谜题 18:字符串奶酪
下面的程序从一个字节序列创建了一个字符串,然后迭代遍历字符串中的字符,
并将它们作为数字打印。请描述一下程序打印出来的数字序列:
1 | public class StringCheese { |
首先,byte 数组用从 0 到 255 每一个可能的 byte 数值进行了初始化,然后这些
byte 数值通过 String 构造器被转换成了 char 数值。最后,char 数值被转型为
int 数值并被打印。打印出来的数值肯定是非负整数,因为 char 数值是无符号
的,因此,你可能期望该程序将按顺序打印出 0 到 255 的整数。
如果你运行该程序,可能会看到这样的序列。但是在运行一次,可能看到的就不
是这个序列了。我们在四台机器上运行它,会看到四个不同的序列,包括前面描
述的那个序列。这个程序甚至都不能保证会正常终止,比打印其他任何特定字符
串都要缺乏这种保证。它的行为完全是不确定的。
这里的罪魁祸首就是 String(byte[])构造器。有关它的规范描述道:“在通过
解码使用平台缺省字符集的指定 byte 数组来构造一个新的 String 时,该新
String 的长度是字符集的一个函数,因此,它可能不等于 byte 数组的长度。当
给定的所有字节在缺省字符集中并非全部有效时,这个构造器的行为是不确定
的”[Java-API]。
到底什么是字符集?从技术角度上讲,它是“被编码的字符集合和字符编码模式
的结合物”[Java-API]。换句话说,字符集是一个包,包含了字符、表示字符的
数字编码以及在字符编码序列和字节序列之间来回转换的方式。转换模式在字符
集之间存在着很大的区别:某些是在字符和字节之间做一对一的映射,但是大多
数都不是这样。ISO-8859-1 是唯一能够让该程序按顺序打印从 0 到 255 的整数
的缺省字符集,它更为大家所熟知的名字是 Latin-1[ISO-8859-1]。
J2SE 运行期环境(JRE)的缺省字符集依赖于底层的操作系统和语言。如果你想
知道你的 JRE 的缺省字符集,并且你使用的是 5.0 或更新的版本,那么你可以通
过调用 java.nio.charset.Charset.defaultCharset()来了解。如果你使用的是
较早的版本,那么你可以通过阅读系统属性“file.encoding”来了解。
幸运的是,你没有被强制要求必须去容忍各种稀奇古怪的缺省字符集。当你在
char 序列和 byte 序列之间做转换时,你可以且通常是应该显式地指定字符集。
除了接受 byte 数字之外,还可以接受一个字符集名称的 String 构造器就是专为
此目的而设计的。如果你用下面的构造器去替换在最初的程序中的 String 构造
器,那么不管缺省的字符集是什么,该程序都保证能够按照顺序打印从 0 到 255
的整数:
1 | String str = new String(bytes, "ISO-8859-1"); |
这个构造器声明会抛出 UnsupportedEncodingException 异常,因此你必须捕获
它,或者更适宜的方式是声明 main 方法将抛出它,要不然程序不能通过编译。
尽管如此,该程序实际上不会抛出异常。Charset 的规范要求 Java 平台的每一
种实现都要支持某些种类的字符集,ISO-8859-1 就位列其中。 这个谜题的教训是:每当你要将一个 byte 序列转换成一个 String 时,你都在使
用某一个字符集,不管你是否显式地指定了它。如果你想让你的程序的行为是可
预知的,那么就请你在每次使用字符集时都明确地指定。对 API 的设计者来说,
提供这么一个依赖于缺省字符集的 String(byte[])构造器可能并非是一个好主
意。
谜题 19:漂亮的火花
下面的程序用一个方法对字符进行了分类。这个程序会打印出什么呢?
1 | public class Classifier { |
如果你猜想该程序将打印 LETTER UNKNOWN NUMERAL,那么你就掉进陷阱里面了。
这个程序连编译都通不过。让我们再看一看相关的部分,这一次我们用粗体字突
出注释部分:
1 | if ("abcdefghijklmnopqrstuvwxyz".indexOf(ch) >= 0) |
正如你之所见,注释在包含了字符*/的字符串内部就结束了,结果使得程序在语
法上变成非法的了。我们将程序中的一部分注释出来的尝试之所以失败了,是因
为字符串字面常量在注释中没有被特殊处理。
更一般地讲,注释内部的文本没有以任何方式进行特殊处理[JLS 3.7]。因此,
块注释不能嵌套。请考虑下面的代码段: /* Add the numbers from 1 to n */
1 | int sum = 0; |
现在假设我们要将该代码段注释成为一个块注释,我们再次用粗体字突出整个注
释:
1 | /* |
正如你之所见,我们没有能够将最初的代码段注释掉。好在所产生的代码包含了
一个语法错误,因此编译器将会告诉我们代码存在着问题。
你可能偶尔看到过这样的代码段,它被一个布尔表达式为常量 false 的 if 语句
禁用了:
1 | //code commented out with an if statement - doesn't always work! |
语言规范建议将这种方式作为一种条件编译技术[JLS 14.21],但是它不适合用
来注释代码。除非要被禁用的代码是一个合法的语句序列,否则就不要使用这项
技术。
注释掉一个代码段的最好的方式是使用单行的注释序列。大多数 IDE 工具都可以
自动化这个过程:
1 | //code commented out with an if statement - doesn't always work! |
总之,块注释不能可靠地注释掉代码段,应该用单行的注释序列来代替。对语言
设计者来说,应该注意到可嵌套的块注释并不是一个好主意。他们强制编译器去
解析块注释内部的文本,而由此引发的问题比它能够解决的问题还要多。
谜题 20:我的类是什么?
下面的程序被设计用来打印它的类文件的名称。如果你不熟悉类字面常量,那么
我告诉你 Me.class.getName()将返回 Me 类完整的名称,即
“com.javapuzzlers.Me”。那么,这个程序会打印出什么呢?
1 | package com.javapuzzlers; |
该程序看起来会获得它的类名(“com.javapuzzlers.Me”),然后用“/”替换
掉所有出现的字符串“.”,并在末尾追加字符串“.class”。你可能会认为该
程序将打印 com/javapuzzlers/Me.class,该程序正式从这个类文件中被加载
的。如果你运行这个程序,就会发现它实际上打印的是
///////////////////.class。到底怎么回事?难道我们是斜杠的受害者吗?
问题在于 String.replaceAll 接受了一个正则表达式作为它的第一个参数,而并
非接受了一个字符序列字面常量。(正则表达式已经被添加到了 Java 平台的 1.4
版本中。)正则表达式“.”可以匹配任何单个的字符,因此,类名中的每一个
字符都被替换成了一个斜杠,进而产生了我们看到的输出。
要想只匹配句点符号,在正则表达式中的句点必须在其前面添加一个反斜杠(\)
进行转义。因为反斜杠字符在字面含义的字符串中具有特殊的含义——它标识转
义字符序列的开始——因此反斜杠自身必须用另一个反斜杠来转义,这样就可以
产生一个转义字符序列,它可以在字面含义的字符串中生成一个反斜杠。把这些
合在一起,就可以使下面的程序打印出我们所期望的
com/javapuzzlers/Me.class:
1 | package com.javapuzzlers; |
为了解决这类问题,5.0 版本提供了新的静态方法
java.util.regex.Pattern.quote。它接受一个字符串作为参数,并可以添加必
需的转义字符,它将返回一个正则表达式字符串,该字符串将精确匹配输入的字
符串。下面是使用该方法之后的程序:
1 | package com.javapuzzlers; |
该程序的另一个问题是:其正确的行为是与平台相关的。并不是所有的文件系统
都使用斜杠符号来分隔层次结构的文件名组成部分的。要想获取一个你正在运行
的平台上的有效文件名,你应该使用正确的平台相关的分隔符号来代替斜杠符
号。这正是下一个谜题所要做的。
谜题 21:我的类是什么?II
下面的程序所要做的事情正是前一个谜题所做的事情,但是它没有假设斜杠符号
就是分隔文件名组成部分的符号。相反,该程序使用的是
java.io.File.separator,它被指定为一个公共的 String 域,包含了平台相关
的文件名分隔符。那么,这个程序会打印出其正确的、平台相关的类文件名吗?
1 | package com.javapuzzlers; |
这个程序根据底层平台的不同会显示两种行为中的一种。如果文件分隔符是斜
杠,就像在 UNIX 上一样,那么该程序将打印 com/javapuzzlers/MeToo.class,
这是正确的。但是,如果文件分隔符是反斜杠,就像在 Windows 上一样,那么该
程序将打印像下面这样的内容:
1 | Exception in thread "main" |
尽管这种行为是平台相关的,但是它并非就是我们所期待的。在 Windows 上出了
什么错呢?
事实证明,String.replaceAll 的第二个参数不是一个普通的字符串,而是一个
替代字符串(replacement string),就像在 java.util.regex 规范中所定义的
那样[Java-API]。在替代字符串中出现的反斜杠会把紧随其后的字符进行转义,
从而导致其被按字面含义而处理了。
当你在 Windows 上运行该程序时,替代字符串是单独的一个反斜杠,它是无效的。
不可否认,抛出的异常应该提供更多一些有用的信息。 那么你应该怎样解决此问题呢?5.0 版本提供了不是一个而是两个新的方法来
解决它。第一个方法是 java.util.regex.Matcher.quoteReplacement,它将字
符串转换成相应的替代字符串。下面展示了如何使用这个方法来订正该程序:
1 | System.out.println(MeToo.class.getName().replaceAll("\\.", |
引入到 5.0 版本中的第二个方法提供了一个更好的解决方案。该方法就是
String.replace(CharSequence, CharSequence),它做的事情和
String.replaceAll 相同,但是它将模式和替代物都当作字面含义的字符串处
理。下面展示了如何使用这个方法来订正该程序:
1 | System.out.println(MeToo.class.getName(). |
但是如果你使用的是较早版本的 Java 该怎么办?很遗憾,没有任何捷径能够生
成替代字符串。完全不使用正则表达式,而使用 String.replace(char,char)也
许要显得更容易一些:
1 | System.out.println(MeToo.class.getName(). |
本谜题和前一个谜题的主要教训是:在使用不熟悉的类库方法时一定要格外小
心。当你心存疑虑时,就要求助于 Javadoc。还有就是正则表达式是很棘手的:
它所引发的问题趋向于在运行时刻而不是在编译时刻暴露出来。
对 API 的设计者来说,使用方法具名的模式来以明显的方式区分方法行为的差异
是很重要的。Java 的 String 类就没有很好地遵从这一原则。对许多程序员来说,
对于哪些字符串替代方法使用的是字面含义的字符串,以及哪些使用的是正则表
达式或替代字符串,要记住这些都不是一件容易事。
谜题 22:URL 的愚弄
本谜题利用了 Java 编程语言中一个很少被人了解的特性。请考虑下面的程序将
会做些什么?
1 | public class BrowserTest { |
这是一个有点诡异的问题。该程序将不会做任何特殊的事情,而是直接打印
iexplore::maximize。在程序中间出现的 URL 是一个语句标号(statement label)
[JLS 14.7]后面跟着一行行尾注释(end-of-line comment)[JLS 3.7]。在 Java
中很少需要标号,这多亏了 Java 没有 goto 语句。在本谜题中所引用的“Java
编程语言中很少被人了解的特性”实际上就是你可以在任何语句前面放置标号。
这个程序标注了一个表达式语句,它是合法的,但是却没什么用处。 它的价值所在,就是提醒你,如果你真的想要使用标号,那么应该用一种更合理
的方式来格式化程序:
1 | public class BrowserTest { |
这就是说,我们没有任何可能的理由去使用与程序没有任何关系的标号和注释。
本谜题的教训是:令人误解的注释和无关的代码会引起混乱。要仔细地写注释,
并让它们跟上时代;要切除那些已遭废弃的代码。还有就是如果某些东西看起来
过于奇怪,以至于不像对的,那么它极有可能就是错的。
谜题 23:不劳无获
下面的程序将打印一个单词,其第一个字母是由一个随机数生成器来选择的。请
描述该程序的行为:
1 | import java.util.Random; |
乍一看,这个程序可能会在一次又一次的运行中,以相等的概率打印出 Pain,
Gain 或 Main。看起来该程序会根据随机数生成器所选取的值来选择单词的第一
个字母:0 选 M,1 选 P,2 选 G。谜题的题目也许已经给你提供了线索,它实际
上既不会打印 Pain,也不会打印 Gain。也许更令人吃惊的是,它也不会打印 Main,
并且它的行为不会在一次又一次的运行中发生变化,它总是在打印 ain。
有三个 bug 凑到一起引发了这种行为。你完全没有发现它们吗?第一个 bug 是所
选取的随机数使得 switch 语句只能到达其三种情况中的两种。Random.nextInt(int)的规范描述道:“返回一个伪随机的、均等地分布在从 0
(包括)到指定的数值(不包括)之间的一个 int 数值”[Java-API]。这意味着
表达式 rnd.nextInt(2)可能的取值只有 0 和 1,Switch 语句将永远也到不了 case
2 分支,这表示程序将永远不会打印 Gain。nextInt 的参数应该是 3 而不是 2。
这是一个相当常见的问题源,被熟知为“栅栏柱错误(fencepost error)”。
这个名字来源于对下面这个问题最常见的但却是错误的答案,如果你要建造一个
100 英尺长的栅栏,其栅栏柱间隔为 10 英尺,那么你需要多少根栅栏柱呢?11
根或 9 根都是正确答案,这取决于是否要在栅栏的两端树立栅栏柱,但是 10 根
却是错误的。要当心栅栏柱错误,每当你在处理长度、范围或模数的时候,都要
仔细确定其端点是否应该被包括在内,并且要确保你的代码的行为要与其相对
应。
第二个 bug 是在不同的情况(case)中没有任何 break 语句。不论 switch 表达
式为何值,该程序都将执行其相对应的 case 以及所有后续的 case[JLS 14.11]。
因此,尽管每一个 case 都对变量 word 赋了一个值,但是总是最后一个赋值胜出,
覆盖了前面的赋值。最后一个赋值将总是最后一种情况(default),即 new
StringBuffer{‘M’}。这表明该程序将总是打印Main,而从来不打印Pain或Gain。
在 switch 的各种情况中缺少 break 语句是非常常见的错误。从 5.0 版本起,javac
提供了-Xlint:fallthrough 标志,当你忘记在一个 case 与下一个 case 之间添
加 break 语句是,它可以生成警告信息。不要从一个非空的 case 向下进入了另
一个 case。这是一种拙劣的风格,因为它并不常用,因此会误导读者。十次中
有九次它都会包含错误。如果 Java 不是模仿 C 建模的,那么它倒是有可能不需
要 break。对语言设计者的教训是:应该考虑提供一个结构化的 switch 语句。
最后一个,也是最微妙的一个 bug 是表达式 new StringBuffer(‘M’)可能没有做
哪些你希望它做的事情。你可能对 StringBuffer(char)构造器并不熟悉,这很
容易解释:它压根就不存在。StringBuffer 有一个无参数的构造器,一个接受
一个 String 作为字符串缓冲区初始内容的构造器,以及一个接受一个 int 作为
缓冲区初始容量的构造器。在本例中,编译器会选择接受 int 的构造器,通过拓
宽原始类型转换把字符数值’M’转换为一个 int 数值 77[JLS 5.1.2]。换句话说,
new StringBuffer(‘M’)返回的是一个具有初始容量 77 的空的字符串缓冲区。该
程序余下的部分将字符 a、i 和 n 添加到了这个空字符串缓冲区中,并打印出该
字符串缓冲区那总是 ain 的内容。
为了避免这类问题,不管在什么时候,都要尽可能使用熟悉的惯用法和 API。如
果你必须使用不熟悉的 API,那么请仔细阅读其文档。在本例中,程序应该使用
常用的接受一个 String 的 StringBuffer 构造器。
下面是该程序订正了这三个 bug 之后的正确版本,它将以均等的概率打印 Pain、
Gain 和 Main:
1 | import java.util.Random; |
尽管这个程序订正了所有的 bug,它还是显得过于冗长了。下面是一个更优雅的
版本:
1 | import java.util.Random; |
下面是一个更好的版本。尽管它稍微长了一点,但是它更加通用。它不依赖于所
有可能的输出只是在它们的第一个字符上有所不同的这个事实:
1 | import java.util.Random; |
总结一下:首先,要当心栅栏柱错误。其次,牢记在 switch 语句的每一个 case
中都放置一条 break 语句。第三,要使用常用的惯用法和 API,并且当你在离开老路子的时候,一定要参考相关的文档。第四,一个 char 不是一个 String,
而是更像一个 int。最后,要提防各种诡异的谜题。