> 文档中心 > JAVA语言进阶—《循环者六大谜题》带你深层次了解无限循环

JAVA语言进阶—《循环者六大谜题》带你深层次了解无限循环


🎉博客首页:痛而不言笑而不语的浅伤

📢欢迎关注🥳点赞 👍 收藏 ⭐留言 📝 欢迎讨论!

🔮本文由痛而不言笑而不语的浅伤原创,CSDN首发!

🌋系列专栏:《JAVA进阶谜题》

🧿首发时间:2022年5月16日

❤:热爱Java学习,期待一起交流!

🙏🏻作者水平有限,如果发现错误,求告知,多谢!

🥰有问题可以私信交流!!!

​​​​​​​

目录

​​​​​​​

循环者六大谜题

循环者

循环者的新娘

循环者的爱子

循环者的鬼魂

循环者的诅咒

循环者遇到了狼人

总结


循环者六大谜题

循环者

 下面的谜题以及随后的五个谜题对你来说是扭转了局面,它们不是向你展示某些代码,然后询问你这些代码将做些什么,它们要让你去写代码,但是数量会很少。这些谜题被称为“循环者(looper)”。你眼前会展示出一个循环,它看起来应该很快就终止的,而你的任务就是写一个变量声明,在将它作用于该循环之上时,使得该循环无限循环下去。例如,考虑下面的for循环:

for(int i=start;i<= start+1;i++) {}

看起来它好像应该只迭代两次,但是通过利用溢出行为,可以使它无限循环下去。下面的的声明就采用了这项技巧:

int start=IntegerMAX VALUE-1:

现在该轮到你了。什么样的声明能够让下面的循环变成一个无限循环?

while(i==i+1){}

仔细查看这个while循环,它真的好像应该立即终止。一个数字永远不会等于它自己加1,对吗?嗯,如果这个数字是无穷大的,又会怎样呢?Java强制要求使用IEEE754浮点数算术运算[IEEE754],它可以让你用一个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,即33554,432;而对于double类型,最小级数是254,大约是1.8x1016。

毗邻的浮点数值之间的距离被称为一个ulp,它是最小单位(unitinthelastplace)”的首字母缩写词。在5.0版中,引入了Mathulp方法来计算 float或double数值的ulp。

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

循环者的新娘

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

while(i!=i) {}

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

