Java 谜题 Java 谜题 6——库谜题
谜题 56:大问题
作为一项热身活动,我们来测试一下你对 BigInteger 的了解程度。下面这个程
1 | import java.math.sBigInteger; |
你可能会认为这个程序会打印出 555000。毕竟,它将 total 设置为用 BigInteger
表示的 0,然后将 5,000、50,000 和 500,000 加到了这个变量上。如果你运行该
程序,你就会发现它打印的不是 555000,而是 0。很明显,所有这些加法对 total
没有产生任何影响。
对此有一个很好理由可以解释:BigInteger 实例是不可变的。String、
BigDecimal 以及包装器类型:Integer、Long、Short、Byte、Character、Boolean、
Float 和 Double 也是如此,你不能修改它们的值。我们不能修改现有实例的值,
对这些类型的操作将返回新的实例。起先,不可变类型看起来可能很不自然,但是它们具有很多胜过与其向对应的可变类型的优势。不可变类型更容易设计、实
现和使用;它们出错的可能性更小,并且更加安全[EJ Item 13]。
为了在一个包含对不可变对象引用的变量上执行计算,我们需要将计算的结果赋
值给该变量。这样做就会产生下面的程序,它将打印出我们所期望的 555000:
1 | import java.math.BigInteger; |
本谜题的教训是:不要被误导,认为不可变类型是可变的。这是一个在刚入门的
Java 程序员中很常见的错误。公正地说,Java 不可变类型的某些方法名促使我
们走上了歧途。像 add、subtract 和 negate 之类的名字似乎是在暗示这些方法
将修改它们所调用的实例。也许 plus、minus 和 negation 才是更好的名字。
对 API 设计来说,其教训是:在命名不可变类型的方法时,应该优选介词和名词,
而不是动词。介词适用于带有参数的方法,而名词适用于不带参数的方法。对语
言设计者而言,其教训与谜题 2 相同,那就是应该考虑对操作符重载提供有限的
支持,这样算数操作符就可以作用于诸如 BigInteger 这样的数值型的引用类型。
由此,即使是初学者也不会认为计算表达式 total + fiveThousand 将会对 total
的值产生任何影响。
谜题 57:名字里有什么?
下面的程序包含了一个简单的不可变类,它表示一个名字,其 main 方法将一个
名字置于一个集合中,并检查该集合是否确实包含了该名字。那么,这个程序到
底会打印出什么呢?
1 | import java.util.*; |
一个 Name 实例由一个姓和一个名构成。两个 Name 实例在通过 equals 方法进行
计算时,如果它们的姓相等且名也相等,则这两个 Name 实例相等。姓和名是用
在 String 中定义的 equals 方法来比较的,两个字符串如果以相同的顺序包含相
同的若干个字符,那么它们就相等。因此,两个 Name 实例如果表示相同的名字,
那么它们就相等。例如,下面的方法调用将返回 true:
new Name(“Mickey”, “Mouse”).equals(new Name(“Mickey”, “Mouse”))
该程序的 main 方法创建了两个 Name 实例,它们都表示 Mickey Mouse。该程序
将第一个实例放置到了一个散列集合中,然后检查该集合是否包含第二个实例。
这两个 Name 实例是相等的,因此看起来该程序似乎应该打印 true。如果你运行
它,几乎可以肯定它将打印 false。那么这个程序出了什么问题呢?
这里的 bug 在于 Name 违反了 hashCode 约定。这看起来有点奇怪,因为 Name 连
hashCode 都没有,但是这确实是问题所在。Name 类覆写了 equals 方法,而
hashCode 约定要求相等的对象要具有相同的散列码。为了遵守这项约定,无论
何时,只要你覆写了 equals 方法,你就必须同时覆写 hashCode 方法[EJ Item 8]。
因为 Name 类没有覆写 hashCode 方法,所以它从 Object 那里继承了其 hashCode
实现。这个实现返回的是基于标识的散列码。换句话说,不同的对象几乎总是产
生不相等的散列值,即使它们是相等的也是如此。所以说 Name 没有遵守 hashCode
的约定,因此包含 Name 元素的散列集合的行为是不确定的。
当程序将第一个 Name 实例放置到散列集合中时,该集合就会在某个散列位置上
放置这个实例对应的项。该集合是基于实例的散列值来选择散列位置的,这个散
列值是通过实例的 hashCode 方法计算出来的。
当该程序在检查第二个 Name 实例是否包含在散列集合中时,它基于第二个实例
的散列值来选择要搜索的散列位置。因为第二个实例有别于第一个实例,因此它
极有可能产生不同的散列值。如果这两个散列值映射到了不同的位置,那么contains 方法将返回 false:我们所喜爱的啮齿动物米老鼠就在这个散列集合
中,但是该集合却找不到他。
假设两个 Name 实例映射到了相同的位置,那又会怎样呢?我们所了解的所有的
HashSet 实现都进行了一种优化,即每一项在存储元素本身之外,还存储了元素
的散列值。在搜索某个元素时,这种实现通过遍历集合中的项,去拿存储在每一
项中的散列值与我们想要查找的元素的散列值进行比较,从而选取适当的散列位
置。只有在两个元素的散列值相等的情况下,这种实现才会认为这两个元素相等。
这种优化是有实际意义的,因为比较散列码相对于比较元素来说,其代价要小得
多。
对散列集合来说,这项优化并不足以使其能够搜索到正确的位置;两个 Name 实
例必须具有相同的散列值才能让散列集合能够将它们识别为是相等的。该程序偶
尔也会打印出 true,这是因为被连续创建的两个对象偶尔也会具有相同的标识
散列码。一个粗略的实验表明,这种偶然性出现的概率大约是 25,000,000 分之
一。这个实验的结果可能会因所使用的 Java 实现的不同而有所变化,但是在任
何我们所知的 JRE 上,你基本上是不可能看到该程序打印出 true 的。
要想订正该程序,只需在 Name 类中添加一个恰当的 hashCode 方法即可。尽管任
何其返回值仅有姓和名来确定的方法都可以满足 hashCode 的约定,但是高质量
的散列函数应该尝试着对不同的名字返回不同的散列值。下面的方法就能够很好
地实现这一点[EJ Item 8]。只要我们把该方法添加到了程序中,那么该程序就
可以打印出我们所期望的 true:
1 | public int hashCode() { |
总之,当你覆写 equals 方法时,一定要记着覆写 hashCode 方法。更一般地讲,
当你在覆写一个方法时,如果它具有一个通用的约定,那么你一定要遵守它。对
于大多数在Object中声明的非final的方法,都需要注意这一点[EJ Chapter 3]。
不采用这项建议就会导致任意的、不确定的行为。
谜题 58:产生它的散列码 产生它的散列码
本谜题试图从前一个谜题中吸取教训。下面的程序还是由一个 Name 类和一个
main 方法构成,这个 main 方法还是将一个名字放置到一个散列集合中,然后检
查该集合是否包含了这个名字。然而,这一次 Name 类已经覆写了 hashCode 方法。
那么下面的程序将打印出什么呢?
1 | import java.util.*; |
与谜题 57 一样,该程序的 main 方法创建了两个 Name 实例,它们表示的是相同
的名字。这一次使用的名字是 Donald Duck 而不是 Mickey Mouse,但是它们不
应该有很大的区别。main 方法同样还是将第一个实例置于一个散列集合中,然
后检查该集合中是否包含了第二个实例。这一次 hashCode 方法明显是正确的,
因此看起来该程序应该打印 true。但是,表象再次欺骗了我们:它总是打印出
false。这一次又是哪里出错了呢?
这个程序的缺陷与谜题 57 中的缺陷很相似,在谜题 57 中,Name 覆写了 equals
方法,但是没有覆写 hashCode 方法;而在本谜题中,Name 覆写了 hashCode 方
法,但是没有覆写 equals 方法。这并不是说 Name 没有声明一个 equals 方法,
它确实声明了,但是那是个错误的声明。Name 类声明了一个参数类型是 Name 而
不是 Object 的 equals 方法。这个类的作者可能想要覆写 equals 方法,但是却
错误地重载了它[JLS 8.4.8.1, 8.4.9]。
HashSet 类是使用 equals(Object)方法来测试元素的相等性的;Name 类中声明
一个 equals(Name)方法对 HashSet 不造成任何影响。那么 Name 是从哪里得到了
它的 equals(Object)方法的呢?它是从 Object 哪里继承而来的。这个方法只有
在它的参数与在其上调用该方法的对象完全相同时才返回 true。我们的程序中
的 main 方法将一个 Name 实例插入到了散列集合中,并且测试另一个实例是否存
在于该散列集合中,由此可知该测试一定是返回 false 的。对我们而言,两个实
例可以代表那令人惊奇的水禽唐老鸭,但是对散列映射表而言,它们只是两个不
相等的对象。
订正该程序只需用可以在谜题 57 中找到的覆写的 equals 方法来替换重载的
equals 方法即可。通过使用这个 equals 方法,该程序就可以打印出我们所期望
的 true:
1 | public boolean equals(Object o) { |
要让该程序可以正常工作,你只需增加一个覆写的 equals 方法即可。你不必剔
除那个重载的版本,但是你最好是删掉它。重载为错误和混乱提供了机会[EJ
Item 26]。如果兼容性要求强制你必须保留一个自身类型的 equals 方法,那么
你应该用自身类型的重载去实现 Object 的重载,以此来确保它们具有相同的行
为:
1 | public boolean equals(Object o) { return o instanceof Name && equals((Name) |
本谜题的教训是:当你想要进行覆写时,千万不要进行重载。为了避免无意识地
重载,你应该机械地对你想要覆写的每一个超类方法都拷贝其声明,或者更好的
方式是让你的 IDE 帮你去做这些事。这样做除了可以保护你免受无意识的重载之
害,而且还可以保护你免受拼错方法名之害。如果你使用的 5.0 或者更新的版本,
那么对于那些意在覆写超类方法的方法,你可以将@Override 注释应用于每一个
这样的方法的声明上:
1 | @Override public Boolean equals(Object o) { ... } |
在使用这个注释时,除非被注释的方法确实覆写了一个超类方法,否则它将不能
编译。对语言设计者来说,值得去考虑在每一个覆写超类方法的方法声明上都添
加一个强制性的修饰符。
谜题 59:什么是差?
下面的程序在计算一个 int 数组中的元素两两之间的差,将这些差置于一个集合
中,然后打印该集合的尺寸大小。那么,这个程序将打印出什么呢?
1 | import java.util.*; |
外层循环迭代数组中的每一个元素,而内层循环从外层循环当前迭代到的元素开
始迭代到数组中的最后一个元素。因此,这个嵌套的循环将遍历数组中每一种可能的两两组合。(元素可以与其自身组成一对。)这个嵌套循环中的每一次迭代
都计算了一对元素之间的差(总是正的),并将这个差存储到了集合中,集合是
可以消除重复元素的。因此,本谜题就带来了一个问题,在由 vals 数组中的元
素结成的对中,有多少唯一的正的差存在呢?
当你仔细观察程序中的数组时,会发现其构成模式非常明显:连续两个元素之间
的差总是 111。因此,两个元素之间的差是它们在数组之间的偏移量之差的函数。
如果两个元素是相同的,那么它们的差就是 0;如果两个元素是相邻的,那么它
们的差就是 111;如果两个元素被另一个元素分割开了,那么它们的差就是 222;
以此类推。看起来不同的差的数量与元素间不同的距离的数量是相等的,也就是
等于数组的尺寸,即 8。如果你运行该程序,就会发现它打印的是 14。怎么回事
呢?
上面的分析有一个小的漏洞。要想了解清楚这个缺陷,我们可以通过将 println
语句中的.size()这几个字符移除掉,来打印出集合中的内容。这么做会产生下
面的输出:
[111,222,446,557,668,113,335,444,779,224,0,333,555,666]
这些数字并非都是 111 的倍数。在 vals 数组中肯定有两个毗邻的元素的差是
113。如果你观察该数组的声明,不可能很清楚地发现原因所在:
int vals[ ] = { 789, 678, 567, 456, 345, 234, 123, 012 };
但是如果你打印数组的内容,你就会看见下面的内容:
[789,678,567,456,345,234,123,10]
为什么数组中的最后一个元素是 10 而不是 12 呢?因为以 0 开头的整数类型字面
常量将被解释成为八进制数值[JLS 3.10.1]。这个隐晦的结构是从 C 编程语言那
里遗留下来东西,C 语言产生于 1970 年代,那时八进制比现在要通用得多。
一旦你知道了 012 == 10,就会很清楚为什么该程序打印出了 14:有 6 个不涉及
最后一个元素的唯一的非 0 差,有 7 个涉及最后一个元素的非 0 差,还有 0,加
在一起正好是 14 个唯一的差。订正该程序的方法更加明显:将八进制整型字面
常量 012 替换为十进制整型字面常量 12。如果你这么做了,该程序将打印出我
们所期望的 8。
本谜题的教训很简单:千万不要在一个整型字面常量的前面加上一个 0;这会使
它变成一个八进制字面常量。有意识地使用八进制整型字面常量的情况相当少
见,你应该对所有的这种特殊用法增加注释。对语言设计者来说,在决定应该包
含什么特性时,应该考虑到其限制条件。当有所迟疑时,应该将它剔除在外。
谜题 60:一行的方法 现在该轮到你写一些代码了。下面的谜题每一个都可以用一个方法来解决,这些
方法的方法体都只包含一行代码。各就各位,预备,编码!
• A.编写一个方法,它接受一个包含元素的 List,并返回一个新的 List,
它以相同的顺序包含相同的元素,只不过它把第二次以及后续出现的重复
元素都剔除了。例如,如果你传递了一个包
含”spam”,”sausage”,”spam”,”spam”,”bacon”,”spam”,”t
omato”和”spam”的列表,那么你将得到一个包
含”spam”,”sausage”,”bacon”,”tomato”的新列表。
• B.编写一个方法,它接受一个由 0 个或多个由逗号分隔的标志所组成的
字符串,并返回一个表示这些标志的字符串数组,数组中的元素的顺序与
这些标志在输入字符串中出现的顺序相同。每一个逗号后面都可能会跟随
0 个或多个空格字符,这个方法忽略它们。例如,如果你传递的字符串
是”fear, surprise, ruthless efficiency, an almost fanatical
devotion to the Pope, nice red uniforms”,那么你得到的将是一个
包含 5 个元素的字符串数组,这些元素
是”fear”,”surprise”,”ruthless efficiency”,”an almost
fanatical devotion to the Pope” 和 “nice red uniform”。
• C.假设你有一个多维数组,出于调试的目的,你想打印它。你不知道这
个数组有多少级,以及在数组的每一级中所存储的对象的类型。编写一个
方法,它可以向你显示出在每一级上的所有元素。
• D.编写一个方法,它接受两个 int 数值,并在第一个数值与第二个数值
以二进制补码形式进行比较,具有更多的位被置位时,返回 true。
A.众所周知,你可以通过把集合(collection)中的元素置于一个 Set 中将集
合中的所有重复元素都消除掉。在本谜题中,你还被要求要保持最初的集合中的
元素顺序。幸运的是,有一种 Set 的实现可以维护其元素被插入的顺序,它提供
的导入性能接近 HashMap。它就是 LinkedHashSet,它是在 1.4 版本的 JDK 中被
添加到 Java 平台中的。在内部,它是用一个链接列表来处理的,从而被实现为
一个散列表。它还有一个映射表版本可供你使用,以定制缓存。一旦你了解了
LinkedHashSet,本谜题就很容易解决了。剩下唯一的关键就是你被要求要返回
一个 List,因此你必须用 LinkedHashSet 的内容来初始化一个 List。把它们放
到一块,就形成了下面的解决方案:
1 | static <E> List<E> withoutDuplicates(List<E> original) { |
B.在将字符串解析成标志时,许多程序员都立刻想到了使用 StringTokenizer。
这是最不幸的事情,自 1.4 版本开始,由于正则表达式被添加到了 Java 平台中
(java.util.regex),StringTokenizer 开始变得过时了。如果你试图通过
StringTokenizer 来解决本谜题,那么你很快就会意识到它不是非常适合。通过
使用正则表达式,它就是小菜一碟。为了在一行代码中解决本谜题,我们要使用
很方便的方法 String.split,它接受一个描述标志分界符的正则表达式作为参
数。如果你以前从来没有使用过正则表达式,那么它们看起来会显得有一点神秘,
但是它们惊人地强大,值得我们好好学习一下:
1 | static String[ ] parse(String string) { |
C.这是一个讲究技巧的问题。你甚至不必去编写一个方法。这个方法在 5.0 或
之后的版本中已经提供了,它就是 Arrays.deepToString。如果你传递给它一个
对象引用的数组,它将返回一个精密的字符串表示。它可以处理嵌套数组,甚至
可以处理循环引用,即一个数组元素直接或间接地引用了其嵌套外层的数组。事
实上,5.0 版本中的 Arrays 类提供了一整套的 toString、equals 和 hashCode
方法,使你能够打印、比较或散列任何原始类型数组或对象引用数组的内容。
D.为了在一行代码中解决该谜题,你需要了解在 5.0 版本中添加到 Java 平台中
的一整套位操作方法。整数类型的包装器类(Integer、Long、Short、Byte 和
Char)现在支持通用的位处理操作,包括 highestOneBit、lowestOneBit、
numberOfLeadingZeros、numberOfTrailingZeros、bitCount、rotateLeft、
rotateRight、reverse、signum 和 reverseBytes。在本例中,你需要的是
Integer.bitCount,它返回的是一个 int 数值中被置位的位数:
1 | static Boolean hasMoreBitsSet(int i, int j) { |
总之,Java 平台的每一个主版本都在其类库中隐藏了一些宝藏。本谜题的所有 4
个部分都依赖于这样的宝藏。每当该平台发布一个新版本时,你都应该研究就一
下新特性和提高(new features and enhancements)页面,这样你就不会遗漏
掉新版本提供的任何惊喜[Features-1.4, Features-5.0]。了解类库中有些什么
可以节省你大量的时间和精力,并且可以提高你的程序的速度和质量。
谜题 61:日期游戏
下面的程序演练了 Date 和 Calendar 类的某些基本特性,它会打印出什么呢?
1 | import java.util.*; |
} 该程序创建了一个 Calendar 实例,它应该表示的是 1999 年的除夕夜,然后该程
序打印年份和日。看起来该程序应该打印 1999 31,但是它没有;它打印的是 2000
1。难道这是致命的 Y2K(千年虫)问题吗?
不,事情比我们想象的要糟糕得多:这是致命的 Date/Calendar 问题。在 Java
平台首次发布时,它唯一支持日历计算类的就是 Date 类。这个类在能力方面是
受限的,特别是当需要支持国际化时,它就暴露出了一个基本的设计缺陷:Date
实例是易变的。在 1.1 版中,Calendar 类被添加到了 Java 平台中,以矫正 Date
的缺点,由此大部分的 Date 方法就都被弃用了。遗憾的是,这么做只能使情况
更糟。我们的程序说明 Date 和 Calendar API 有许多问题。
该程序的第一个 bug 就位于方法调用 cal.set(1999,12,31)中。当月份以数字来
表示时,习惯上我们将第一个月被赋值为 1。遗憾的是,Date 将一月表示为 0,
而 Calendar 延续了这个错误。因此,这个方法调用将日历设置到了 1999 年第
13 个月的第 31 天。但是标准的(西历)日历只有 12 个月,该方法调用肯定应
该抛出一个 IllegalArgumentException 异常,对吗?它是应该这么做,但是它
并没有这么做。Calendar 类直接将其替换为下一年,在本例中即 2000 年的第一
个月。这也就解释了我们的程序为什么打印出的第一个数字是 2000。
有两种方法可以订正这个问题。你可以将 cal.set 调用的第二个参数由 12 改为
11,但是这么做容易引起混淆,因为数字 11 会让读者误以为是 11 月。更好的方
式是使用 Calendar 专为此目的而定义的常量,即 Calendar.DECEMBER。
该程序打印出的第二个数字又是怎么回事呢?cal.set 调用很明显是要把日历
设置到这个月的第 31 天,Date 实例 d 表示的是与 Calendar 相同的时间点,因
此它的 getDay 方法应该返回 31,但是程序打印的却是 1,这是怎么搞得呢?
为了找出原因,你必须先阅读一下文档,它叙述道 Date.getDay 返回的是 Date
实例所表示的星期日期,而不是月份日期。这个返回值是基于 0 的,从星期天开
始计算。因此程序所打印的 1 表示 2000 年 1 月 31 日是星期一。请注意,相应的
Calendar 方法 get(Calendar.DAY_OF_WEEK) 不知为什么返回的是基于 1 的星期
日期值,而不是像 Date 的对应方法那样返回基于 0 的星期日期值。
有两种方法可以订正这个问题。你可以调用 Date.date 这一名字极易让人混淆的
方法,它返回的是月份日期。然而,与大多数 Date 方法一样,它已经被弃用了,
因此你最好是将 Date 彻底抛弃,直接调用 Calendar 的
get(Calendar.DAY_OF_MONTH)方法。用这两种方法,该程序都可以打印出我们想
要的 1999 31:
1 | public class DatingGame { |
本谜题只是掀开了 Calendar 和 Date 缺陷的冰山一角。这些 API 简直就是雷区。
Calendar 其他的严重问题包括弱类型(几乎每样事物都是一个 int)、过于复杂
的状态空间、拙劣的结构、不一致的命名以及不一致的雨衣等。在使用 Calendar
和 Date 的时候一定要当心,千万要记着查阅 API 文档。
对 API 设计者来说,其教训是:如果你不能在第一次设计时就使它正确,那么至
少应该在第二次设计时应该使它正确,绝对不能留到第三次设计时去处理。如果
你对某个 API 的首次尝试出现了严重问题,那么你的客户可能会原谅你,并且会
再给你一次机会。如果你第二次尝试又有问题,你可能会永远坚持这些错误了。
谜题 62:名字游戏
下面的程序将两个映射关系放置到了一个映射表中,然后打印它们的尺寸。那么,
它会打印出什么呢?
1 | import java.util.*; |
对该程序的一种幼稚的分析认为,它应该打印 1。该程序虽然将两个映射关系放
置到了映射表中,但是它们具有相同的键(Mickey)。这是一个映射表,不是一
个多重映射表,所以棒球传奇人物(Mickey Mantle)应该覆盖了啮齿类动画明
星(Mickey Mouse),从而只留下一个映射关系在映射表中。
更透彻一些的分析会对这个预测产生质疑。IdentityHashMap 的文档中叙述道:
“这个类用一个散列表实现了 Map 接口,它在比较键时,使用的是引用等价性而
不是值等价性”[Java-API]。换句话说,如果第二次出现的字符串字面常量
“Mickey”被计算出来是与第一次出现的“Mickey”字符串不同的 String 实例
的话,那么该程序应该打印 2 而不是 1。如此说来,该程序到底是打印 1,还是
打印 2,抑或是其行为会根据不同的实现而有所变化?
如果你试着运行该程序,你就会发现,尽管我们那个幼稚的分析是有缺陷的,但
是该程序正如这种分析所指出的一样,打印出来的是 1。这是为什么呢?语言规
范保证了字符串是内存限定的,换句话说,相等的字符串常量同时也是相同的
[JLS 15.28]。这可以确保在我们的程序中第二次出现的字符串字面常量“Mickey”引用到了与第一次相同的 String 实例上,因此尽管我们使用了一个
IdentityHashMap 来代替诸如 HashMap 这样的通用目的的 Map 实现,但是对程序
的行为却不会产生任何影响。我们那个幼稚的分析忽略了两个细节,但是这些细
节造成的影响却彼此有效地抵消了。
本谜题的一个重要教训是:不要使用 IdentityHashMap,除非你需要其基于标识
的语义;它不是一个通用目的的 Map 实现。这些语义对于实现保持拓扑结构的对
象图转换(topology-preserving object graph transformations)非常有用,
例如序列化和深层复制。我们得到的次要教训是字符串常量是内存限定的。正如
在谜题 13 中所述,在任何时候,程序都应该尽量不依赖于这种行为去保证它们
的操作正确。
谜题 63:更多同样的问题 更多同样的问题
下面的程序除了是面向对象的这一点之外,与前一个非常相似。因为从前一个程
序中已经吸取了教训,这个程序使用了一个通用目的的 Map 实现,即一个
HashMap,来替代前一个程序的 IdentityHashMap。那么,这个程序会打印出什
么呢?
1 | import java.util.*; |
这个程序看起来很直观,其 main 方法通过调用无参数的构造器创建了一个
MoreNames 实例。这个 MoreNames 实例包含一个私有的 Map 域(m),它被初始
化成一个空的 HashMap。该无参数的构造器似乎将两个映射关系放置到了映射表
m 中,这两个映射关系都具有相同的键(Mickey)。我们从前一个谜题已知,棒
球手(Mickey Mantle)应该覆盖啮齿明星(Mickey Mouse),从而只留下一个
映射关系。main 方法之后在 MoreNames 实例上调用了 size 方法,它会调用映射
表 m 上的 size 方法,并返回结果,我们假设其为 1。这种分析还剩下一个问题:
该程序打印的是 0 而不是 1。这种分析出了什么错呢? 问题在于 MoreNames 没有任何程序员声明的构造器。它拥有的只是一个返回值为
void 的实例方法,即 MoreNames,作者可能是想让它作为构造器的。遗憾的是,
返回类型(void)的出现将想要的构造器声明变成了一个方法声明,而且该方法
永远都不会被调用。因为 MoreNames 没有任何程序员声明的构造器,所以编译器
会帮助(真的是在帮忙吗?)生成一个公共的无参数构造器,它除了初始化它所
创建的域实例之外,不做任何事情。就像前面提到的,m 被初始化成了一个空的
HashMap。当在这个 HashMap 上调用 size 方法时,它将返回 0,这正是该程序打
印出来的内容。
订正该程序很简单,只需将 void 返回类型从 MoreNames 声明中移除即可,这将
使它从一个实例方法声明变成一个构造器声明。通过这种修改,该程序就可以打
印出我们所期望的 1。
本谜题的教训是:不要因为偶然地添加了一个返回类型,而将一个构造器声明变
成了一个方法声明。尽管一个方法的名字与声明它的类的名字相同是合法的,但
是你千万不要这么做。更一般地讲,要遵守标准的命名习惯,它强制要求方法名
必须以小写字母开头,而类名应该以大写字母开头。
对语言设计者来说,在没有任何程序员声明的构造器的情况下,自动生成一个缺
省的构造器这种做法并非是一个很好的主意。如果确实生成了这样的构造器,也
许应该让它们是私有的。有好几种其他的方法可以消除这个陷阱。一种方法是禁
止方法名与类名相同,就像 C#所作的那样,另一种是彻底消灭所有的构造器,
就像 Smalltalk 所作的那样。
谜题 64:按余数编组
下面的程序将生成整数对 3 取余的柱状图,那么,它将打印出什么呢?
1 | public class Mod { |
该程序首先初始化 int 数组 histogram,其每一个位置都为对 3 取余的一个数值
而准备(0、1 和 2),所有这三个位置都被初始化为 0。然后,该程序在所有 232
个 int 数值上遍历变量 i,使用的是在谜题 26 中介绍的惯用法。因为整数取余操作(%)在第一个操作数是负数时,可以返回一个负值,就像在谜题 1 中所描
述的那样,所以该程序在计算 i 被 3 整除的余数之前,先取 i 的绝对值。然后用
这个余数来递增数组位置的索引。在循环完成之后,该程序将打印 histogram
数组中的内容,它的元素表示对 3 取余得到 0、1 和 2 的 int 数值的个数。
该程序所打印的三个数字应该彼此大致相等,它们加起来应该等于 232。如果你
想知道怎样计算出它们的精确值,那么你需要有一点数学气质,并仔细阅读下面
两段话。否则,你可以跳过这两段话。
该程序打印的三个数字不可能精确地相等,因为它们必须加起来等于 232,这个
数字不能被 3 除尽。如果你仔细观察 2 的连续幂级数对 3 取余的值,就会发现,
它们在 1 和 2 之间交替变化:20 对 3 取余是 1,21 对 3 取余是 2,22 对 3 取余
是 1,23 对 3 取余是 2,以此类推。每一个 2 的偶次幂对 3 取余的值都是 1,每
一个 2 的奇次幂对 3 取余的值都是 2。因为 232 对 3 取余是 1,所以该程序所打
印的三个数字中有一个将比另外两个大 1,但是它是哪一个呢?
该循环依次递增三个数组元素的数值,因此该循环最后递增的那个数值必然是最
大的数值,它就是表示 Integer.MAX_VALUE 或(232-1)对 3 取余的数值。因为 231
是 2 的奇次幂,所以它对 3 取余应该得到 2,因此(232-1)对 3 取余将得到 1。
该程序打印的三个数字中的第二个表示的就是对 3 取余得到 1 的 int 数值的个
数,因此,我们期望这个值比第一个和最后一个数值大 1。
由此,该程序应该在运行了相当长的时间之后,打印(232/3)的较小值 (232/3)
的较大值 (232/3)的较小值,即 1431655765 1431655766 1431655765。但是它
真的是这么做的吗?不,它几乎立刻就抛出了下面的异常:
1 | Exception in thread "main" ArrayIndexOutOfBoundsException: -2 |
问题出在哪了呢?
问题在于该程序对 Math.abs 方法的使用上,它会导致错误的对 3 取余的数值。
考虑一下当 i 为 -2 时所发生的事情,该程序计算 Math.abs(-2) % 3 的数值,
得到 2,但是-2 对 3 取余应该得到 1。这可以解释为什么产生了不正确的统计结
果,但是还有一个问题留待解决,为什么程序抛出了
ArrayIndexOutOfBoundsException 异常呢?这个异常表明该程序使用了一个负
的数组索引,但是这肯定是不可能的:数组索引是通过的接受 i 的绝对值并计算
这个绝对值被 3 整除时的余数而计算出来的。在计算一个非负的 int 数值整除一
个正的 int 数值的余数时,可以保证将产生一个非负的结果[JLS 15.17.3]。我
们又要问了,这里又出了什么问题呢?
要回答这个问题,我们必须要去看看 Math.abs 的文档。这个方法的名字有一点
带有欺骗性,它几乎总是返回它的参数的绝对值,但是在有一种情况下,它做不
到这一点。文档中叙述道:“如果其参数等于 Integer.MIN_VALUE,那么产生的
结果与该参数相同,它是一个负数。”通过对这条知识的掌握,就可以很清楚地知道为什么该程序立即抛出了 ArrayIndexOutOfBoundsException 异常。循环索
引 i 的初始值是 Integer.MIN_VALUE,由 Math.abs(Integer.MIN_VALUE) % 3 所
产生的数组索引等于 Integer.MIN_VALUE % 3,即 -2。
为了订正这个程序,我们必须用一个真正的取余操作来替代伪取余计算
(Math.abs(i) % MODULUS)。如果我们将这个表达式替换为对下面这个方法的调
用,那么该程序就可以产生我们做期望的输出 1431655765 1431655766
1431655765:
1 | private static int mod(int i, int modulus) { |
本谜题的教训是:Math.abs 不能保证一定会返回非负的结果。如果它的参数是
Integer.MIN_VALUE,或者对于 long 版本的实现传递的是 Long.MIN_VALUE,那
么它将返回它的参数。这个方法在一般情况下是不会这么做的,上述这种行为的
根源在于 2 的补码算数具有不对称性,这在谜题 33 中已经很详细的讨论过了。
简单地讲,没有任何 int 数值可以表示 Integer.MIN_VALUE 的负值,也没有任何
long 数值可以表示 Long.MIN_VALUE 的负值。对类库的设计者来说,也许在将
Integer.MIN_VALUE 和 Long.MIN_VALUE 传递给 Math.abs 时,抛出
IllegalArgumentException 会显得更合理。然而,有人可能会争辩道,该方法
的实际行为应该与 Java 内置的整数算术操作相一致,它们在溢出时并不会抛出
异常。
谜题 65:一种疑似排序的惊人传奇 一种疑似排序的惊人传奇
下面的程序使用定制的比较器,对一个由随机挑选的 Integer 实例组成的数组进
行排序,然后打印了一个描述了数组顺序的单词。回忆一下,Comparator 接口
只有一个方法,即 compare,它在第一个参数小于第二个参数时返回一个负数,
在两个参数相等时返回 0,在第一个参数大于第二个参数时返回一个整数。这个
程序是展示 5.0 版特性的一个样例程序。它使用了自动包装和解包、泛型和枚举
类型。那么,它会打印出什么呢?
1 | import java.util.*; |
该程序的 main 方法创建了一个 Integer 实例的数组,并用随机数对其进行了初
始化,然后用比较器 cmp 对该数组进行排序。这个比较器的 compare 方法将返回
它的第二个参数减去第一个参数的值,如果第二个参数表示的是比第一个参数大
的数值,其返回值就是正的;如果这两个参数相等,其返回值为 0;如果第二个
参数表示的是比第一个参数小的数值,其返回值就是负的。这种行为正好与
compare 方法通常的做法相反,因此,该比较器应该施加的是降序排列。
在对数组排序之后,main 方法将该数组传递给了静态方法 order,然后打印由这
个方法返回的结果。该方法在数组中所有的元素都表示相同的数值时,返回
CONSTANT;在数组中每一对毗邻的元素中第二个元素都大于等于第一个元素时,
返回 ASCENDING;在数组中每一对毗邻的元素中第二个元素都小于等于第一个元
素时,返回 DESCENDING;在这些条件都不满足时,返回 UNORDERED。尽管理论上
说,数组中的 100 个随机数有可能彼此都相等,但是这种奇特现象发生的非常小:
232×99 分之一,即大约 5×10953 分之一。因此,该程序看起来应该打印
DESCENDING。如果你运行该程序,几乎可以肯定你将看到它打印的是 UNORDERED。
为什么它会产生如此的行为呢?
order 方法很直观,它并不会说谎。Arrays.sort 方法已经存在许多年了,它工
作得非常好。现在只有一个地方能够发现 bug 了:比较器。乍一看,这个比较器
似乎不可能出错。毕竟,它使用的是标准的惯用法:如果你有两个数字,你想得
到一个数值,其符号表示它们的顺序,那么你可以计算它们的差。这个惯用法至少从 1970 年代早期就一直存在了,它在早期的 UNIX 里面被广泛地应用。遗憾的
是,这种惯用法从来都没有正确地工作过。本谜题也许应该称为“白痴一般的惯
用法的案例”。这种惯用法的问题在于定长的整数没有大到可以保存任意两个同
等长度的整数之差的程度。当你在做两个 int 或 long 数值的减法时,其结果可
能会溢出,在这种情况下我们就会得到错误的符号。
例如,请考虑下面的程序:
1 | public class Overflow { |
很明显,x 比 z 小,但是程序打印的是 294967296,它是一个正数。既然这种比
较的惯用法是有问题的,那么为什么它会被如此广泛地应用呢?因为它在大多数
时间里可以正常工作的。它只在用来来进行比较的两个数字的差大于
Integer.MAX_VALUE 的时候才会出问题。这意味着对于许多应用而言,在实际使
用中是不会看到这种错误的。更糟的是,它们被观察到的次数少之又少,以至于
这个 bug 永远都不会被发现和订正。
那么这对于我们的程序的行为意味着什么呢?如果你查阅一下 Comparator 的文
档,你就会看到它所实现的排序关系必须是可传递的(transitive),换句话说,
(compare(x,y) > 0)&&(compare(y,z) > 0)蕴含着 compare(x,z) > 0。如果我
们取 Overflow 例子中的 x 和 z,并取 y 为 0,那么我们的比较器在这些数值上就
违反了可传递性。事实上,在所有随机选取的 int 数值对中,有四分之一该比较
器都会返回错误的值。用这样的比较器来执行一个搜索或排序,或者用它去排序
一个有序的集合,都会产生不确定的行为,就像我们在运行本谜题的程序时所看
到的那样。出于数学上的倾向性,Comparator.compare 方法的一般约定要求比
较器要产生一个全序(total order),但是这个比较器在数个计算上都未能做
到这一点。
我们可以通过替换遵守上述一般约定的 Comparator 实现来订正我们的程序。因
为我们只是想要反转自然排序的顺序,所以我们甚至可以不必编写我们自己的比
较器。Collection 类提供了一个可以产生这种顺序的比较器。如果你用
Arrays.sort(arr,Collections.reverseOrder())来替代最初的 Arrays.sort 调
用,该程序就可以打印出我们所期望的 DESCENDING。
或者,你可以编写你自己的比较器。下面的代码并不“聪明”,但是它可以工作,
从而使该程序可以打印出我们所期望的 DESCENDING:
1 | public int compare(Integer i1, Integer i2) { |
本谜题有数个教训,最具体的是:不要使用基于减法的比较器,除非你能够确保
要比较的数值之间的差永远不会大于 Integer.MAX_VALUE [EJ Item 11]。更一
般地讲,要意识到 int 的溢出,就像谜题 3、26 和 33 所讨论的那样。另一个教
训是你应该避免“聪明”的代码。应该努力去编写清晰正确的代码,不要对它作
任何优化,除非该优化被证明是必需的[EJ Item 37]。
对语言设计者来说,得到的教训与谜题 3、26 和 33 相同:也许真的值得去考虑
支持某种形式整数算数运算,它不会在溢出时不抛出异常。还有就是可能应该在
语言中提供一个三值的比较器操作符,就像 Perl 所作的那样(<=>操作符)。