java解惑.docx
《java解惑.docx》由会员分享,可在线阅读,更多相关《java解惑.docx(168页珍藏版)》请在冰豆网上搜索。
![java解惑.docx](https://file1.bdocx.com/fileroot1/2023-1/31/a6e9fbf4-7812-4761-adf5-fc77bb206e52/a6e9fbf4-7812-4761-adf5-fc77bb206e521.gif)
java解惑
Java谜题1——表达式谜题
谜题1:
奇数性
下面的方法意图确定它那唯一的参数是否是一个奇数。
这个方法能够正确运转吗?
publicstaticbooleanisOdd(inti){
returni%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比较,并且反转比较的含义即可:
publicstaticbooleanisOdd(inti){
returni%2!
=0;
}
如果你正在在一个性能临界(performance-critical)环境中使用isOdd方法,那么用位操作符AND(&)来替代取余操作符会显得更好:
publicstaticbooleanisOdd(inti){
return(i&1)!
=0;
}
总之,无论你何时使用到了取余操作符,都要考虑到操作数和结果的符号。
该操作符的行为在其操作数非负时是一目了然的,但是当一个或两个操作数都是负数时,它的行为就不那么显而易见了。
谜题2:
找零时刻
请考虑下面这段话所描述的问题:
Tom在一家汽车配件商店购买了一个价值$1.10的火花塞,但是他钱包中都是两美元一张的钞票。
如果他用一张两美元的钞票支付这个火花塞,那么应该找给他多少零钱呢?
下面是一个试图解决上述问题的程序,它会打印出什么呢?
publicclassChange{
publicstaticvoidmain(Stringargs[]){
System.out.println(2.00-1.10);
}
}
你可能会很天真地期望该程序能够打印出0.90,但是它如何才能知道你想要打印小数点后两位小数呢?
如果你对在Double.toString文档中所设定的将double类型的值转换为字符串的规则有所了解,你就会知道该程序打印出来的小数,是足以将double类型的值与最靠近它的临近值区分出来的最短的小数,它在小数点之前和之后都至少有一位。
因此,看起来,该程序应该打印0.9是合理的。
这么分析可能显得很合理,但是并不正确。
如果你运行该程序,你就会发现它打印的是0.8999999999999999。
问题在于1.1这个数字不能被精确表示成为一个double,因此它被表示成为最接近它的double值。
该程序从2中减去的就是这个值。
遗憾的是,这个计算的结果并不是最接近0.9的double值。
表示结果的double值的最短表示就是你所看到的打印出来的那个可恶的数字。
更一般地说,问题在于并不是所有的小数都可以用二进制浮点数来精确表示的。
如果你正在用的是JDK5.0或更新的版本,那么你可能会受其诱惑,通过使用printf工具来设置输出精度的方订正该程序:
//拙劣的解决方案——仍旧是使用二进制浮点数
System.out.printf("%.2f%n",2.00-1.10);
这条语句打印的是正确的结果,但是这并不表示它就是对底层问题的通用解决方案:
它使用的仍旧是二进制浮点数的double运算。
浮点运算在一个范围很广的值域上提供了很好的近似,但是它通常不能产生精确的结果。
二进制浮点对于货币计算是非常不适合的,因为它不可能将0.1——或者10的其它任何次负幂——精确表示为一个长度有限的二进制小数
解决该问题的一种方式是使用某种整数类型,例如int或long,并且以分为单位来执行计算。
如果你采纳了此路线,请确保该整数类型大到足够表示在程序中你将要用到的所有值。
对这里举例的谜题来说,int就足够了。
下面是我们用int类型来以分为单位表示货币值后重写的println语句。
这个版本将打印出正确答案90分:
System.out.println((200-110)+"cents");
解决该问题的另一种方式是使用执行精确小数运算的BigDecimal。
它还可以通过JDBC与SQLDECIMAL类型进行互操作。
这里要告诫你一点:
一定要用BigDecimal(String)构造器,而千万不要用BigDecimal(double)。
后一个构造器将用它的参数的“精确”值来创建一个实例:
newBigDecimal(.1)将返回一个表示0.100000000000000055511151231257827021181583404541015625的BigDecimal。
通过正确使用BigDecimal,程序就可以打印出我们所期望的结果0.90:
importjava.math.BigDecimal;
publicclassChange1{
publicstaticvoidmain(Stringargs[]){
System.out.println(newBigDecimal("2.00").
subtract(newBigDecimal("1.10")));
}
}
这个版本并不是十分地完美,因为Java并没有为BigDecimal提供任何语言上的支持。
使用BigDecimal的计算很有可能比那些使用原始类型的计算要慢一些,对某些大量使用小数计算的程序来说,这可能会成为问题,而对大多数程序来说,这显得一点也不重要。
总之,在需要精确答案的地方,要避免使用float和double;对于货币计算,要使用int、long或BigDecimal。
对于语言设计者来说,应该考虑对小数运算提供语言支持。
一种方式是提供对操作符重载的有限支持,以使得运算符可以被塑造为能够对数值引用类型起作用,例如BigDecimal。
另一种方式是提供原始的小数类型,就像COBOL与PL/I所作的一样。
谜题3:
长整除
这个谜题之所以被称为长整除是因为它所涉及的程序是有关两个long型数值整除的。
被除数表示的是一天里的微秒数;而除数表示的是一天里的毫秒数。
这个程序会打印出什么呢?
publicclassLongDivision{
publicstaticvoidmain(Stringargs[]){
finallongMICROS_PER_DAY=24*60*60*1000*1000;
finallongMILLIS_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是一种拓宽原始类型转换(wideningprimitiveconversion),它保留了(不正确的)数值。
这个值之后被MILLIS_PER_DAY整除,而MILLIS_PER_DAY的计算是正确的,因为它适合int运算。
这样整除的结果就得到了5。
那么为什么计算会是以int运算来执行的呢?
因为所有乘在一起的因子都是int数值。
当你将两个int数值相乘时,你将得到另一个int数值。
Java不具有目标确定类型的特性,这是一种语言特性,其含义是指存储结果的变量的类型会影响到计算所使用的类型。
通过使用long常量来替代int常量作为每一个乘积的第一个因子,我们就可以很容易地订正这个程序。
这样做可以强制表达式中所有的后续计算都用long运作来完成。
尽管这么做只在MICROS_PER_DAY表达式中是必需的,但是在两个乘积中都这么做是一种很好的方式。
相似地,使用long作为乘积的“第一个”数值也并不总是必需的,但是这么做也是一种很好的形式。
在两个计算中都以long数值开始可以很清楚地表明它们都不会溢出。
下面的程序将打印出我们所期望的1000:
publicclassLongDivision{
publicstaticvoidmain(Stringargs[]){
finallongMICROS_PER_DAY=24L*60*60*1000*1000;
finallongMILLIS_PER_DAY=24L*60*60*1000;
System.out.println(MICROS_PER_DAY/MILLIS_PER_DAY);
}
}
这个教训很简单:
当你在操作很大的数字时,千万要提防溢出——它可是一个缄默杀手。
即使用来保存结果的变量已显得足够大,也并不意味着要产生结果的计算具有正确的类型。
当你拿不准时,就使用long运算来执行整个计算。
语言设计者从中可以吸取的教训是:
也许降低缄默溢出产生的可能性确实是值得做的一件事。
这可以通过对不会产生缄默溢出的运算提供支持来实现。
程序可以抛出一个异常而不是直接溢出,就像Ada所作的那样,或者它们可以在需要的时候自动地切换到一个更大的内部表示上以防止溢出,就像Lisp所作的那样。
这两种方式都可能会遭受与其相关的性能方面的损失。
降低缄默溢出的另一种方式是支持目标确定类型,但是这么做会显著地增加类型系统的复杂度
谜题4:
初级问题
得啦,前面那个谜题是有点棘手,但它是有关整除的,每个人都知道整除是很麻烦的。
那么下面的程序只涉及加法,它又会打印出什么呢?
publicclassElementary{
publicstaticvoidmain(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作为变量名
Listl=newArrayList();
l.add("Foo");
System.out.println
(1);
总之,小写字母l和数字1在大多数打字机字体中都是几乎一样的。
为避免你的程序的读者对二者产生混淆,千万不要使用小写的l来作为long型字面常量的结尾或是作为变量名。
Java从C编程语言中继承良多,包括long型字面常量的语法。
也许当初允许用小写的l来编写long型字面常量本身就是一个错误。
谜题5:
十六进制的趣事
下面的程序是对两个十六进制(hex)字面常量进行相加,然后打印出十六进制的结果。
这个程序会打印出什么呢?
publicclassJoyOfHex{
publicstaticvoidmain(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-typecomputation):
左操作数是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:
publicclassJoyOfHex{
publicstaticvoidmain(String[]args){
System.out.println(
Long.toHexString(0x100000000L+0xcafebabeL));
}
}
这个谜题给我们的教训是:
混合类型的计算可能会产生混淆,尤其是十六进制和八进制字面常量无需显式的减号符号就可以表示负的数值。
为了避免这种窘境,通常最好是避免混合类型的计算。
对于语言的设计者们来说,应该考虑支持无符号的整数类型,从而根除符号扩展的可能性。
可能会有这样的争辩:
负的十六进制和八进制字面常量应该被禁用,但是这可能会挫伤程序员,他们经常使用十六进制字面常量来表示那些符号没有任何重要含义的数值。
谜题6:
多重转型
转型被用来将一个数值从一种类型转换到另一种类型。
下面的程序连续使用了三个转型。
那么它到底会打印出什么呢?
publicclassMulticast{
publicstaticvoidmain(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的转型是很简单的,它执行了一个窄化原始类型转化(narrowingprimitiveconversion),直接将除低8位之外的所有位全部砍掉。
这样做留下的是一个8位都被置位了的byte,它仍旧表示-1。
从byte到char的转型稍微麻烦一点,因为byte是一个有符号类型,而char是一个无符号类型。
在将一个整数类型转换成另一个宽度更宽的整数类型时,通常是可以保持其数值的,但是却不可能将一个负的byte数值表示成一个char。
因此,从byte到char的转换被认为不是一个拓宽原始类型的转换,而是一个拓宽并窄化原始类型的转换(wideningandnarrowingprimitiveconversion):
byte被转换成了int,而这个int又被转换成了char。
所有这些听起来有点复杂,幸运的是,有一条很简单的规则能够描述从较窄的整型转换成较宽的整型时的符号扩展行为:
如果最初的数值类型是有符号的,那么就执行符号扩展;如果它是char,那么不管它将要被转换成什么类型,都执行零扩展。
了解这条规则可以使我们很容易地解决这个谜题。
因为byte是一个有符号的类型,所以在将byte数值-1转换成char时,会发生符号扩展。
作为结果的char数值的16个位就都被置位了,因此它等于216-1,即65535。
从char到int的转型也是一个拓宽原始类型转换,所以这条规则告诉我们,它将执行零扩展而不是符号扩展。
作为结果的int数值也就成了65535,这正是程序打印出的结果。
尽管这条简单的规则描述了在有符号和无符号整型之间进行拓宽原始类型时的符号扩展行为,你最好还是不要编写出依赖于它的程序。
如果你正在执行一个转型到char或从char转型的拓宽原始类型转换,并且这个char是仅有的无符号整型,那么你最好将你的意图明确地表达出来。
如果你在将一个char数值c转型为一个宽度更宽的类型,并且你不希望有符号扩展,那么为清晰表达意图,可以考虑使用一个位掩码,即使它并不是必需的:
inti=c&0xffff;
或者,书写一句注释来描述转换的行为:
inti=c;//不会执行符号扩展
如果你在将一个char数值c转型为一个宽度更宽的整型,并且你希望有符号扩展,那么就先将char转型为一个short,它与char具有同样的宽度,但是它是有符号的。
在给出了这种细微的代码之后,你应该也为它书写一句注释:
inti=(short)c;//转型将引起符号扩展
如果你在将一个byte数值b转型为一个char,并且你不希望有符号扩展,那么你必须使用一个位掩码来限制它。
这是一种通用做法,所以不需要任何注释:
charc=(char)(b&0xff);
这个教训很简单:
如果你通过观察不能确定程序将要做什么,那么它做的就很有可能不是你想要的。
要为明白清晰地表达你的意图而努力。
尽管有这么一条简单的规则,描述了涉及有符号和无符号整型拓宽转换的符号扩展行为,但是大多数程序员都不知道它。
如果你的程序依赖于它,那么你就应该把你的意图表达清楚。
谜题7:
互换内容
下面的程序使用了复合的异或赋值操作符,它所展示的技术是一种编程习俗。
那么它会打印出什么呢?
publicclassCleverSwap{
publicstaticvoidmain(String[]args){
intx=1984;//(0x7c0)
inty=2001;//(0x7d1)
x^=y^=x^=y;
System.out.println("x="+x+";y="+y);
}
}
就像其名称所暗示的,这个程序应该交换变量x和y的值。
如果你运行它,就会发现很悲惨,它失败了,打印的是
x=0;y=1984。
交换两个变量的最显而易见的方式是使用一个临时变量:
inttmp=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^