对,但是IEEE754浮点算术保留了一个特殊的值用来表示一个不是数字的数量[IEEE754]。这个值就是NaN(“不是一个数字(Not aNumber)"的缩写),对于所有没有良好的数字定义的浮点计算,例如0.0/0.0,其值都是它。规范中描述道,NaN不等于任何浮点数值,包括它自身在内 TJLS 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){doublei=0.0/0.0;System.out.println(i-i==0);}}

这条计算NaN的规则所基于的原理是:

一旦一个计算产生了NaN,它就被损坏了,没有任何更进一步的计算可以修复这样的损坏。NaN值意图使受损的计算继续执行下去,直到方便处理这种情况的地方为止。

总之,float和double类型都有一个特殊的NaN值,用来表示不是数字的数量。对于涉及NaN值的计算,其规则很简单也很明智,但是这些规则的结果可能是违背直觉的。

循环者的爱子

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

while(i!=i+0){}

与前一个谜题不同,你必须在你的答案中不使用浮点数。换句话说,你不能把i声明为double或 float类型的。

与前一个谜题一样,这个谜题初看起来是不可能实现的。毕竟,一个数字总是等于它自身加上0,你被禁止使用浮点数,因此不能使用NaN,而在整数类型中没有NaN的等价物。那么,你能给出什么呢?

我们必然可以得出这样的结论,即i的类型必须是非数值类型的,并且这其中存在着解谜方案。唯一的+操作符有定义的非数值类型就是String。+操作符被重载了:对于String类型,它执行的不是加法而是字符串连接。如果在连接中的某个操作数具有非String的类型,那么这个操作书就会在连接之前转换成字符串[JLS15.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通常被当作整型变量名而被保留的。对于程序的可读性来说,好的变量名、方法名和类名至少与好的注释同等重要。

对语言设计者的教训是操作符的重载。操作符重载是很容易引起混乱的,也许+操作符就不应该被重载用来进行字符串连接操作。有充分的理由证明提供一个字符串连接操作符是多么必要,但是它不应该是+。

循环者的鬼魂

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

while(i!=0) { i >>>= 1;}

回想一下,>>>=是对应于无符号右移操作符的赋值操作符。0被从左移入到由移位操作而空出来的位上,即使被移位的负数也是如此。

这个循环比前面三个循环要稍微复杂一点,因为其循环体非空。在其循环题中,i的值由它右移一位之后的值所替代。为了使移位合法,i必须是一个整数类型(byte、char、shortint或long)。无符号右移操作符把0从左边移入,因此看起来这个循环执行迭代的次数与最大的整数类型所占据的位数相同,即64次。如果你在循环的前面放置如下的声明,那么这确实就是将要发生的事情:

long i=-1; // -1L has all 64 bits set

你怎样才能将它转变为一个无限循环呢?解决本谜题的关键在于>>>=是一个复合赋值操作符。(复合赋值操作符包括*=、/=、%=、+=、-=、<>=、>>>=、&=、^=和=。)有关混合操作符的一个不幸的事实是,它们可能会自动地执行窄化原始类型转换[JLS15.26.2],这种转换把一种数字类型转换成了另一种更缺乏表示能力的类型。窄化原始类型转换可能会丢失级数的信息,或者是数值的精度[JLS 5.1.3]。

让我们更具体一些,假设你在循环的前面放置了下面的声明:

short i=-1;

因为i的初始值((short)0xffff)是非0的,所以循环体会被执行。在执行移位操作时,第一步是将 i提升为int类型。所有算数操作都会对short、byte和char类型的操作数执行这样的提升。这种提升是一个拓宽原始类型转换,因此没有任何信息会丢失。这种提升执行的是符号扩展,因此所产生的int数值是0xffffffff。然后,这个数值右移1位,但不使用符号扩展,因此产生了int数值0x7fcccff,最后,这个数值被存回到i中,为了将int数值存入short变量,Java执行的是可怕的窄化原始类型转换,它直接将高16位截掉。这样就只剩下(short)oxffff了,我们又回到了开始处。循环的第二次以及后续的迭代行为都是一样的,因此循环将永远不会终止。

如果你将声明为一个short或byte变量,并且初始化为任何负数,那么这种行为也会发生。如果你声明i为一个char,那么你将无法得到无限循环因为char是无符号的,所以发生在移位之前的拓宽原始类型转换不会执行符号扩展。

总之,不要在short、byte或char类型的变量之上使用复合赋值操作符。因为这样的表达式执行的是混合类型算术运算,它容易造成混乱。更糟的是,它们执行将隐式地执行会丢失信息的窄化转型,其结果是灾难性的。

对语言设计者的教训是语言不应该自动地执行窄化转换。还有一点值得好好争论的是,Java是否应该禁止在short、byte和char变量上使用复合赋值操作符。

循环者的诅咒

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

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

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

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

在5.0版中,自动包装(autoboxing)和自动反包装(auto-unboxing)被添加到了Java语言中。如果你对它们并不了解,请查看:http://javasuncom/j2se/5.0/docs/guide/languageautoboxing.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和i上执行解包转换[JLS5.1.8],并且在数字上比较所产生的int数值。i和i都表示0,所以这两个子表达式都被计算为true。第三个子表达式(i!=i)在对象引用i和i上执行标识比较,因为它们都初始化为一个新的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.outprintln(new Integer(0)==0);}}

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

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

循环者遇到了狼人

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

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

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

嗯,没有任何实数具有这种属性,但是没有任何一种Java数值类型能够对实数进行完美建模。浮点数值是用一个符号位、一个被通俗地称为尾数(mantissa)的有效数字以及一个指数来表示的。除了0之外,没有任何浮点数等于其符号位反转之后的值,因此i的类型必然是整数型的。有符号的整数类型使用的是2的补码算术运算:为了对一个数值取其负值,你要反转其每一位。然后加1,从而得到结果[JLS1515.4]。2的补码算术运算的一个很大的优势是,0具有唯一的表示形式。如果你要对int数值0取负值,你将得到0xffffffff+1,它仍然是0。

但是,这也有一个相应的不利之处,总共存在偶数个int数值--准确地说有232个--其中一个用来表示0,这样就剩些奇数个int数值来表示正整数和负整数,这意味着正的和负的int数值的数量必然不相等。这暗示着至少有一个int数值,其负值不能正确地表示成为一个int数值。事实上,恰恰就有一个这样的int数值,它就是 IntegerMIN VALUE,即-231。他的十六进制表示是0x80000000。其符号位为1,其余所有的位都是0。如果我们对这个值取负值,那么我们将得到0x7fffffff+1,也就是0x80000000,即 IntegerMIN VALUE!换句话说,IntegerMIN VALUE是它自己的负值,Long.MINVALUE也是一样。对这两个值取负值将会产生溢出,但是Java在整数计算中忽略了溢出。其结果已经阐述清楚了,即使它们并不总是你所期望的。

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

int i=IntegerMIN VALUE;

下面这个也可以:

long i = Long.MIN_VALUE;

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

i≡-i(mod 232)

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

2i=0(mod 32)

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

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

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

总结

好啦,今天的分享就到这里了,各位小伙伴你们学会了吗?对JAVA语言中的循环是否有了更深层次的理解呢?知道如何才能把不同形式的等式变成无限循环吗?我想应该不用多说了吧。那就再见啦!

【完】