Java谜题6——库谜题

Java谜题6——库谜题谜题56:大问题 | 谜题57:名字里有什么? | 谜题58:产生它的散列码 | 谜题59:什么是差? | 谜题60:一行的方法 | 谜题61:日期游戏 | 谜题62:名字游戏 | 谜题63:更多同样的问题 | 谜题64:按余数编组 | 谜题65:一种疑似排序的惊人传奇 谜题56:大问题作为一项热身活动,我们来测试一下你对BigInteger的了解程度。下面这个程序将打印出什么呢? import java.math.BigInteger; public class BigProblem { public static void main(String[ ] args) { BigInteger fiveThousand = new BigInteger("5000"); BigInteger fiftyThousand = new BigInteger("50000"); BigInteger fiveHundredThousand = new BigInteger("500000"); BigInteger total = BigInteger.ZERO; total.add(fiveThousand); total.add(fiftyThousand); total.add(fiveHundredThousand); System.out.println(total); } } 你可能会认为这个程序会打印出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: import java.math.BigInteger; public class BigProblem { public static void main(String[] args) { BigInteger fiveThousand = new BigInteger("5000"); BigInteger fiftyThousand = new BigInteger("50000"); BigInteger fiveHundredThousand = new BigInteger("500000"); BigInteger total = BigInteger.ZERO; total = total.add(fiveThousand); total = total.add(fiftyThousand); total = total.add(fiveHundredThousand); System.out.println(total); } } 本谜题的教训是:不要被误导,认为不可变类型是可变的。这是一个在刚入门的Java程序员中很常见的错误。公正地说,Java不可变类型的某些方法名促使我们走上了歧途。像add、subtract和negate之类的名字似乎是在暗示这些方法将修改它们所调用的实例。也许plus、minus和negation才是更好的名字。 对API设计来说,其教训是:在命名不可变类型的方法时,应该优选介词和名词,而不是动词。介词适用于带有参数的方法,而名词适用于不带参数的方法。对语言设计者而言,其教训与谜题2相同,那就是应该考虑对操作符重载提供有限的支持,这样算数操作符就可以作用于诸如BigInteger这样的数值型的引用类型。由此,即使是初学者也不会认为计算表达式total + fiveThousand将会对total的值产生任何影响。 谜题57:名字里有什么?下面的程序包含了一个简单的不可变类,它表示一个名字,其main方法将一个名字置于一个集合中,并检查该集合是否确实包含了该名字。那么,这个程序到底会打印出什么呢? import java.util.*; public class Name { private String first, last; public Name(String first, String last) { this.first = first; this.last = last; } public boolean equals(Object o) { if (!(o instanceof Name)) return false; Name n = (Name)o; return n.first.equals(first) && n.last.equals(last); } public static void main(String[] args) { Set s = new HashSet(); s.add(new Name("Mickey", "Mouse")); System.out.println( s.contains(new Name("Mickey", "Mouse"))); } } 一个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: public int hashCode() { return 37 * first.hashCode() + last.hashCode(); } 总之,当你覆写equals方法时,一定要记着覆写hashCode方法。更一般地讲,当你在覆写一个方法时,如果它具有一个通用的约定,那么你一定要遵守它。对于大多数在Object中声明的非final的方法,都需要注意这一点[EJ Chapter 3]。不采用这项建议就会导致任意的、不确定的行为。 谜题58:产生它的散列码本谜题试图从前一个谜题中吸取教训。下面的程序还是由一个Name类和一个main方法构成,这个main方法还是将一个名字放置到一个散列集合中,然后检查该集合是否包含了这个名字。然而,这一次Name类已经覆写了hashCode方法。那么下面的程序将打印出什么呢? import java.util.*; public class Name { private String first, last; public Name(String first, String last) { this.first = first; this.last = last; } public boolean equals(Name n) { return n.first.equals(first) && n.last.equals(last); } public int hashCode() { return 31 * first.hashCode() + last.hashCode(); } public static void main(String[ ] args) { Set s = new HashSet(); s.add(new Name("Donald", "Duck")); System.out.println( s.contains(new Name("Donald", "Duck"))); } } 与谜题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: public boolean equals(Object o) { if (!(o instanceof Name)) return false; Name n = (Name)o; return n.first.equals(first) && n.last.equals(last); } 要让该程序可以正常工作,你只需增加一个覆写的equals方法即可。你不必剔除那个重载的版本,但是你最好是删掉它。重载为错误和混乱提供了机会[EJ Item 26]。如果兼容性要求强制你必须保留一个自身类型的equals方法,那么你应该用自身类型的重载去实现Object的重载,以此来确保它们具有相同的行为: public boolean equals(Object o) { return o instanceof Name && equals((Name) o); } 本谜题的教训是:当你想要进行覆写时,千万不要进行重载。为了避免无意识地重载,你应该机械地对你想要覆写的每一个超类方法都拷贝其声明,或者更好的方式是让你的IDE帮你去做这些事。这样做除了可以保护你免受无意识的重载之害,而且还可以保护你免受拼错方法名之害。如果你使用的5.0或者更新的版本,那么对于那些意在覆写超类方法的方法,你可以将@Override注释应用于每一个这样的方法的声明上: @Override public Boolean equals(Object o) { ... } 在使用这个注释时,除非被注释的方法确实覆写了一个超类方法,否则它将不能编译。对语言设计者来说,值得去考虑在每一个覆写超类方法的方法声明上都添加一个强制性的修饰符。 谜题59:什么是差?下面的程序在计算一个int数组中的元素两两之间的差,将这些差置于一个集合中,然后打印该集合的尺寸大小。那么,这个程序将打印出什么呢? import java.util.*; public class Differences { public static void main(String[ ] args) { int vals[ ] = { 789, 678, 567, 456, 345, 234, 123, 012 }; Set diffs = new HashSet(); for (int i = 0; i < vals.length; i++) for (int j = i; j < vals.length; j++) diffs.add(vals[i] - vals[j]); System.out.println(diffs.size()); } } 外层循环迭代数组中的每一个元素,而内层循环从外层循环当前迭代到的元素开始迭代到数组中的最后一个元素。因此,这个嵌套的循环将遍历数组中每一种可能的两两组合。(元素可以与其自身组成一对。)这个嵌套循环中的每一次迭代都计算了一对元素之间的差(总是正的),并将这个差存储到了集合中,集合是可以消除重复元素的。因此,本谜题就带来了一个问题,在由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”,”tomato”和”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。把它们放到一块,就形成了下面的解决方案: static List withoutDuplicates(List original) { return new ArrayList (new LinkedHashSet (original)); } B.在将字符串解析成标志时,许多程序员都立刻想到了使用StringTokenizer。这是最不幸的事情,自1.4版本开始,由于正则表达式被添加到了Java平台中(java.util.regex),StringTokenizer开始变得过时了。如果你试图通过StringTokenizer来解决本谜题,那么你很快就会意识到它不是非常适合。通过使用正则表达式,它就是小菜一碟。为了在一行代码中解决本谜题,我们要使用很方便的方法String.split,它接受一个描述标志分界符的正则表达式作为参数。如果你以前从来没有使用过正则表达式,那么它们看起来会显得有一点神秘,但是它们惊人地强大,值得我们好好学习一下: static String[ ] parse(String string) { return string.split(",//S*"); } 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数值中被置位的位数: static Boolean hasMoreBitsSet(int i, int j) { return (Integer.bitCount(i) > Integer.bitCount(j)); } 总之,Java平台的每一个主版本都在其类库中隐藏了一些宝藏。本谜题的所有4个部分都依赖于这样的宝藏。每当该平台发布一个新版本时,你都应该研究就一下新特性和提高(new features and enhancements)页面,这样你就不会遗漏掉新版本提供的任何惊喜[Features-1.4, Features-5.0]。了解类库中有些什么可以节省你大量的时间和精力,并且可以提高你的程序的速度和质量。 谜题61:日期游戏下面的程序演练了Date和Calendar类的某些基本特性,它会打印出什么呢? import java.util.*; public class DatingGame { public static void main(String[ ] args) { Calendar cal = Calendar.getInstance(); cal.set(1999, 12, 31); // Year, Month, Day System.out.print(cal.get(Calendar.YEAR) + " "); Date d = cal.getTime(); System.out.println(d.getDay()); } } 该程序创建了一个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: public class DatingGame { public static void main(String[] args) { Calendar cal = Calendar.getInstance(); cal.set(1999, Calendar.DECEMBER, 31); System.out.print(cal.get(Calendar.YEAR) + " "); System.out.println(cal.get(Calendar.DAY_OF_MONTH)); } } 本谜题只是掀开了Calendar和Date缺陷的冰山一角。这些API简直就是雷区。Calendar其他的严重问题包括弱类型(几乎每样事物都是一个int)、过于复杂的状态空间、拙劣的结构、不一致的命名以及不一致的雨衣等。在使用Calendar和Date的时候一定要当心,千万要记着查阅API文档。 对API设计者来说,其教训是:如果你不能在第一次设计时就使它正确,那么至少应该在第二次设计时应该使它正确,绝对不能留到第三次设计时去处理。如果你对某个API的首次尝试出现了严重问题,那么你的客户可能会原谅你,并且会再给你一次机会。如果你第二次尝试又有问题,你可能会永远坚持这些错误了。 谜题62:名字游戏下面的程序将两个映射关系放置到了一个映射表中,然后打印它们的尺寸。那么,它会打印出什么呢? import java.util.*; public class NameGame { public static void main(String args[ ]) { Map m = new IdentityHashMap(); m.put("Mickey", "Mouse"); m.put("Mickey", "Mantle"); System.out.println(m.size()); } } 对该程序的一种幼稚的分析认为,它应该打印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。那么,这个程序会打印出什么呢? import java.util.*; public class MoreNames { private Map m = new HashMap(); public void MoreNames() { m.put("Mickey", "Mouse"); m.put("Mickey", "Mantle"); } public int size() { return m.size(); } public static void main(String args[ ]) { MoreNames moreNames = new MoreNames(); System.out.println(moreNames.size()); } } 这个程序看起来很直观,其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取余的柱状图,那么,它将打印出什么呢? public class Mod { public static void main(String[ ] args) { final int MODULUS = 3; int[] histogram = new int[MODULUS]; // Iterate over all ints (Idiom from Puzzle 26) int i = Integer.MIN_VALUE; do { histogram[Math.abs(i) % MODULUS]++; } while (i++ != Integer.MAX_VALUE); for (int j = 0; j < MODULUS; j++) System.out.println(histogram[j] + " "); } } 该程序首先初始化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。但是它真的是这么做的吗?不,它几乎立刻就抛出了下面的异常: Exception in thread "main" ArrayIndexOutOfBoundsException: -2 at Mod.main(Mod.java:9) 问题出在哪了呢? 问题在于该程序对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: private static int mod(int i, int modulus) { int result = i % modulus; return result < 0 ? result + modulus : result; } 本谜题的教训是: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版特性的一个样例程序。它使用了自动包装和解包、泛型和枚举类型。那么,它会打印出什么呢? import java.util.*; public class SuspiciousSort { public static void main(String[ ] args) { Random rnd = new Random(); Integer[ ] arr = new Integer[100]; for (int i = 0; i < arr.length; i++) arr[i] = rnd.nextInt(); Comparator cmp = new Comparator () { public int compare(Integer i1, Integer i2) { return i2 - i1; } }; Arrays.sort(arr, cmp); System.out.println(order(arr)); } enum Order { ASCENDING, DESCENDING, CONSTANT, UNORDERED }; static Order order(Integer[ ] a) { boolean ascending = false; boolean descending = false; for (int i = 1; i < a.length; i++) { ascending |= a[i] > a[i-1]; descending |= a[i] < a[i-1]; } if (ascending && !descending) return Order.ASCENDING; if (descending && !ascending) return Order.DESCENDING; if (!ascending) return Order.CONSTANT; // All elements equal return Order.UNORDERED; // Array is not sorted } } 该程序的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数值的减法时,其结果可能会溢出,在这种情况下我们就会得到错误的符号。 例如,请考虑下面的程序: public class Overflow { public static void main(String[] args){ int x = -2000000000; int z = 2000000000; System.out.println(x - z); } } 很明显,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: public int compare(Integer i1, Integer i2) { return (i2 < i1 ? -1 : (i2 == i1 ? 0 :1)); } 本谜题有数个教训,最具体的是:不要使用基于减法的比较器,除非你能够确保要比较的数值之间的差永远不会大于Integer.MAX_VALUE [EJ Item 11]。更一般地讲,要意识到int的溢出,就像谜题3、26和33所讨论的那样。另一个教训是你应该避免“聪明”的代码。应该努力去编写清晰正确的代码,不要对它作任何优化,除非该优化被证明是必需的[EJ Item 37]。 对语言设计者来说,得到的教训与谜题3、26和33相同:也许真的值得去考虑支持某种形式整数算数运算,它不会在溢出时不抛出异常。还有就是可能应该在语言中提供一个三值的比较器操作符,就像Perl所作的那样(<=>操作符)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值