谜题1:奇数性
下面的方法意图确定它那唯一的参数是否是一个奇数。这个方法能够正确运转吗?
public static boolean isOdd(int i){
return i % 2 == 1;
}
奇数可以被定义为被2整除余数为1的整数。表达式 i % 2 计算的是 i 整除 2 时所产生的余数,因此看起来这个程序应该能够正确运转。遗憾的是,它不能;它在四分之一的时间里返回的都是错误的答案。
为什么是四分之一?因为在所有的 int 数值中,有一半都是负数,而 isOdd 方法对于对所有负奇数的判断都会失败。在任何负整数上调用该方法都回返回 false ,不管该整数是偶数还是奇数。
这是 Java 对取余操作符(%)的定义所产生的后果。该操作符被定义为对于所有的 int 数值 a 和所有的非零 int 数值 b,都满足下面的恒等式:
(a / b) * b + (a % b) == a
换句话说,如果你用b整除a,将商乘以b,然后加上余数,那么你就得到了最初的值 a 。该恒等式具有正确的含义,但是当与 Java 的截尾整数整除操作符相结合时,它就意味着:当取余操作返回一个非零的结果时,它与左操作数具有相同的正负符号。
当 i 是一个负奇数时,i % 2 等于-1而不是1, 因此 isOdd 方法将错误地返回 false。为了防止这种意外,请测试你的方法在为每一个数值型参数传递负数、零和正数数值时,其行为是否正确。
这个问题很容易订正。只需将 i % 2 与0而不是与1比较,并且反转比较的含义即可:
public static boolean isOdd(int i){
return i % 2 != 0;
}
如果你正在在一个性能临界(performance-critical)环境中使用isOdd方法,那么用位操作符AND(&)来替代取余操作符会显得更好:
public static boolean isOdd(int i){
return (i & 1) != 0;
}
总之,无论你何时使用到了取余操作符,都要考虑到操作数和结果的符号。该操作符的行为在其操作数非负时是一目了然的,但是当一个或两个操作数都是负数时,它的行为就不那么显而易见了。
谜题3:
这个谜题之所以被称为长整除是因为它所涉及的程序是有关两个long型数值整除的。被除数表示的是一天里的微秒数;而除数表示的是一天里的毫秒数。这个程序会打印出什么呢?
public class LongDivision{
public static void main(String args[]){
final long MICROS_PER_DAY = 24 * 60 * 60 * 1000 * 1000;
final long MILLIS_PER_DAY = 24 * 60 * 60 * 1000;
System.out.println(MICROS_PER_DAY/MILLIS_PER_DAY);
}
}
这个谜题看起来相当直观。每天的毫秒数和每天的微秒数都是常量。为清楚起见,它们都被表示成积的形式。每天的微秒数是(24小时/天*60分钟/小时*60秒/分钟*1000毫秒/秒*1000微秒/毫秒)。而每天的毫秒数的不同之处只是少了最后一个因子1000。
当你用每天的毫秒数来整除每天的微秒数时,除数中所有的因子都被约掉了,只剩下1000,这正是每毫秒包含的微秒数。
除数和被除数都是long类型的,long类型大到了可以很容易地保存这两个乘积而不产生溢出。因此,看起来程序打印的必定是1000。
遗憾的是,它打印的是5。这里到底发生了什么呢?
问题在于常数MICROS_PER_DAY的计算“确实”溢出了。尽管计算的结果适合放入long中,并且其空间还有富余,但是这个结果并不适合放入int中。这个计算完全是以int运算来执行的,并且只有在运算完成之后,其结果才被提升到long,而此时已经太迟了:计算已经溢出了,它返回的是一个小了200倍的数值。从int提升到long是一种拓宽原始类型转换(widening primitive conversion),它保留了(不正确的)数值。这个值之后被MILLIS_PER_DAY整除,而MILLIS_PER_DAY的计算是正确的,因为它适合int运算。这样整除的结果就得到了5。
那么为什么计算会是以int运算来执行的呢?因为所有乘在一起的因子都是int数值。当你将两个int数值相乘时,你将得到另一个int数值。Java不具有目标确定类型的特性,这是一种语言特性,其含义是指存储结果的变量的类型会影响到计算所使用的类型。
通过使用long常量来替代int常量作为每一个乘积的第一个因子,我们就可以很容易地订正这个程序。这样做可以强制表达式中所有的后续计算都用long运作来完成。尽管这么做只在MICROS_PER_DAY表达式中是必需的,但是在两个乘积中都这么做是一种很好的方式。相似地,使用long作为乘积的“第一个”数值也并不总是必需的,但是这么做也是一种很好的形式。在两个计算中都以long数值开始可以很清楚地表明它们都不会溢出。下面的程序将打印出我们所期望的1000:
public class LongDivision{
public static void main(String args[ ]){
final long MICROS_PER_DAY = 24L * 60 * 60 * 1000 * 1000;
final long MILLIS_PER_DAY = 24L * 60 * 60 * 1000;
System.out.println(MICROS_PER_DAY/MILLIS_PER_DAY);
}
}
这个教训很简单:当你在操作很大的数字时,千万要提防溢出——它可是一个缄默杀手。即使用来保存结果的变量已显得足够大,也并不意味着要产生结果的计算具有正确的类型。当你拿不准时,就使用long运算来执行整个计算。
语言设计者从中可以吸取的教训是:也许降低缄默溢出产生的可能性确实是值得做的一件事。这可以通过对不会产生缄默溢出的运算提供支持来实现。程序可以抛出一个异常而不是直接溢出,就像Ada 所作的那样,或者它们可以在需要的时候自动地切换到一个更大的内部表示上以防止溢出,就像Lisp所作的那样。这两种方式都可能会遭受与其相关的性能方面的损失。降低缄默溢出的另一种方式是支持目标确定类型,但是这么做会显著地增加类型系统的复杂度
谜题4:初级问题
得啦,前面那个谜题是有点棘手,但它是有关整除的,每个人都知道整除是很麻烦的。那么下面的程序只涉及加法,它又会打印出什么呢?
public class Elementary{
public static void main(String[] args){
System.out.println(12345+5432l);
}
}
从表面上看,这像是一个很简单的谜题——简单到不需要纸和笔你就可以解决它。加号的左操作数的各个位是从1到5升序排列的,而右操作数是降序排列的。因此,相应各位的和仍然是常数,程序必定打印66666。对于这样的分析,只有一个问题:当你运行该程序时,它打印出的是17777。难道是Java对打印这样的非常数字抱有偏见吗?不知怎么的,这看起来并不像是一个合理的解释。
事物往往有别于它的表象。就以这个问题为例,它并没有打印出我们想要的输出。请仔细观察 + 操作符的两个操作数,我们是将一个int类型的12345加到了long类型的5432l上。请注意左操作数开头的数字1和右操作数结尾的小写字母l之间的细微差异。数字1的水平笔划(称为“臂(arm)”)和垂直笔划(称为“茎(stem)”)之间是一个锐角,而与此相对照的是,小写字母l的臂和茎之间是一个直角。
在你大喊“恶心!”之前,你应该注意到这个问题确实已经引起了混乱,这里确实有一个教训:在long型字面常量中,一定要用大写的L,千万不要用小写的l。这样就可以完全掐断这个谜题所产生的混乱的源头。
System.out.println(12345+5432L);
相类似的,要避免使用单独的一个l字母作为变量名。例如,我们很难通过观察下面的代码段来判断它到底是打印出列表l还是数字1。
//不良代码-使用了l作为变量名
List l = new ArrayList();
l.add("Foo");
System.out.println(1);
总之,小写字母l和数字1在大多数打字机字体中都是几乎一样的。为避免你的程序的读者对二者产生混淆,千万不要使用小写的l来作为long型字面常量的结尾或是作为变量名。Java从C编程语言中继承良多,包括long型字面常量的语法。也许当初允许用小写的l来编写long型字面常量本身就是一个错误。
谜题5:十六进制的趣事
下面的程序是对两个十六进制(hex)字面常量进行相加,然后打印出十六进制的结果。这个程序会打印出什么呢?
public class JoyOfHex{
public static void main(String[] args){
System.out.println(
Long.toHexString(0x100000000L + 0xcafebabe));
}
}
看起来很明显,该程序应该打印出1cafebabe。毕竟,这确实就是十六进制数字10000000016与cafebabe16的和。该程序使用的是long型运算,它可以支持16位十六进制数,因此运算溢出是不可能的。
然而,如果你运行该程序,你就会发现它打印出来的是cafebabe,并没有任何前导的1。这个输出表示的是正确结果的低32位,但是不知何故,第33位丢失了。
看起来程序好像执行的是int型运算而不是long型运算,或者是忘了加第一个操作数。这里到底发生了什么呢?
十进制字面常量具有一个很好的属性,即所有的十进制字面常量都是正的,而十六进制或是八进制字面常量并不具备这个属性。要想书写一个负的十进制常量,可以使用一元取反操作符(-)连接一个十进制字面常量。以这种方式,你可以用十进制来书写任何int或long型的数值,不管它是正的还是负的,并且负的十进制常数可以很明确地用一个减号符号来标识。但是十六进制和八进制字面常量并不是这么回事,它们可以具有正的以及负的数值。如果十六进制和八进制字面常量的最高位被置位了,那么它们就是负数。在这个程序中,数字0xcafebabe是一个int常量,它的最高位被置位了,所以它是一个负数。它等于十进制数值-889275714。
该程序执行的这个加法是一种“混合类型的计算(mixed-type computation):左操作数是long类型的,而右操作数是int类型的。为了执行该计算,Java将int类型的数值用拓宽原始类型转换提升为一个long类型,然后对两个long类型数值相加。因为int是一个有符号的整数类型,所以这个转换执行的是符合扩展:它将负的int类型的数值提升为一个在数值上相等的long类型数值。
这个加法的右操作数0xcafebabe被提升为了long类型的数值0xffffffffcafebabeL。这个数值之后被加到了左操作数0x100000000L上。当作为int类型来被审视时,经过符号扩展之后的右操作数的高32位是-1,而左操作数的高32位是1,将这两个数值相加就得到了0,这也就解释了为什么在程序输出中前导1丢失了。下面所示是用手写的加法实现。(在加法上面的数字是进位。)
1111111
0xffffffffcafebabeL
+ 0x0000000100000000L
---------------------
0x00000000cafebabeL
订正该程序非常简单,只需用一个long十六进制字面常量来表示右操作数即可。这就可以避免了具有破坏力的符号扩展,并且程序也就可以打印出我们所期望的结果1cafebabe:
public class JoyOfHex{
public static void main(String[] args){
System.out.println(
Long.toHexString(0x100000000L + 0xcafebabeL));
}
}
这个谜题给我们的教训是:混合类型的计算可能会产生混淆,尤其是十六进制和八进制字面常量无需显式的减号符号就可以表示负的数值。为了避免这种窘境,通常最好是避免混合类型的计算。对于语言的设计者们来说,应该考虑支持无符号的整数类型,从而根除符号扩展的可能性。可能会有这样的争辩:负的十六进制和八进制字面常量应该被禁用,但是这可能会挫伤程序员,他们经常使用十六进制字面常量来表示那些符号没有任何重要含义的数值。
谜题6:多重转型
转型被用来将一个数值从一种类型转换到另一种类型。下面的程序连续使用了三个转型。那么它到底会打印出什么呢?
public class Multicast{
public static void main (String[] args){
System.out.println((int)(char)(byte) -1);
}
}
无论你怎样分析这个程序,都会感到很迷惑。它以int数值-1开始,然后从int转型为byte,之后转型为char,最后转型回int。第一个转型将数值从32位窄化到了8位,第二个转型将数值从8位拓宽到了16位,最后一个转型又将数值从16位拓宽回了32位。这个数值最终是回到了起点吗?如果你运行该程序,你就会发现不是。它打印出来的是65535,但是这是为什么呢?
该程序的行为紧密依赖于转型的符号扩展行为。Java使用了基于2的补码的二进制运算,因此int类型的数值-1的所有32位都是置位的。从int到byte的转型是很简单的,它执行了一个窄化原始类型转化(narrowing primitive conversion),直接将除低8位之外的所有位全部砍掉。这样做留下的是一个8位都被置位了的byte,它仍旧表示-1。
从byte到char的转型稍微麻烦一点,因为byte是一个有符号类型,而char是一个无符号类型。在将一个整数类型转换成另一个宽度更宽的整数类型时,通常是可以保持其数值的,但是却不可能将一个负的byte数值表示成一个char。因此,从byte到char的转换被认为不是一个拓宽原始类型的转换,而是一个拓宽并窄化原始类型的转换(widening and narrowing primitive conversion):byte被转换成了int,而这个int又被转换成了char。
所有这些听起来有点复杂,幸运的是,有一条很简单的规则能够描述从较窄的整型转换成较宽的整型时的符号扩展行为:如果最初的数值类型是有符号的,那么就执行符号扩展;如果它是char,那么不管它将要被转换成什么类型,都执行零扩展。了解这条规则可以使我们很容易地解决这个谜题。
因为byte是一个有符号的类型,所以在将byte数值-1转换成char时,会发生符号扩展。作为结果的char数值的16个位就都被置位了,因此它等于216-1,即65535。从char到int的转型也是一个拓宽原始类型转换,所以这条规则告诉我们,它将执行零扩展而不是符号扩展。作为结果的int数值也就成了65535,这正是程序打印出的结果。
尽管这条简单的规则描述了在有符号和无符号整型之间进行拓宽原始类型时的符号扩展行为,你最好还是不要编写出依赖于它的程序。如果你正在执行一个转型到char或从char转型的拓宽原始类型转换,并且这个char是仅有的无符号整型,那么你最好将你的意图明确地表达出来。
如果你在将一个char数值c转型为一个宽度更宽的类型,并且你不希望有符号扩展,那么为清晰表达意图,可以考虑使用一个位掩码,即使它并不是必需的:
int i = c & 0xffff;
或者,书写一句注释来描述转换的行为:
int i = c; //不会执行符号扩展
如果你在将一个char数值c转型为一个宽度更宽的整型,并且你希望有符号扩展,那么就先将char转型为一个short,它与char具有同样的宽度,但是它是有符号的。在给出了这种细微的代码之后,你应该也为它书写一句注释:
int i = (short) c; //转型将引起符号扩展
如果你在将一个byte数值b转型为一个char,并且你不希望有符号扩展,那么你必须使用一个位掩码来限制它。这是一种通用做法,所以不需要任何注释:
char c = (char) (b & 0xff);
这个教训很简单:如果你通过观察不能确定程序将要做什么,那么它做的就很有可能不是你想要的。要为明白清晰地表达你的意图而努力。尽管有这么一条简单的规则,描述了涉及有符号和无符号整型拓宽转换的符号扩展行为,但是大多数程序员都不知道它。如果你的程序依赖于它,那么你就应该把你的意图表达清楚。
谜题7:互换内容
下面的程序使用了复合的异或赋值操作符,它所展示的技术是一种编程习俗。那么它会打印出什么呢?
public class CleverSwap{
public static void main(String[] args){
int x = 1984; // (0x7c0)
int y = 2001; // (0x7d1)
x^= y^= x^= y;
System.out.println("x= " + x + "; y= " + y);
}
}
就像其名称所暗示的,这个程序应该交换变量x和y的值。如果你运行它,就会发现很悲惨,它失败了,打印的是
x = 0; y = 1984。
交换两个变量的最显而易见的方式是使用一个临时变量:
int tmp = x;
x = y;
y = tmp;
很久以前,当中央处理器只有少数寄存器时,人们发现可以通过利用异或操作符(^)的属性(x ^ y ^ x) == y来避免使用临时变量:
x = x ^ y;
y = y ^ x;
x = y ^ x;
这个惯用法曾经在C编程语言中被使用过,并进一步被构建到了C++中,但是它并不保证在二者中都可以正确运行。但是有一点是肯定的,那就是它在Java中肯定是不能正确运行的。
Java语言规范描述到:操作符的操作数是从左向右求值的。为了求表达式 x ^= expr的值,x的值是在计算expr之前被提取的,并且这两个值的异或结果被赋给变量x。在CleverSwap程序中,变量x的值被提取了两次——每次在表达式中出现时都提取一次——但是两次提取都发生在所有的赋值操作之前。
下面的代码段详细地描述了将互换惯用法分解开之后的行为,并且解释了为什么产生的是我们所看到的输出:
// Java中x^= y^= x^= y的实际行为
int tmp1 = x ; // x在表达式中第一次出现
int tmp2 = y ; // y的第一次出现
int tmp3 = x ^ y ; // 计算x ^ y
x = tmp3 ; // 最后一个赋值:存储x ^ y 到 x
y = tmp2 ^ tmp3 ; // 第二个赋值:存储最初的x值到y中
x = tmp1 ^ y ; // 第一个赋值:存储0到x中
在C和C++中,并没有指定表达式的计算顺序。当编译表达式x ^= expr时,许多C和C++编译器都是在计算expr之后才提取x的值的,这就使得上述的惯用法可以正常运转。尽管它可以正常运转,但是它仍然违背了C/C++有关不能在两个连续的序列点之间重复修改变量的规则。因此,这个惯用法的行为在C和C++中也没有明确定义。
为了看重其价值,我们还是可以写出不用临时变量就可以互换两个变量内容的Java表达式的。但是它同样是丑陋而无用的:
// 杀鸡用牛刀的做法,千万不要这么做!
y = (x^= (y^= x))^ y ;
这个教训很简单:在单个的表达式中不要对相同的变量赋值两次。表达式如果包含对相同变量的多次赋值,就会引起混乱,并且很少能够执行你希望的操作。即使对多个变量进行赋值也很容易出错。更一般地讲,要避免所谓聪明的编程技巧。它们都是易于产生bug的,很难以维护,并且运行速度经常是比它们所替代掉的简单直观的代码要慢。
语言设计者可能会考虑禁止在一个表达式中对相同的变量多次赋值,但是在一般的情况下,强制执行这条禁令会因为别名机制的存在而显得很不灵活。例如,请考虑表达式 x = a[i]++ - a[j]++,它是否递增了相同的变量两次呢?这取决于在表达式被计算时i和j的值,并且编译器通常是无法确定这一点。
谜题8:Dos Equis
这个谜题将测试你对条件操作符的掌握程度,这个操作符有一个更广为人知的名字:问号冒号操作符。下面的程序将会打印出什么呢?
public class DosEquis{
public static void main(String[] args){
char x = 'X';
int i = 0;
System.out.println(true ? x : 0);
System.out.println(false ? i : x);
}
}
这个程序由两个变量声明和两个print语句构成。第一个print语句计算条件表达式(true ? x : 0)并打印出结果,这个结果是char类型变量x的值’X’。而第二个print语句计算表达式(false ? i : x)并打印出结果,这个结果还是依旧是’X’的x,因此这个程序应该打印XX。然而,如果你运行该程序,你就会发现它打印出来的是X88。这种行为看起来挺怪的。第一个print语句打印的是X,而第二个打印的却是88。它们的不同行为说明了什么呢?
答案就在规范有关条件表达式部分的一个阴暗的角落里。请注意在这两个表达式中,每一个表达式的第二个和第三个操作数的类型都不相同:x是char类型的,而0和i都是int类型的。就像在谜题5的解答中提到的,混合类型的计算会引起混乱,而这一点比在条件表达式中比在其它任何地方都表现得更明显。你可能考虑过,这个程序中两个条件表达式的结果类型是相同的,就像它们的操作数类型是相同的一样,尽管操作数的顺序颠倒了一下,但是实际情况并非如此。
确定条件表达式结果类型的规则过于冗长和复杂,很难完全记住它们,但是其核心就是一下三点:
- 如果第二个和第三个操作数具有相同的类型,那么它就是条件表达式的类型。换句话说,你可以通过绕过混合类型的计算来避免大麻烦。
- 如果一个操作数的类型是T,T表示byte、short或char,而另一个操作数是一个int类型的常量表达式,它的值是可以用类型T表示的,那么条件表达式的类型就是T。
- 否则,将对操作数类型运用二进制数字提升,而条件表达式的类型就是第二个和第三个操作数被提升之后的类型。
2、3两点对本谜题是关键。在程序的两个条件表达式中,一个操作数的类型是char,另一个的类型是int。在两个表达式中,int操作数都是0,它可以被表示成一个char。然而,只有第一个表达式中的int操作数是常量(0),而第二个表达式中的int操作数是变量(i)。因此,第2点被应用到了第一个表达式上,它返回的类型是char,而第3点被应用到了第二个表达式上,其返回的类型是对int和char运用了二进制数字提升之后的类型,即int。
条件表达式的类型将确定哪一个重载的print方法将被调用。对第一个表达式来说,PrintStream.print(char)将被调用,而对第二个表达式来说,PrintStream.print(int)将被调用。前一个重载方法将变量x的值作为Unicode字符(X)来打印,而后一个重载方法将其作为一个十进制整数(88)来打印。至此,谜题被解开了。
总之,通常最好是在条件表达式中使用类型相同的第二和第三操作数。否则,你和你的程序的读者必须要彻底理解这些表达式行为的复杂规范。
对语言设计者来说,也许可以设计一个牺牲掉了部分灵活性,但是增加了简洁性的条件操作符。例如,要求第二和第三操作数必须就有相同的类型,这看起来就很合理。或者,条件操作符可以被定义为对常量没有任何特殊处理。为了让这些选择对程序员来说更加容易接受,可以提供用来表示所有原始类型字面常量的语法。这也许确实是一个好注意,因为它增加了语言的一致性和完备性,同时又减少了对转型的需求。
谜题9:半斤
现在该轮到你来写些代码了,好消息是,你只需为这个谜题编写两行代码,并为下一个谜题也编写两行代码。这有什么难的呢?我们给出一个对变量x和i的声明即可,它肯定是一个合法的语句:
x += i;
但是,它并不是:
x = x + i;
许多程序员都会认为该迷题中的第一个表达式(x += i)只是第二个表达式(x = x + i)的简写方式。但是这并不十分准确。这两个表达式都被称为赋值表达式。第二条语句使用的是简单赋值操作符(=),而第一条语句使用的是复合赋值操作符。(复合赋值操作符包括 +=、-=、*=、/=、%=、<<=、>>=、>>>=、&=、^=和|=)Java语言规范中讲到,复合赋值 E1 op= E2等价于简单赋值E1 = (T)((E1)op(E2)),其中T是E1的类型,除非E1只被计算一次。
换句话说,复合赋值表达式自动地将它们所执行的计算的结果转型为其左侧变量的类型。如果结果的类型与该变量的类型相同,那么这个转型不会造成任何影响。然而,如果结果的类型比该变量的类型要宽,那么复合赋值操作符将悄悄地执行一个窄化原始类型转换。因此,我们有很好的理由去解释为什么在尝试着执行等价的简单赋值可能会产生一个编译错误。
为了说得具体一些,并提供一个解决方案给这个谜题,假设我们在该谜题的两个赋值表达式之前有下面这些声明:
short x = 0;
int i = 123456;
复合赋值编译将不会产生任何错误:
x += i; // 包含了一个隐藏的转型!
你可能期望x的值在这条语句执行之后是123,456,但是并非如此l,它的值是-7,616。int类型的数值123456对于short来说太大了。自动产生的转型悄悄地把int数值的高两位给截掉了。这也许就不是你想要的了。
相对应的简单赋值是非法的,因为它试图将int数值赋值给short变量,它需要一个显式的转型:
x = x + i; // 不要编译——“可能会丢掉精度”
这应该是明显的,复合赋值表达式可能是很危险的。为了避免这种令人不快的突袭,请不要将复合赋值操作符作用于byte、short或char类型的变量上。在将复合赋值操作符作用于int类型的变量上时,要确保表达式右侧不是long、float或double类型。在将复合赋值操作符作用于float类型的变量上时,要确保表达式右侧不是double类型。这些规则足以防止编译器产生危险的窄化转型。
总之,复合赋值操作符会悄悄地产生一个转型。如果计算结果的类型宽于变量的类型,那么所产生的转型就是一个危险的窄化转型。这样的转型可能会悄悄地丢弃掉精度或数量值。对语言设计者来说,也许让复合赋值操作符产生一个不可见的转型本身就是一个错误;对于在复合赋值中的变量类型比计算结果窄的情况,也许应该让其非法才对。
谜题10:八两
与上面的例子相反,如果我们给出的关于变量x和i的声明是如下的合法语句:
x = x + i;
但是,它并不是:
x += i;
乍一看,这个谜题可能看起来与前面一个谜题相同。但是请放心,它们并不一样。这两个谜题在哪一条语句必是合法的,以及哪一条语句必是不合法的方面,正好相反。
就像前面的谜题一样,这个谜题也依赖于有关复合赋值操作符的规范中的细节。二者的相似之处就此打住。基于前面的谜题,你可能会想:符合赋值操作符比简单赋值操作符的限制要少一些。在一般情况下,这是对的,但是有这么一个领域,在其中简单赋值操作符会显得更宽松一些。
复合赋值操作符要求两个操作数都是原始类型的,例如int,或包装了的原始类型,例如Integer,但是有一个例外:如果在+=操作符左侧的操作数是String类型的,那么它允许右侧的操作数是任意类型,在这种情况下,该操作符执行的是字符串连接操作。简单赋值操作符(=)允许其左侧的是对象引用类型,这就显得要宽松许多了:你可以使用它们来表示任何你想要表示的内容,只要表达式的右侧与左侧的变量是赋值兼容的即可。
你可以利用这一差异来解决该谜题。要想用 += 操作符来执行字符串连接操作,你就必须将左侧的变量声明为String类型。通过使用直接赋值操作符,字符串连接的结果可以存放到一个Object类型的变量中。
为了说得具体一些,并提供一个解决方案给这个谜题,假设我们在该谜题的两个赋值表达式之前有下面这些声明:
Object x = "Buy ";
String i = "Effective Java!";
简单赋值是合法的,因为 x + i 是String类型的,而String类型又是与Object赋值兼容的:
x = x + i;
复合赋值是非法的,因为左侧是一个Object引用类型,而右侧是一个String类型:
x += i;
这个谜题对程序员来说几乎算不上什么教训。对语言设计者来说,加法的复合赋值操作符应该在右侧是String类型的情况下,允许左侧是Object类型。这项修改将根除这个谜题所展示的违背直觉的行为。
谜题11:最后的笑声
下面的程序将打印出什么呢?
public class LastLaugh{
public static void main(String[] args){
System.out.print("H"+"a");
System.out.print('H'+'a');
}
}
你可能会认为这个程序将打印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字符处理的方法。
那么你应该怎样将字符连接在一起呢?你可以使用这些类库。例如,你可以使用一个字符串缓冲区:
StringBuffer sb = new StringBuffer();
sb.append('H');
sb.append('a');
System.out.println(sb);
这么做可以正常运行,但是显得很丑陋。其实我们还是有办法去避免这种方式所产生的拖沓冗长的代码。你可以通过确保至少有一个操作数为字符串类型,来强制 + 操作符去执行一个字符串连接操作,而不是一个加法操作。这种常见的惯用法用一个空字符串("")作为一个连接序列的开始,如下所示:
System.out.println("" + 'H' + 'a');
这种惯用法可以确保子表达式都被转型为字符串。尽管这很有用,但是多少有一点难看,而且它自身可能会引发某些混淆。你能猜到下面的语句将会打印出什么吗?如果你不能确定,那么就试一下:
System.out.print("2 + 2 = " + 2+2);
如果使用的是JDK 5.0,你还可以使用
System.out.printf("%c%c", 'H', 'a');
总之,使用字符串连接操作符使用格外小心。+ 操作符当且仅当它的操作数中至少有一个是String类型时,才会执行字符串连接操作;否则,它执行的就是加法。如果要连接的没有一个数值是字符串类型的,那么你可以有几种选择:
- 预置一个空字符串;
- 将第一个数值用String.valueOf显式地转换成一个字符串;
- 使用一个字符串缓冲区;
- 或者如果你使用的JDK 5.0,可以用printf方法。
这个谜题还包含了一个给语言设计者的教训。操作符重载,即使在Java中只在有限的范围内得到了支持,它仍然会引起混淆。为字符串连接而重载+ 操作符可能就是一个已铸成的错误。
谜题12:ABC
这个谜题要问的是一个悦耳的问题,下面的程序将打印什么呢?
public class ABC{
public static void main(String[] args){
String letters = "ABC";
char[] numbers = {'1', '2', '3'};
System.out.println(letters + " easy as " + numbers);
}
}
可能大家希望这个程序打印出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"。将它们连接到一起就形成了在我们的程序中打印出来的那个丑陋的字符串。
有两种方法可以订正这个程序。你可以在调用字符串连接操作之前,显式地将一个数组转换成一个字符串:
System.out.println(letters + " easy as " +
String.valueOf(numbers));
或者,你可以将System.out.println调用分解为两个调用,以利用println的char[]重载版本:
System.out.print(letters + " easy as ");
System.out.println(numbers);
请注意,这些订正只有在你调用了valueOf和println方法正确的重载版本的情况下,才能正常运行。换句话说,它们严格依赖于数组引用的编译期类型。
下面的程序说明了这种依赖性。看起来它像是所描述的第二种订正方式的具体实现,但是它产生的输出却与最初的程序所产生的输出一样丑陋,因为它调用的是println的Object重载版本,而不是char[]重载版本。
class ABC2{
public static void main(String[] args){
String letters = "ABC";
Object numbers = new char[] { '1', '2', '3' };
System.out.print(letters + " easy as ");
System.out.println(numbers);
}
}
总之,char数组不是字符串。要想将一个char数组转换成一个字符串,就要调用String.valueOf(char[])方法。某些类库中的方法提供了对char数组的类似字符串的支持,通常是提供一个Object版本的重载方法和一个char[]版本的重载方法,而之后后者才能产生我们想要的行为。
对语言设计者的教训是:char[]类型可能应该覆写toString方法,使其返回数组中包含的字符。更一般地讲,数组类型可能都应该覆写toString方法,使其返回数组内容的一个字符串表示。
谜题14:转义字符的溃败
下面的程序使用了两个Unicode的转义字符,它们是用其十六进制代码来表示Unicode字符。那么,这个程序会打印什么呢?
public class EscapeRout{
public static void main(String[] args){
// \u0022 是双引号的Unicode转义字符
System.out.println("a\u0022.length()
+\u0022b".length());
}
}
对该程序的一种很肤浅的分析会认为它应该打印出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。
如果该程序的作者确实希望得到这种行为,那么下面的语句将要清楚得多:
System.out.println("a".length()+"b".length());
更有可能的情况是该作者希望将两个双引号字符置于字符串字面常量的内部。使用Unicode转义字符你是不能实现这一点的,但是你可以使用转义字符序列来实现[JLS 3.10.6]。表示一个双引号的转义字符序列是一个反斜杠后面紧跟着一个双引号(\”)。如果将最初的程序中的Unicode转义字符用转义字符序列来替换,那么它将打印出所期望的16:
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字符插入到源文件中。
谜题13:畜牧场
George Orwell的《畜牧场(Animal Farm)》一书的读者可能还记得老上校的宣言:“所有的动物都是平等的。”下面的Java程序试图要测试这项宣言。那么,它将打印出什么呢?
public class AnimalFarm{
public static void main(String[] args){
final String pig = "length: 10";
final String dog = "length: " + pig.length();
System.out. println("Animals are equal: "
+ pig == dog);
}
}
对该程序的表面分析可能会认为它应该打印出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方法的参数是按照下面的方式计算的:
System.out.println(("Animals are equal: " + pig) == dog);
这个布尔表达式的值当然是false,它正是该程序的所打印的输出。
有一个肯定能够避免此类窘境的方法:在使用字符串连接操作符时,总是将非平凡的操作数用括号括起来。更一般地讲,当你不能确定你是否需要括号时,应该选择稳妥地做法,将它们括起来。如果你在println语句中像下面这样把比较部分括起来,它将产生所期望的输出Animals are equal: false :
System.out.println("Animals are equal: " + (pig == dog));
可以论证,该程序仍然有问题。
如果可以的话,你的代码不应该依赖于字符串常量的内存限定机制。内存限定机制只是设计用来减少虚拟机内存占有量的,它并不是作为程序员可以使用的一种工具而设计的。就像这个谜题所展示的,哪一个表达式会产生字符串常量并非总是很显而易见。
更糟的是,如果你的代码依赖于内存限定机制实现操作的正确性,那么你就必须仔细地了解哪些域和参数必定是内存限定的。编译器不会帮你去检查这些不变量,因为内存限定的和不限定的字符串使用相同的类型(String)来表示的。这些因在内存中限定字符串失败而导致的bug是非常难以探测到的。
在比较对象引用时,你应该优先使用equals方法而不是 == 操作符,除非你需要比较的是对象的标识而不是对象的值。通过把这个教训应用到我们的程序中,我们给出了下面的println语句,这才是它应该具有的模样。很明显,在用这种方式订正了该程序之后,它将打印出true:
System.out.println("Animals are equal: " + pig.equals(dog));
这个谜题对语言设计者来说有两个教训。
- 字符串连接的优先级不应该和加法一样。这意味着重载 + 操作符来执行字符串连接是有问题的,就像在谜题11中提到的一样。
- 还有就是,对于不可修改的类型,例如String,其引用的等价性比值的等价性更加让人感到迷惑。也许 == 操作符在被应用于不可修改的类型时应该执行值比较。要实现这一点,一种方法是将 == 操作符作为equals方法的简便写法,并提供一个单独的类似于System.identityHashCode的方法来执行引用标识的比较。
谜题14:转义字符的溃败
下面的程序使用了两个Unicode的转义字符,它们是用其十六进制代码来表示Unicode字符。那么,这个程序会打印什么呢?
public class EscapeRout{
public static void main(String[] args){
// \u0022 是双引号的Unicode转义字符
System.out.println("a\u0022.length()
+\u0022b".length());
}
}
对该程序的一种很肤浅的分析会认为它应该打印出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。
如果该程序的作者确实希望得到这种行为,那么下面的语句将要清楚得多:
System.out.println("a".length()+"b".length());
更有可能的情况是该作者希望将两个双引号字符置于字符串字面常量的内部。使用Unicode转义字符你是不能实现这一点的,但是你可以使用转义字符序列来实现[JLS 3.10.6]。表示一个双引号的转义字符序列是一个反斜杠后面紧跟着一个双引号(\”)。如果将最初的程序中的Unicode转义字符用转义字符序列来替换,那么它将打印出所期望的16:
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
下面的程序是对一个老生常谈的例子做出了稍许的变化之后的版本。那么,它会打印出什么呢?
/**
* Generated by the IBM IDL-to-Java compiler, version 1.0
* from F:\TestRoot\apps\a1\units\include\PolicyHome.idl
* Wednesday, June 17, 1998 6:44:40 o’clock AM GMT+00:00
*/
public class Test{
public static void main(String[] args){
System.out.print("Hell");
System.out.println("o world");
}
}
这个谜题看起来相当简单。该程序包含了两条语句,第一条打印Hell,而第二条在同一行打印o world,从而将两个字符串有效地连接在了一起。因此,你可能期望该程序打印出Hello world。但是很可惜,你犯了错,实际上,它根本就通不过编译。
问题在于注释的第三行,它包含了字符\units。这些字符以反斜杠(\)以及紧跟着的字母u开头的,而它(\u)表示的是一个Unicode转义字符的开始。遗憾的是,这些字符后面没有紧跟四个十六进制的数字,因此,这个Unicode转义字符是病构的,而编译器则被要求拒绝该程序。Unicode转义字符必须是良构的,即使是出现在注释中也是如此。
在注释中插入一个良构的Unicode转义字符是合法的,但是我们几乎没有什么理由去这么做。程序员有时会在JavaDoc注释中使用Unicode转义字符来在文档中生成特殊的字符。
// Unicode转义字符在JavaDoc注释中有问题的用法
/**
* This method calls itself recursively, causing a
* StackOverflowError to be thrown.
* The algorithm is due to Peter von der Ah\u00E9.
*/
这项技术表示了Unicode转义字符的一种没什么用处的用法。在Javadoc注释中,应该使用HTML实体转义字符来代替Unicode转义字符:
/**
* This method calls itself recursively, causing a
* StackOverflowError to be thrown.
* The algorithm is due to Peter von der Ahé.
*/
前面的两个注释都应该是的在文档中出现的名字为“Peter der Ahé”,但是后一个注释在源文件中还是可理解的。
可能你会感到很诧异,在这个谜题中,问题出在注释这一信息来源自一个实际的bug报告。该程序是机器生成的,这使得我们很难追踪到问题的源头——IDL-to-Java编译器。为了避免让其他程序员也陷入此境地,在没有将Windows文件名进行预先处理,以消除的其中的反斜杠的情况下,工具应该确保不将Windows文件名置于所生成的Java源文件的注释中。
总之,要确保字符\u不出现在一个合法的Unicode转义字符上下文之外,即使是在注释中也是如此。在机器生成的代码中要特别注意此问题。
谜题16:行打印程序
行分隔符(line separator)是为用来分隔文本行的字符或字符组合而起的名字,并且它在不同的平台上是存在差异的。在Windows平台上,它是CR字符(回车)和紧随其后的LF字符(换行)组成的,而在UNIX平台上,通常单独的LF字符被当作换行字符来引用。下面的程序将这个字符传递给了println方法,那么,它将打印出什么呢?它的行为是否是依赖于平台的呢?
public class LinePrinter{
public static void main(String[] args){
// Note: \u000A is Unicode representation of linefeed (LF)
char c = 0x000A;
System.out.println(c);
}
}
这个程序的行为是平台无关的:它在任何平台上都不能通过编译。如果你尝试着去编译它,就会得到类似下面的出错信息:
LinePrinter.java:3: ';' expected
// Note: \u000A is Unicode representation of linefeed (LF)
^
1 error
如果你和大多数人一样,那么这条信息对界定问题是毫无用处的。
这个谜题的关键就是程序第三行的注释。与最好的注释一样,这条注释也是一种准确的表达,遗憾的是,它有一点准确得过头了。编译器不仅会在将程序解析成为符号之前把Unicode转义字符转换成它们所表示的字符(谜题14),而且它是在丢弃注释和空格之前做这些事的[JLS 3.2]。
这个程序包含了一个Unicode转移字符(\u000A),它位于程序唯一的注释行中。就像注释所陈述的,这个转义字符表示换行符,编译器将在丢弃注释之前适时地转换它。遗憾的是,这个换行符是表示注释开始的两个斜杠符之后的第一个行终结符(line terminator),因此它将终结该注释[JLS 3.4]。所以,该转义字符之后的字(is Unicode representation of linefeed (LF))就不是注释的一部分了,而它们在语法上也不是有效的。
订正该程序的最简单的方式就是在注释中移除Unicode转义字符,但是更好的方式是用一个转义字符序列而不是一个十六进制整型字面常量来初始化c,从而消除使用注释的必要:
public class LinePrinter{
public static void main(String[] args){
char c = '\n';
System.out.println(c);
}
}
只要这么做了,程序就可以编译并运行,但是这仍然是一个有问题的程序:它是平台相关的,这正是本谜题所要表达的真正意图。在某些平台上,例如UNIX,它将打印出两个完整的行分隔符;但是在其它一些平台上,例如Windows,它就不会产生这样的行为。尽管这些输出用肉眼看起来是一样的,但是如果它们要被存储到文件中,或是输出到后续的其它处理程序中,那就很容易引发问题。
如果你想打印两行空行,你应该调用println两次。如果使用的是JDK 5.0,那么你可以用带有格式化字符串"%n%n"的printf来代替println。%n的每一次出现都将导致printf打印一个恰当的、与平台相关的行分隔符。
我们希望,上面三个谜题已经使你信服:Unicode转义字符绝对会产生混乱。教训很简单:除非确实是必需的,否则就不要使用Unicode转义字符。它们很少是必需的。
谜题17:嗯?
下面的是一个合法的Java程序吗?如果是,它会打印出什么呢?
\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020\u0020
\u0063\u006c\u0061\u0073\u0073\u0020\u0055\u0067\u006c\u0079
\u007b\u0070\u0075\u0062\u006c\u0069\u0063\u0020\u0020\u0020
\u0020\u0020\u0020\u0020\u0073\u0074\u0061\u0074\u0069\u0063
\u0076\u006f\u0069\u0064\u0020\u006d\u0061\u0069\u006e\u0028
\u0053\u0074\u0072\u0069\u006e\u0067\u005b\u005d\u0020\u0020
\u0020\u0020\u0020\u0020\u0061\u0072\u0067\u0073\u0029\u007b
\u0053\u0079\u0073\u0074\u0065\u006d\u002e\u006f\u0075\u0074
\u002e\u0070\u0072\u0069\u006e\u0074\u006c\u006e\u0028\u0020
\u0022\u0048\u0065\u006c\u006c\u006f\u0020\u0077\u0022\u002b
\u0022\u006f\u0072\u006c\u0064\u0022\u0029\u003b\u007d\u007d
这当然是一个合法的Java程序!这不是很显而易见吗?它会打印Hello World。噢,可能是不那么明显。事实上,该程序根本让人无法理解。每当你没必要地使用了一个Unicode转义字符时,都会使你的程序的可理解性更缺失一点,而该程序将这种做法发挥到了极致。如果你很好奇,可以看看下面给出的该程序在Unicode转义字符都被转换为它们所表示的字符之后的样子:
public
class Ugly
{public
static
void main(
String[]
args){
System.out
.println(
“Hello w”+
“orld”);}}
下面给出了将其进行格式化整理之后的样子:
public class Ugly {
public static void main(String[] args){
System.out.println("Hello w"+"orld");
}
}
这个谜题的教训是:仅仅是因为你可以不以应有的方式去进行表达。或者说,如果你这么做会造成损害,那么就请不要这么做!更严肃地讲,这个谜题是对前面三个教训的补充:Unicode转义字符只有在你要向程序中插入用其他任何方式都无法表示的字符时才是必需的,除此之外的任何情况都不应该避免使用它们。Unicode转义字符降低了程序的清晰度,并且增加了产生bug的可能性。
对语言的设计者来说,也许使用Unicode转义字符来表示ASCII字符应该被定义为是非法的。这样就可以使得在谜题14、15和17(本谜题)中的程序非法,从而消除了大量的混乱。这个限制对程序员并不会造成任何困难
谜题18:字符串奶酪
下面的程序从一个字节序列创建了一个字符串,然后迭代遍历字符串中的字符,并将它们作为数字打印。请描述一下程序打印出来的数字序列:
public class StringCheese {
public static void main(String[] args) {
byte bytes[] = new byte[256];
for (int i = 0; i < 256; i++)
bytes[i] = (byte)i;
String str = new String(bytes);
for (int i = 0, n = str.length(); i < n; i++)
System.out.println((int)str.charAt(i) + " ");
}
}
首先,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的整数:
String str = new String(bytes, "ISO-8859-1");
这个构造器声明会抛出UnsupportedEncodingException异常,因此你必须捕获它,或者更适宜的方式是声明main方法将抛出它,要不然程序不能通过编译。尽管如此,该程序实际上不会抛出异常。Charset的规范要求Java平台的每一种实现都要支持某些种类的字符集,ISO-8859-1就位列其中。
这个谜题的教训是:每当你要将一个byte序列转换成一个String时,你都在使用某一个字符集,不管你是否显式地指定了它。如果你想让你的程序的行为是可预知的,那么就请你在每次使用字符集时都明确地指定。对API的设计者来说,提供这么一个依赖于缺省字符集的String(byte[])构造器可能并非是一个好主意。
谜题19:漂亮的火花
下面的程序用一个方法对字符进行了分类。这个程序会打印出什么呢?
public class Classifier {
public static void main(String[] args) {
System.out.println(
classify('n') + classify('+') + classify('2'));
}
static String classify(char ch) {
if ("0123456789".indexOf(ch) >= 0)
return "NUMERAL ";
if ("abcdefghijklmnopqrstuvwxyz".indexOf(ch) >= 0)
return "LETTER ";
/* (Operators not supported yet)
if ("+-*/&|!=" >= 0)
return "OPERATOR ";
*/
return "UNKNOWN";
}
}
如果你猜想该程序将打印LETTER UNKNOWN NUMERAL,那么你就掉进陷阱里面了。这个程序连编译都通不过。让我们再看一看相关的部分,这一次我们用粗体字突出注释部分:
if ("abcdefghijklmnopqrstuvwxyz".indexOf(ch) >= 0)
return "LETTER ";
/* (Operators not supported yet)
if ("+-*/&|!=" >= 0)
return "OPERATOR ";
*/
return "UNKNOWN";
}
}
正如你之所见,注释在包含了字符*/的字符串内部就结束了,结果使得程序在语法上变成非法的了。我们将程序中的一部分注释出来的尝试之所以失败了,是因为字符串字面常量在注释中没有被特殊处理。
更一般地讲,注释内部的文本没有以任何方式进行特殊处理[JLS 3.7]。因此,块注释不能嵌套。请考虑下面的代码段:
/* Add the numbers from 1 to n */
int sum = 0;
for (int i = 1; I <= n; i++)
sum += i;
现在假设我们要将该代码段注释成为一个块注释,我们再次用粗体字突出整个注释:
/*
/* Add the numbers from 1 to n */
int sum = 0;
for (int i = 1; I <= n; i++)
sum += i;
*/
正如你之所见,我们没有能够将最初的代码段注释掉。好在所产生的代码包含了一个语法错误,因此编译器将会告诉我们代码存在着问题。
你可能偶尔看到过这样的代码段,它被一个布尔表达式为常量false的if语句禁用了:
//code commented out with an if statement - doesn't always work!
if (false) {
/* Add the numbers from 1 to n */
int sum = 0;
for (int i = 1; i <= n; i++)
sum += i;
}
语言规范建议将这种方式作为一种条件编译技术[JLS 14.21],但是它不适合用来注释代码。除非要被禁用的代码是一个合法的语句序列,否则就不要使用这项技术。
注释掉一个代码段的最好的方式是使用单行的注释序列。大多数IDE工具都可以自动化这个过程:
//code commented out with an if statement - doesn't always work!
// /* Add the numbers from 1 to n */
// int sum = 0;
// for (int i = 1; i <= n; i++)
// sum += i;
总之,块注释不能可靠地注释掉代码段,应该用单行的注释序列来代替。对语言设计者来说,应该注意到可嵌套的块注释并不是一个好主意。他们强制编译器去解析块注释内部的文本,而由此引发的问题比它能够解决的问题还要多。
谜题20:我的类是什么?
下面的程序被设计用来打印它的类文件的名称。如果你不熟悉类字面常量,那么我告诉你Me.class.getName()将返回Me类完整的名称,即“com.javapuzzlers.Me”。那么,这个程序会打印出什么呢?
package com.javapuzzlers;
public class Me {
public static void main(String[] args){
System.out.println(
Me.class.getName().
replaceAll(".","/") + ".class");
}
}
该程序看起来会获得它的类名(“com.javapuzzlers.Me”),然后用“/”替换掉所有出现的字符串“.”,并在末尾追加字符串“.class”。你可能会认为该程序将打印com/javapuzzlers/Me.class,该程序正式从这个类文件中被加载的。如果你运行这个程序,就会发现它实际上打印的是///.class。到底怎么回事?难道我们是斜杠的受害者吗?
问题在于String.replaceAll接受了一个正则表达式作为它的第一个参数,而并非接受了一个字符序列字面常量。(正则表达式已经被添加到了Java平台的1.4版本中。)正则表达式“.”可以匹配任何单个的字符,因此,类名中的每一个字符都被替换成了一个斜杠,进而产生了我们看到的输出。
要想只匹配句点符号,在正则表达式中的句点必须在其前面添加一个反斜杠(\)进行转义。因为反斜杠字符在字面含义的字符串中具有特殊的含义——它标识转义字符序列的开始——因此反斜杠自身必须用另一个反斜杠来转义,这样就可以产生一个转义字符序列,它可以在字面含义的字符串中生成一个反斜杠。把这些合在一起,就可以使下面的程序打印出我们所期望的com/javapuzzlers/Me.class:
package com.javapuzzlers;
public class Me {
public static void main(String[] args){
System.out.println(
Me.class.getName().replaceAll("\\.","/") + ".class");
}
}
为了解决这类问题,5.0版本提供了新的静态方法java.util.regex.Pattern.quote。它接受一个字符串作为参数,并可以添加必需的转义字符,它将返回一个正则表达式字符串,该字符串将精确匹配输入的字符串。下面是使用该方法之后的程序:
package com.javapuzzlers;
import java.util.regex.Pattern;
public class Me {
public static void main(String[] args){
System.out.println(Me.class.getName().
replaceAll(Pattern.quote("."),"/") + ".class");
}
}
该程序的另一个问题是:其正确的行为是与平台相关的。并不是所有的文件系统都使用斜杠符号来分隔层次结构的文件名组成部分的。要想获取一个你正在运行的平台上的有效文件名,你应该使用正确的平台相关的分隔符号来代替斜杠符号。这正是下一个谜题所要做的。
谜题21:我的类是什么?II
下面的程序所要做的事情正是前一个谜题所做的事情,但是它没有假设斜杠符号就是分隔文件名组成部分的符号。相反,该程序使用的是java.io.File.separator,它被指定为一个公共的String域,包含了平台相关的文件名分隔符。那么,这个程序会打印出其正确的、平台相关的类文件名吗?
package com.javapuzzlers;
import java.io.File;
public class MeToo {
public static void main(String[] args){
System.out.println(MeToo.class.getName().
replaceAll("\\.", File.separator) + ".class");
}
}
这个程序根据底层平台的不同会显示两种行为中的一种。如果文件分隔符是斜杠,就像在UNIX上一样,那么该程序将打印com/javapuzzlers/MeToo.class,这是正确的。但是,如果文件分隔符是反斜杠,就像在Windows上一样,那么该程序将打印像下面这样的内容:
Exception in thread "main"
java.lang.StringIndexOutOfBoundsException: String index out of range: 1
at java.lang.String.charAt(String.java:558)
at java.util.regex.Matcher.appendReplacement(Mather.
java:696)
at java.util.regex.Matcher.replaceAll(Mather.java:806)
at java.lang.String.replaceAll(String.java:2000)
at com.javapuzzlers.MeToo.main(MeToo.java:6)
尽管这种行为是平台相关的,但是它并非就是我们所期待的。在Windows上出了什么错呢?
事实证明,String.replaceAll的第二个参数不是一个普通的字符串,而是一个替代字符串(replacement string),就像在java.util.regex规范中所定义的那样[Java-API]。在替代字符串中出现的反斜杠会把紧随其后的字符进行转义,从而导致其被按字面含义而处理了。
当你在Windows上运行该程序时,替代字符串是单独的一个反斜杠,它是无效的。不可否认,抛出的异常应该提供更多一些有用的信息。
那么你应该怎样解决此问题呢?5.0版本提供了不是一个而是两个新的方法来解决它。第一个方法是java.util.regex.Matcher.quoteReplacement,它将字符串转换成相应的替代字符串。下面展示了如何使用这个方法来订正该程序:
System.out.println(MeToo.class.getName().replaceAll("\\.",
Matcher.quoteReplacement(File.separator)) + ".class");
引入到5.0版本中的第二个方法提供了一个更好的解决方案。该方法就是String.replace(CharSequence, CharSequence),它做的事情和String.replaceAll相同,但是它将模式和替代物都当作字面含义的字符串处理。下面展示了如何使用这个方法来订正该程序:
System.out.println(MeToo.class.getName().
replace(".", File.separator) + ".class");
但是如果你使用的是较早版本的Java该怎么办?很遗憾,没有任何捷径能够生成替代字符串。完全不使用正则表达式,而使用String.replace(char,char)也许要显得更容易一些:
System.out.println(MeToo.class.getName().
replace('.', File.separatorChar) + ".class");
本谜题和前一个谜题的主要教训是:在使用不熟悉的类库方法时一定要格外小心。当你心存疑虑时,就要求助于Javadoc。还有就是正则表达式是很棘手的:它所引发的问题趋向于在运行时刻而不是在编译时刻暴露出来。
对API的设计者来说,使用方法具名的模式来以明显的方式区分方法行为的差异是很重要的。Java的String类就没有很好地遵从这一原则。对许多程序员来说,对于哪些字符串替代方法使用的是字面含义的字符串,以及哪些使用的是正则表达式或替代字符串,要记住这些都不是一件容易事。
谜题22:URL的愚弄
本谜题利用了Java编程语言中一个很少被人了解的特性。请考虑下面的程序将会做些什么?
public class BrowserTest {
public static void main(String[] args) {
System.out.print("iexplore:");
http://www.google.com;
System.out.println(":maximize");
}
}
这是一个有点诡异的问题。该程序将不会做任何特殊的事情,而是直接打印iexplore::maximize。在程序中间出现的URL是一个语句标号(statement label)[JLS 14.7]后面跟着一行行尾注释(end-of-line comment)[JLS 3.7]。在Java中很少需要标号,这多亏了Java没有goto语句。在本谜题中所引用的“Java编程语言中很少被人了解的特性”实际上就是你可以在任何语句前面放置标号。这个程序标注了一个表达式语句,它是合法的,但是却没什么用处。
它的价值所在,就是提醒你,如果你真的想要使用标号,那么应该用一种更合理的方式来格式化程序:
public class BrowserTest {
public static void main(String[] args) {
System.out.print("iexplore:");
http: //www.google.com;
System.out.println(":maximize");
}
}
这就是说,我们没有任何可能的理由去使用与程序没有任何关系的标号和注释。
本谜题的教训是:令人误解的注释和无关的代码会引起混乱。要仔细地写注释,并让它们跟上时代;要切除那些已遭废弃的代码。还有就是如果某些东西看起来过于奇怪,以至于不像对的,那么它极有可能就是错的。
谜题23:不劳无获
下面的程序将打印一个单词,其第一个字母是由一个随机数生成器来选择的。请描述该程序的行为:
import java.util.Random;
public class Rhymes {
private static Random rnd = new Random();
public static void main(String[] args) {
StringBuffer word = null;
switch(rnd.nextInt(2)) {
case 1: word = new StringBuffer('P');
case 2: word = new StringBuffer('G');
default: word = new StringBuffer('M');
}
word.append('a');
word.append('i');
word.append('n');
System.out.println(word);
}
}
乍一看,这个程序可能会在一次又一次的运行中,以相等的概率打印出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:
import java.util.Random;
public class Rhymes1 {
private static Random rnd = new Random();
public static void main(String[] args) {
StringBuffer word = null;
switch(rnd.nextInt(3)) {
case 1:
word = new StringBuffer("P");
break;
case 2:
word = new StringBuffer("G");
break;
default:
word = new StringBuffer("M");
break;
}
word.append('a');
word.append('i');
word.append('n');
System.out.println(word);
}
}
尽管这个程序订正了所有的bug,它还是显得过于冗长了。下面是一个更优雅的版本:
import java.util.Random;
public class Rhymes2 {
private static Random rnd = new Random();
public static void main(String[] args) {
System.out.println("PGM".charAt(rnd.nextInt(3)) + "ain");
}
}
下面是一个更好的版本。尽管它稍微长了一点,但是它更加通用。它不依赖于所有可能的输出只是在它们的第一个字符上有所不同的这个事实:
import java.util.Random;
public class Rhymes3 {
public static void main(String[] args) {
String a[] = {"Main","Pain","Gain"};
System.out.println(randomElement(a));
}
private static Random rnd = new Random();
private static String randomElement(String[] a){
return a[rnd.nextInt(a.length)];
}
}
总结一下:首先,要当心栅栏柱错误。其次,牢记在 switch 语句的每一个 case 中都放置一条 break 语句。第三,要使用常用的惯用法和 API,并且当你在离开老路子的时候,一定要参考相关的文档。第四,一个 char 不是一个 String,而是更像一个 int。最后,要提防各种诡异的谜题
JAVA 解惑文章(谜题24:尽情享受每一个字节)
下面的程序循环遍历byte数值,以查找某个特定值。这个程序会打印出什么呢?
public class BigDelight {
public static void main(String[] args) {
for (byte b = Byte.MIN_VALUE; b < Byte.MAX_VALUE; b++) {
if (b == 0x90)
System.out.print("Joy!");
}
}
}
这个循环在除了Byte.MAX_VALUE之外所有的byte数值中进行迭代,以查找0x90。这个数值适合用byte表示,并且不等于Byte.MAX_VALUE,因此你可能会想这个循环在该迭代会找到它一次,并将打印出Joy!。但是,所见为虚。如果你运行该程序,就会发现它没有打印任何东西。怎么回事?
简单地说,0x90是一个int常量,它超出了byte数值的范围。这与直觉是相悖的,因为0x90是一个两位的十六进制字面常量,每一个十六进制位都占据4个比特的位置,所以整个数值也只占据8个比特,即1个byte。问题在于byte是有符号类型。常量0x90是一个正的最高位被置位的8位int数值。合法的byte数值是从-128到+127,但是int常量0x90等于+144。
拿一个byte与一个int进行的比较是一个混合类型比较(mixed-type comparison)。如果你把byte数值想象为苹果,把int数值想象成为桔子,那么该程序就是在拿苹果与桔子比较。请考虑表达式((byte)0x90 == 0x90),尽管外表看起来是成立的,但是它却等于false。
为了比较byte数值(byte)0x90和int数值0x90,Java通过拓宽原始类型转换将byte提升为一个int[JLS 5.1.2],然后比较这两个int数值。因为byte是一个有符号类型,所以这个转换执行的是符号扩展,将负的byte数值提升为了在数字上相等的int数值。在本例中,该转换将(byte)0x90提升为int数值-112,它不等于int数值0x90,即+144。
由于系统总是强制地将一个操作数提升到与另一个操作数相匹配的类型,所以混合类型比较总是容易把人搞糊涂。这种转换是不可视的,而且可能不会产生你所期望的结果。有若干种方法可以避免混合类型比较。我们继续有关水果的比喻,你可以选择拿苹果与苹果比较,或者是拿桔子与桔子比较。你可以将int转型为byte,之后你就可以拿一个byte与另一个byte进行比较了:
if (b == (byte)0x90)
System.out.println("Joy!");
或者,你可以用一个屏蔽码来消除符号扩展的影响,从而将byte转型为int,之后你就可以拿一个int与另一个int进行比较了:
if ((b & 0xff) == 0x90)
System.out.print("Joy!");
上面的两个解决方案都可以正常运行,但是避免这类问题的最佳方法还是将常量值移出到循环的外面,并将其在一个常量声明中定义它。下面是我们对此作出的第一个尝试:
public class BigDelight {
private static final byte TARGET = 0x90;
public static void main(String[] args) {
for (byte b = Byte.MIN_VALUE; b <
Byte.MAX_VALUE; b++) {
if (b == TARGET)
System.out.print("Joy!");
}
}
}
遗憾的是,它根本就通不过编译。常量声明有问题,编译器会告诉你问题所在:0x90对于byte类型来说不是一个有效的数值。如果你想下面这样订正该声明,那么程序将运行得非常好:
private static final byte TARGET = (byte)0x90;
总之,要避免混合类型比较,因为它们内在地容易引起混乱(谜题5)。为了帮助实现这个目标,请使用声明的常量替代“魔幻数字”。你已经了解了这确实是一个好主意:它说明了常量的含义,集中了常量的定义,并且根除了重复的定义。现在你知道它还可以强制你去为每一个常量赋予适合其用途的类型,从而消除了产生混合类型比较的一种根源。
对语言设计的教训是byte数值的符号扩展是产生bug和混乱的一种常见根源。而用来抵销符号扩展效果所需的屏蔽机制会使得程序显得混乱无序,从而降低了程序的可读性。因此,byte类型应该是无符号的。还可以考虑为所有的原始类型提供定义字面常量的机制,这可以减少对易于产生错误的类型转换的需求(谜题27)。
JAVA 解惑文章(谜题25:无情的增量操作)
下面的程序对一个变量重复地进行增量操作,然后打印它的值。那么这个值是什么呢?
public class Increment {
public static void main(String[] args) {
int j = 0;
for (int i = 0; i < 100; i++)
j = j++;
System.out.println(j);
}
}
乍一看,这个程序可能会打印100。毕竟,它对j做了100次增量操作。可能会令你感到有些震惊,它打印的不是100而是0。所有的增量操作都无影无踪了,为什么?
就像本谜题的题目所暗示的,问题出在了执行增量操作的语句上:
j = j++;
大概该语句的作者是想让它执行对j的值加1的操作,也就是表达式j++所做的操作。遗憾的是,作者大咧咧地将这个表达式的值有赋回给了j。
当++操作符被置于一个变量值之后时,其作用就是一个后缀增量操作符(postfix increment operator)[JLS 15.14.2]:表达式j++的值等于j在执行增量操作之前的初始值。因此,前面提到的赋值语句首先保存j的值,然后将j设置为其值加1,最后将j复位到它的初始值。换句话说,这个赋值操作等价于下面的语句序列:
int tmp = j;
j = j + 1;
j = tmp?;
程序重复该过程100次,之后j的值还是等于它在循环开始之前的值,即0。
订正该程序非常简单,只需从循环中移除无关的赋值操作,只留下:
for (int i = 0; i < 100; i++)
j++;
经过这样的修改,程序就可以打印出我们所期望的100了。
这与谜题7中的教训相同:不要在单个的表达式中对相同的变量赋值超过一次。对相同的变量进行多次赋值的表达式会产生混淆,并且很少能够产生你希望的行为。
JAVA 解惑文章(谜题26:在循环中)
下面的程序计算了一个循环的迭代次数,并且在该循环终止时将这个计数值打印了出来。那么,它打印的是什么呢?
public class InTheLoop {
public static final int END = Integer.MAX_VALUE;
public static final int START = END - 100;
public static void main(String[] args) {
int count = 0;
for (int i = START; i <= END; i++)
count++;
System.out.println(count);
}
}
如果你没有非常仔细地查看这个程序,你可能会认为它将打印100,因为END比START大100。如果你稍微仔细一点,你可能会发现该程序没有使用典型的循环惯用法。大多数的循环会在循环索引小于终止值时持续运行,而这个循环则是在循环索引小于或等于终止值时持续运行。所以它会打印101,对吗?
嗯,根本不对。如果你运行该程序,就会发现它压根就什么都没有打印。更糟的是,它会持续运行直到你撤销它为止。它从来都没有机会去打印count,因为在打印它的语句之前插入的是一个无限循环。
问题在于这个循环会在循环索引(i)小于或等于Integer.MAX_VALUE时持续运行,但是所有的int变量都是小于或等于Integer.MAX_VALUE的。因为它被定义为所有int数值中的最大值。当i达到Integer.MAX_VALUE,并且再次被执行增量操作时,它就有绕回到了Integer.MIN_VALUE。
如果你需要的循环会迭代到int数值的边界附近时,你最好是使用一个long变量作为循环索引。只需将循环索引的类型从int改变为long就可以解决该问题,从而使程序打印出我们所期望的101:
for (long i = START; i <= END; i++)
更一般地讲,这里的教训就是int不能表示所有的整数。无论你在何时使用了一个整数类型,都要意识到其边界条件。如果其数值下溢或是上溢了,会怎么样呢?所以通常最好是使用一个取之范围更大的类型。(整数类型包括byte、char、short、int和long。)
不使用long类型的循环索引变量也可以解决该问题,但是它看起来并不那么漂亮:
int i = START;
do {
count++;
}while (i++ != END);
如果清晰性和简洁性占据了极其重要的地位,那么在这种情况下使用一个long类型的循环索引几乎总是最佳方案。
但是有一个例外:如果你在所有的(或者几乎所有的)int数值上迭代,那么使用int类型的循环索引的速度大约可以提高一倍。下面是将f函数作用于所有40亿个int数值上的惯用法:
//Apply the function f to all four billion int values
int i = Integer.MIN_VALUE;
do {
f(i);
}while (i++ != Integer.MAX_VALUE);
该谜题对语言设计者的教训与谜题3相同:可能真的值得去考虑,应该对那些不会在产生溢出时而不抛出异常的算术运算提供支持。同时,可能还值得去考虑,应该对那些在整数值范围之上进行迭代的循环进行特殊设计,就像许多其他语言所做的那样。
JAVA 解惑文章(谜题27:变幻莫测的i值)
与谜题26中的程序一样,下面的程序也包含了一个记录在终止前有多少次迭代的循环。与那个程序不同的是,这个程序使用的是左移操作符(<<)。你的任务照旧是要指出这个程序将打印什么。当你阅读这个程序时,请记住 Java 使用的是基于2的补码的二进制算术运算,因此-1在任何有符号的整数类型中(byte、short、int或long)的表示都是所有的位被置位:
public class Shifty {
public static void main(String[] args) {
int i = 0;
while (-1 << i != 0)
i++;
System.out.println(i);
}
}
常量-1是所有32位都被置位的int数值(0xffffffff)。左移操作符将0移入到由移位所空出的右边的最低位,因此表达式(-1 << i)将i最右边的位设置为0,并保持其余的32 - i位为1。很明显,这个循环将完成32次迭代,因为-1 << i对任何小于32的i来说都不等于0。你可能期望终止条件测试在i等于32时返回false,从而使程序打印32,但是它打印的并不是32。实际上,它不会打印任何东西,而是进入了一个无限循环。
问题在于(-1 << 32)等于-1而不是0,因为移位操作符之使用其右操作数的低5位作为移位长度。或者是低6位,如果其左操作数是一个long类数值[JLS 15.19]。
这条规则作用于全部的三个移位操作符:<<、>>和>>>。移位长度总是介于0到31之间,如果左操作数是long类型的,则介于0到63之间。这个长度是对32取余的,如果左操作数是long类型的,则对64取余。如果试图对一个int数值移位32位,或者是对一个long数值移位64位,都只能返回这个数值自身的值。没有任何移位长度可以让一个int数值丢弃其所有的32位,或者是让一个long数值丢弃其所有的64位。
幸运的是,有一个非常容易的方式能够订正该问题。我们不是让-1重复地移位不同的移位长度,而是将前一次移位操作的结果保存起来,并且让它在每一次迭代时都向左再移1位。下面这个版本的程序就可以打印出我们所期望的32:
public class Shifty {
public static void main(String[] args) {
int distance = 0;
for (int val = -1; val != 0; val <<= 1)
distance++;
System.out.println(distance);
}
}
这个订正过的程序说明了一条普遍的原则:如果可能的话,移位长度应该是常量。如果移位长度紧盯着你不放,那么你让其值超过31,或者如果左操作数是long类型的,让其值超过63的可能性就会大大降低。当然,你并不可能总是可以使用常量的移位长度。当你必须使用一个非常量的移位长度时,请确保你的程序可以应付这种容易产生问题的情况,或者压根就不会碰到这种情况。
前面提到的移位操作符的行为还有另外一个令人震惊的结果。很多程序员都希望具有负的移位长度的右移操作符可以起到左移操作符的作用,反之亦然。但是情况并非如此。右移操作符总是起到右移的作用,而左移操作符也总是起到左移的作用。负的移位长度通过只保留低5位而剔除其他位的方式被转换成了正的移位长度——如果左操作数是long类型的,则保留低6位。因此,如果要将一个int数值左移,其移位长度为-1,那么移位的效果是它被左移了31位。
总之,移位长度是对32取余的,或者如果左操作数是long类型的,则对64取余。因此,使用任何移位操作符和移位长度,都不可能将一个数值的所有位全部移走。同时,我们也不可能用右移操作符来执行左移操作,反之亦然。如果可能的话,请使用常量的移位长度,如果移位长度不能设为常量,那么就要千万当心。
语言设计者可能应该考虑将移位长度限制在从0到以位为单位的类型尺寸的范围内,并且修改移位长度为类型尺寸时的语义,让其返回0。尽管这可以避免在本谜题中所展示的混乱情况,但是它可能会带来负面的执行结果,因为Java的移位操作符的语义正是许多处理器上的移位指令的语义。
JAVA 解惑文章(谜题28:循环者)
下面的谜题以及随后的五个谜题对你来说是扭转了局面,它们不是向你展示某些代码,然后询问你这些代码将做些什么,它们要让你去写代码,但是数量会很少。这些谜题被称为“循环者(looper)”。你眼前会展示出一个循环,它看起来应该很快就终止的,而你的任务就是写一个变量声明,在将它作用于该循环之上时,使得该循环无限循环下去。例如,考虑下面的for循环:
for (int i = start; i <= start + 1; i++) {}
看起来它好像应该只迭代两次,但是通过利用在谜题26中所展示的溢出行为,可以使它无限循环下去。下面的的声明就采用了这项技巧:
int start = Integer.MAX_VALUE - 1;
现在该轮到你了。什么样的声明能够让下面的循环变成一个无限循环?
While (i == i + 1) {}
仔细查看这个while循环,它真的好像应该立即终止。一个数字永远不会等于它自己加1,对吗?嗯,如果这个数字是无穷大的,又会怎样呢?Java强制要求使用IEEE 754浮点数算术运算[IEEE 754],它可以让你用一个double或float来表示无穷大。正如我们在学校里面学到的,无穷大加1还是无穷大。如果i在循环开始之前被初始化为无穷大,那么终止条件测试(i == i + 1)就会被计算为true,从而使循环永远都不会终止。
你可以用任何被计算为无穷大的浮点算术表达式来初始化i,例如:
double i = 1.0 / 0.0;
不过,你最好是能够利用标准类库为你提供的常量:
double i = Double.POSITIVE_INFINITY;
事实上,你不必将i初始化为无穷大以确保循环永远执行。任何足够大的浮点数都可以实现这一目的,例如:
double i = 1.0e40;
这样做之所以可以起作用,是因为一个浮点数值越大,它和其后继数值之间的间隔就越大。浮点数的这种分布是用固定数量的有效位来表示它们的必然结果。对一个足够大的浮点数加1不会改变它的值,因为1是不足以“填补它与其后继者之间的空隙”。
浮点数操作返回的是最接近其精确的数学结果的浮点数值。一旦毗邻的浮点数值之间的距离大于2,那么对其中的一个浮点数值加1将不会产生任何效果,因为其结果没有达到两个数值之间的一半。对于float类型,加1不会产生任何效果的最小级数是225,即33,554,432;而对于double类型,最小级数是254,大约是1.8 × 1016。
毗邻的浮点数值之间的距离被称为一个ulp,它是“最小单位(unit in the last place)”的首字母缩写词。在5.0版中,引入了Math.ulp方法来计算float或double数值的ulp。
总之,用一个double或一个float数值来表示无穷大是可以的。大多数人在第一次听到这句话时,多少都会有一点吃惊,可能是因为我们无法用任何整数类型来表示无穷大的原因。第二点,将一个很小的浮点数加到一个很大的浮点数上时,将不会改变大的浮点数的值。这过于违背直觉了,因为对实际的数字来说这是不成立的。我们应该记住二进制浮点算术只是对实际算术的一种近似。
JAVA 解惑文章(29 - 31)
请提供一个对i的声明,将下面的循环转变为一个无限循环:
while (i != i) {
}
这个循环可能比前一个还要使人感到困惑。不管在它前面作何种声明,它看起来确实应该立即终止。一个数字总是等于它自己,对吗?
对,但是IEEE 754浮点算术保留了一个特殊的值用来表示一个不是数字的数量[IEEE 754]。这个值就是NaN(“不是一个数字(Not a Number)”的缩写),对于所有没有良好的数字定义的浮点计算,例如0.0/0.0,其值都是它。规范中描述道,NaN不等于任何浮点数值,包括它自身在内[JLS15.21.1]。因此,如果i在循环开始之前被初始化为NaN,那么终止条件测试(i != i)的计算结果就是true,循环就永远不会终止。很奇怪但却是事实。
你可以用任何计算结果为NaN的浮点算术表达式来初始化i,例如:
double i = 0.0 / 0.0;
同样,为了表达清晰,你可以使用标准类库提供的常量:
double i = Double.NaN;
NaN还有其他的惊人之处。任何浮点操作,只要它的一个或多个操作数为NaN,那么其结果为NaN。这条规则是非常合理的,但是它却具有奇怪的结果。例如,下面的程序将打印false:
class Test {
public static void main(String[] args) {
double i = 0.0 / 0.0;
System.out.println(i - i == 0);
}
}
这条计算NaN的规则所基于的原理是:一旦一个计算产生了NaN,它就被损坏了,没有任何更进一步的计算可以修复这样的损坏。NaN值意图使受损的计算继续执行下去,直到方便处理这种情况的地方为止。
总之,float和double类型都有一个特殊的NaN值,用来表示不是数字的数量。对于涉及NaN值的计算,其规则很简单也很明智,但是这些规则的结果可能是违背直觉的。
谜题30:循环者的爱子
请提供一个对i的声明,将下面的循环转变为一个无限循环:
while (i != i + 0) {
}
与前一个谜题不同,你必须在你的答案中不使用浮点数。换句话说,你不能把i声明为double或float类型的。
与前一个谜题一样,这个谜题初看起来是不可能实现的。毕竟,一个数字总是等于它自身加上0,你被禁止使用浮点数,因此不能使用NaN,而在整数类型中没有NaN的等价物。那么,你能给出什么呢?
我们必然可以得出这样的结论,即i的类型必须是非数值类型的,并且这其中存在着解谜方案。唯一的 + 操作符有定义的非数值类型就是String。+操作符被重载了:对于String类型,它执行的不是加法而是字符串连接。如果在连接中的某个操作数具有非String的类型,那么这个操作书就会在连接之前转换成字符串[JLS 15.18.1]。
事实上,i可以被初始化为任何值,只要它是String类型的即可,例如:
String i = "Buy seventeen copies of Effective Java";
int类型的数值0被转换成String类型的数值”0”,并且被追加到了感叹号之后,所产生的字符串在用equals方法计算时就不等于最初的字符串了,这样它们在使用==操作符进行计算时,当然就不是相等的。因此,计算布尔表达式(i != i + 0)得到的值就是true,循环也就永远不会被终止了。
总之,操作符重载是很容易令人误解的。在本谜题中的加号看起来是表示一个加法,但是通过为变量i选择合适的类型,即String,我们让它执行了字符串连接操作。甚至是因为变量被命名为i,都使得本谜题更加容易令人误解,因为i通常被当作整型变量名而被保留的。对于程序的可读性来说,好的变量名、方法名和类名至少与好的注释同等重要。
对语言设计者的教训与谜题11和13中的教训相同。操作符重载是很容易引起混乱的,也许 + 操作符就不应该被重载用来进行字符串连接操作。有充分的理由证明提供一个字符串连接操作符是多么必要,但是它不应该是 + 。
谜题31:循环者的鬼魂
请提供一个对i的声明,将下面的循环转变为一个无限循环:
while (i != 0) {
i >>>= 1;
}
回想一下,>>>=是对应于无符号右移操作符的赋值操作符。0被从左移入到由移位操作而空出来的位上,即使被移位的负数也是如此。
这个循环比前面三个循环要稍微复杂一点,因为其循环体非空。在其循环题中,i的值由它右移一位之后的值所替代。为了使移位合法,i必须是一个整数类型(byte、char、short、int或long)。无符号右移操作符把0从左边移入,因此看起来这个循环执行迭代的次数与最大的整数类型所占据的位数相同,即64次。如果你在循环的前面放置如下的声明,那么这确实就是将要发生的事情:
long i = -1; // -1L has all 64 bits set
你怎样才能将它转变为一个无限循环呢?解决本谜题的关键在于>>>=是一个复合赋值操作符。(复合赋值操作符包括*=、/=、%=、+=、-=、<<=、>>=、>>>=、&=、^=和|=。)有关混合操作符的一个不幸的事实是,它们可能会自动地执行窄化原始类型转换[JLS 15.26.2],这种转换把一种数字类型转换成了另一种更缺乏表示能力的类型。窄化原始类型转换可能会丢失级数的信息,或者是数值的精度[JLS 5.1.3]。
让我们更具体一些,假设你在循环的前面放置了下面的声明:
short i = -1;
因为i的初始值((short)0xffff)是非0的,所以循环体会被执行。在执行移位操作时,第一步是将i提升为int类型。所有算数操作都会对short、byte和char类型的操作数执行这样的提升。这种提升是一个拓宽原始类型转换,因此没有任何信息会丢失。这种提升执行的是符号扩展,因此所产生的int数值是0xffffffff。然后,这个数值右移1位,但不使用符号扩展,因此产生了int数值0x7fffffff。最后,这个数值被存回到i中。为了将int数值存入short变量,Java执行的是可怕的窄化原始类型转换,它直接将高16位截掉。这样就只剩下(short)oxffff了,我们又回到了开始处。循环的第二次以及后续的迭代行为都是一样的,因此循环将永远不会终止。
如果你将i声明为一个short或byte变量,并且初始化为任何负数,那么这种行为也会发生。如果你声明i为一个char,那么你将无法得到无限循环,因为char是无符号的,所以发生在移位之前的拓宽原始类型转换不会执行符号扩展。
总之,不要在short、byte或char类型的变量之上使用复合赋值操作符。因为这样的表达式执行的是混合类型算术运算,它容易造成混乱。更糟的是,它们执行将隐式地执行会丢失信息的窄化转型,其结果是灾难性的。
对语言设计者的教训是语言不应该自动地执行窄化转换。还有一点值得好好争论的是,Java是否应该禁止在short、byte和char变量上使用复合赋值操作符。