Java谜题3——循环谜题

Java谜题3——循环谜题

谜题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)。

谜题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中的教训相同:不要在单个的表达式中对相同的变量赋值超过一次。对相同的变量进行多次赋值的表达式会产生混淆,并且很少能够产生你希望的行为。

谜题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相同:可能真的值得去考虑,应该对那些不会在产生溢出时而不抛出异常的算术运算提供支持。同时,可能还值得去考虑,应该对那些在整数值范围之上进行迭代的循环进行特殊设计,就像许多其他语言所做的那样。

谜题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的移位操作符的语义正是许多处理器上的移位指令的语义。

谜题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数值来表示无穷大是可以的。大多数人在第一次听到这句话时,多少都会有一点吃惊,可能是因为我们无法用任何整数类型来表示无穷大的原因。第二点,将一个很小的浮点数加到一个很大的浮点数上时,将不会改变大的浮点数的值。这过于违背直觉了,因为对实际的数字来说这是不成立的。我们应该记住二进制浮点算术只是对实际算术的一种近似。

谜题29:循环者的新娘

请提供一个对i的声明,将下面的循环转变为一个无限循环:

while (i != i) {

}

这个循环可能比前一个还要使人感到困惑。不管在它前面作何种声明,它看起来确实应该立即终止。一个数字总是等于它自己,对吗?

对,但是IEEE 754浮点算术保留了一个特殊的值用来表示一个不是数字的数量[IEEE 754]。这个值就是NaN(“不是一个数字(Not a Number)”的缩写),对于所有没有良好的数字定义的浮点计算,例如0.0/0.0,其值都是它。规范中描述道,NaN不等于任何浮点数值,包括它自身在内[JLS 15.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变量上使用复合赋值操作符。

谜题32:循环者的诅咒

请提供一个对i的声明,将下面的循环转变为一个无限循环:

while (i <= j && j <= i && i != j) {

}

噢,不,不要再给我看起来不可能的循环了!如果i <= j 并且 j <= i,i不是肯定等于j吗?这一属性对实数肯定有效。事实上,它是如此地重要,以至于它有这样的定义:实数上的≤关系是反对称的。Java的<=操作符在5.0版之前是反对称的,但是这从5.0版之后就不再是了。

直到5.0版之前,Java的数字比较操作符(<、<=、>和>=)要求它们的两个操作数都是原始数字类型的(byte、char、short、int、long、float和double)[JLS 15.20.1]。但是在5.0版中,规范作出了修改,新规范描述道:每一个操作数的类型必须可以转换成原始数字类型[JLS 15.20.1,5.1.8]。问题难就难在这里了。

在5.0版中,自动包装(autoboxing)和自动反包装(auto-unboxing)被添加到了Java语言中。如果你对它们并不了解,请查看:http://java.sun.com/j2se/5.0/docs/guide/language/autoboxing.html [Boxing]。<=操作符在原始数字类型集上仍然是反对称的,但是现在它还被应用到了被包装的数字类型上。(被包装的数字类型有:Byte、Character、Short、Integer、Long、Float和Double。)<=操作符在这些类型的操作数上不是反对称的,因为Java的判等操作符(==和!=)在作用于对象引用时,执行的是引用ID的比较,而不是值的比较。

让我们更具体一些,下面的声明赋予表达式(i <= j && j <= i && i != j)的值为true,从而将这个循环变成了一个无限循环:

Integer i = new Integer(0);

Integer j = new Integer(0);

前两个子表达式(i <= j 和j <= i)在i和j上执行解包转换[JLS 5.1.8],并且在数字上比较所产生的int数值。i和j都表示0,所以这两个子表达式都被计算为true。第三个子表达式(i != j)在对象引用i和j上执行标识比较,因为它们都初始化为一个新的Integer实例,因此,第三个子表达式同样也被计算为true,循环也就永远地环绕下去了。

你可能会感到奇怪,为什么语言规范没有修改为:当判等操作符作用于被包装的数字类型时,它们执行的是值比较。答案很简单:兼容性。当一种语言被广泛使用之后,以违反现有规范的方式去改变现有程序的行为是让人无法接受的。下面的程序过去总是保证可以打印false,因此它必须继续保持此特征:

public class ReferenceComparison {

public static void main(String[] args) {

System.out.println(

new Integer(0) == new Integer(0));

}

}

判等操作符在其两个操作数中只有一个是被包装的数字类型,而另一个是原始类型时,执行的确实是数值比较。因为这在5.0版之前是非法的,所有在这里没有任何兼容性的问题。让我们更具体一些,下面的程序在1.4版中是非法的,而在5.0版中将打印true:

public class ValueComparison {

public static void main(String[] args) {

System.out.println(

new Integer(0) == 0);

}

}

总之,当两个操作数都是被包装的数字类型时,数值比较操作符和判等操作符的行为存在着根本的差异:数值比较操作符执行的是值比较,而判等操作符执行的是引用标识的比较。

对语言设计者来说,如果判等操作符一直执行的都是数值比较(谜题13),那么生活可能就要简单得多、快乐得多。也许真正的教训应该是:语言设计者应该拥有高质量的水晶球,以预测语言的未来,并且做出相应的设计决策。严肃一点地讲,语言设计者应该考虑语言可能会如何演化,并且应该努力去最小化在演化之路上的各种制约影响。

谜题33:循环者遇到了狼人

请提供一个对i的声明,将下面的循环转变为一个无限循环。这个循环不需要使用任何5.0版的特性:

while (i != 0 && i == -i) {

}

这仍然是一个循环。在布尔表达式(i != 0 && i == -i)中,一元减号操作符作用于i,这意味着它的类型必须是数字型的:一元减号操作符作用于一个非数字型操作数是非法的。因此,我们要寻找一个非0的数字型数值,它等于它自己的负值。NaN不能满足这个属性,因为它不等于任何数值,因此,i必须表示一个实际的数字。肯定没有任何数字满足这样的属性吗?

嗯,没有任何实数具有这种属性,但是没有任何一种Java数值类型能够对实数进行完美建模。浮点数值是用一个符号位、一个被通俗地称为尾数(mantissa)的有效数字以及一个指数来表示的。除了0之外,没有任何浮点数等于其符号位反转之后的值,因此i的类型必然是整数型的。

有符号的整数类型使用的是2的补码算术运算:为了对一个数值取其负值,你要反转其每一位,然后加1,从而得到结果[JLS 15.15.4]。2的补码算术运算的一个很大的优势是,0具有唯一的表示形式。如果你要对int数值0取负值,你将得到0xffffffff+1,它仍然是0。

但是,这也有一个相应的不利之处,总共存在偶数个int数值——准确地说有232个——其中一个用来表示0,这样就剩些奇数个int数值来表示正整数和负整数,这意味着正的和负的int数值的数量必然不相等。这暗示着至少有一个int数值,其负值不能正确地表示成为一个int数值。

事实上,恰恰就有一个这样的int数值,它就是Integer.MIN_VALUE,即-231。他的十六进制表示是0x80000000。其符号位为1,其余所有的位都是0。如果我们对这个值取负值,那么我们将得到0x7fffffff+1,也就是0x80000000,即Integer.MIN_VALUE!换句话说,Integer.MIN_VALUE是它自己的负值,Long.MIN_VALUE也是一样。对这两个值取负值将会产生溢出,但是Java在整数计算中忽略了溢出。其结果已经阐述清楚了,即使它们并不总是你所期望的。

下面的声明将使得布尔表达式(i != 0 && i == -i)的计算结果为true,从而使循环无限环绕下去:

int i = Integer.MIN_VALUE;

下面这个也可以:

long i = Long.MIN_VALUE;

如果你对取模运算很熟悉,那么很有必要指出,这个谜题也可以用代数方法解决。Java的int算术运算是实际的算术运算对232取模的运算,因此本谜题需要一个对这种线性全等的非0解决方案:

i ≡ -i(mod 232)

将i加到恒等式的两边,我们可以得到:

2i ≡ 0(mod 32)

对这种全等的非0解决方案就是 i = 231。尽管这个值不能表示成为一个int,但是它是和-231全等的,即与Integer.MIN_VALUE全等。

总之,Java使用2的补码的算术运算,它是非对称的。对于每一种有符号的整数类型(int、long、byte和short),负的数值总是比正的数值多一个,这个多出来的值总是这种类型所能表示的最小数值。对Integer.MIN_VALUE取负值得到的还是它没有改变过的值,Long.MIN_VALUE也是如此。对Short.MIN_VALUE取负值并将所产生的int数值转型回short,返回的同样是最初的值(Short.MIN_VALUE)。对Byte.MIN_VALUE来说,也会产生相似的结果。更一般地讲,千万要当心溢出:就像狼人一样,它是个杀手。

对语言设计者的教训与谜题26中的教训一样。应该对某种溢出不会悄悄发生的整数算术运算形式提供语言级的支持。

谜题34:被计数击倒了

与谜题26和27中的程序一样,下面的程序有一个单重的循环,它记录迭代的次数,并在循环终止时打印这个数。那么,这个程序会打印出什么呢?

public class Count {

public static void main(String[] args) {

final int START = 2000000000;

int count = 0;

for (float f = START; f < START + 50; f++)

count++;

System.out.println(count);

}

}

表面的分析也许会认为这个程序将打印50,毕竟,循环变量(f)被初始化为2,000,000,000,而终止值比初始值大50,并且这个循环具有传统的“半开”形式:它使用的是 < 操作符,这是的它包括初始值但是不包括终止值。

然而,这种分析遗漏了关键的一点:循环变量是float类型的,而非int类型的。回想一下谜题28,很明显,增量操作(f++)不能正常工作。F的初始值接近于Integer.MAX_VALUE,因此它需要用31位来精确表示,而float类型只能提供24位的精度。对如此巨大的一个float数值进行增量操作将不会改变其值。因此,这个程序看起来应该无限地循环下去,因为f永远也不可能解决其终止值。但是,如果你运行该程序,就会发现它并没有无限循环下去,事实上,它立即就终止了,并打印出0。怎么回事呢?

问题在于终止条件测试失败了,其方式与增量操作失败的方式非常相似。这个循环只有在循环索引f比(float)(START + 50)小的情况下才运行。在将一个int与一个float进行比较时,会自动执行从int到float的提升[JLS 15.20.1]。遗憾的是,这种提升是会导致精度丢失的三种拓宽原始类型转换的一种[JLS 5.1.2]。(另外两个是从long到float和从long到double。)

f的初始值太大了,以至于在对其加上50,然后将结果转型为float时,所产生的数值等于直接将f转换成float的数值。换句话说,(float)2000000000 == 2000000050,因此表达式f < START + 50即使是在循环体第一次执行之前就是false,所以,循环体也就永远的不到机会去运行。

订正这个程序非常简单,只需将循环变量的类型从float修改为int即可。这样就避免了所有与浮点数计算有关的不精确性:

for (int f = START; f < START + 50; f++)

count++;

如果不使用计算机,你如何才能知道2,000,000,050与2,000,000,000有相同的float表示呢?关键是要观察到2,000,000,000有10个因子都是2:它是一个2乘以9个10,而每个10都是5×2。这意味着2,000,000,000的二进制表示是以10个0结尾的。50的二进制表示只需要6位,所以将50加到2,000,000,000上不会对右边6位之外的其他为产生影响。特别是,从右边数过来的第7位和第8位仍旧是0。提升这个31位的int到具有24位精度的float会在第7位和第8位之间四舍五入,从而直接丢弃最右边的7位。而最右边的6位是2,000,000,000与2,000,000,050位以不同之处,因此它们的float表示是相同的。

这个谜题寓意很简单:不要使用浮点数作为循环索引,因为它会导致无法预测的行为。如果你在循环体内需要一个浮点数,那么请使用int或long循环索引,并将其转换为float或double。在将一个int或long转换成一个float或double时,你可能会丢失精度,但是至少它不会影响到循环本身。当你使用浮点数时,要使用double而不是float,除非你肯定float提供了足够的精度,并且存在强制性的性能需求迫使你使用float。适合使用float而不是double的时刻是非常非常少的。

对语言设计者的教训,仍然是悄悄地丢失精度对程序员来说是非常令人迷惑的。请查看谜题31有关这一点的深入讨论。

谜题35:一分钟又一分钟

下面的程序在模仿一个简单的时钟。它的循环变量表示一个毫秒计数器,其计数值从0开始直至一小时中包含的毫秒数。循环体以定期的时间间隔对一个分钟计数器执行增量操作。最后,该程序将打印分钟计数器。那么它会打印出什么呢?

public class Clock {

public static void main(String[] args) {

int minutes = 0;

for (int ms = 0; ms < 60*60*1000; ms++)

if (ms % 60*1000 == 0)

minutes++;

System.out.println(minutes);

}

}

在这个程序中的循环是一个标准的惯用for循环。它步进毫秒计数器(ms),从0到一小时中的毫秒数,即3,600,000,包括前者但是不包括后者。循环体看起来是在每当毫秒计数器的计数值是60,000(一分钟内所包含毫秒数)的倍数时,对分钟计数器(minutes)执行增量操作。这在循环的生命周期内总共发生了3,600,000/60,000次,即60次,因此你可能期望程序打印出60,毕竟,这就是一小时所包含的分钟数。但是,该程序的运行却会告诉你另外一番景象:它打印的是60000。为什么它会如此频繁地对minutes执行了增量操作呢?

问题在于那个布尔表达式(ms % 60*1000 == 0)。你可能会认为这个表达式等价于(ms % 60000 == 0),但是它们并不等价。取余和乘法操作符具有相同的优先级[JLS 15.17],因此表达式ms % 60*1000 等价于(ms % 60)*1000。如果(ms % 60)等于0的话,这个表达式就等于0,因此循环每60次迭代就对minutes执行增量操作。这使得最终的结果相差1000倍。

订正该程序的最简单的方式就是在布尔表达式中插入一对括号,以强制规定计算的正确顺序:

if (ms % (60 * 1000) == 0)

minutes++;

然而,有一个更好的方法可以订正该程序。用被恰当命名的常量来替代所有的魔幻数字:

public class Clock {

private static final int MS_PER_HOUR = 60 * 60 * 1000;

private static final int MS_PER_MINUTE = 60 * 1000;

public static void main(String[] args) {

int minutes = 0;

for (int ms = 0; ms < MS_PER_HOUR; ms++)

if (ms % MS_PER_MINUTE == 0)

minutes++;

System.out.println(minutes);

}

}

之所以要在最初的程序中展现表达式 ms % 60*1000,是为了诱使你去认为乘法比取余有更高的优先级。然而,编译器是忽略空格的,所以千万不要使用空格来表示分组,要使用括号。空格是靠不住的,而括号是从来不说谎的。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值