Java 谜题 Java 谜题 1——表达式谜题
谜题 1:奇数性
下面的方法意图确定它那唯一的参数是否是一个奇数。这个方法能够正确运转
吗?
1 | public static boolean isOdd(int i){ |
奇数可以被定义为被 2 整除余数为 1 的整数。表达式 i % 2 计算的是 i 整除 2
时所产生的余数,因此看起来这个程序应该能够正确运转。遗憾的是,它不能;
它在四分之一的时间里返回的都是错误的答案。
为什么是四分之一?因为在所有的 int 数值中,有一半都是负数,而 isOdd 方
法对于对所有负奇数的判断都会失败。在任何负整数上调用该方法都回返回
false ,不管该整数是偶数还是奇数。
这是 Java 对取余操作符(%)的定义所产生的后果。该操作符被定义为对于所
有的 int 数值 a 和所有的非零 int 数值 b,都满足下面的恒等式:
1 | (a / b) * b + (a % b) == a |
换句话说,如果你用 b 整除 a,将商乘以 b,然后加上余数,那么你就得到了最
初的值 a 。该恒等式具有正确的含义,但是当与 Java 的截尾整数整除操作符
相结合时,它就意味着:当取余操作返回一个非零的结果时,它与左操作数具有
相同的正负符号。
当 i 是一个负奇数时,i % 2 等于-1 而不是 1, 因此 isOdd 方法将错误地返
回 false。为了防止这种意外,请测试你的方法在为每一个数值型参数传递负数、
零和正数数值时,其行为是否正确。
这个问题很容易订正。只需将 i % 2 与 0 而不是与 1 比较,并且反转比较的含
义即可:
1 | public static boolean isOdd(int i){ |
如果你正在在一个性能临k界(performance-critical)环境中使用 isOdd 方法,
那么用位操作符 AND(&)来替代取余操作符会显得更好:
1 | public static boolean isOdd(int i){ |
总之,无论你何时使用到了取余操作符,都要考虑到操作数和结果的符号。该操
作符的行为在其操作数非负时是一目了然的,但是当一个或两个操作数都是负数
谜题 2:找零时刻
请考虑下面这段话所描述的问题:
Tom 在一家汽车配件商店购买了一个价值$1.10 的火花塞,但是他钱包中都是两
美元一张的钞票。如果他用一张两美元的钞票支付这个火花塞,那么应该找给他
多少零钱呢?
下面是一个试图解决上述问题的程序,它会打印出什么呢?
1 | public class Change{ |
你可能会很天真地期望该程序能够打印出 0.90,但是它如何才能知道你想要打
印小数点后两位小数呢?
如果你对在 Double.toString 文档中所设定的将 double 类型的值转换为字符串
的规则有所了解,你就会知道该程序打印出来的小数,是足以将 double 类型的
值与最靠近它的临近值区分出来的最短的小数,它在小数点之前和之后都至少有
一位。因此,看起来,该程序应该打印 0.9 是合理的。
这么分析可能显得很合理,但是并不正确。如果你运行该程序,你就会发现它打
印的是 0.8999999999999999。
问题在于 1.1 这个数字不能被精确表示成为一个 double,因此它被表示成为最
接近它的 double 值。该程序从 2 中减去的就是这个值。遗憾的是,这个计算的
结果并不是最接近 0.9 的 double 值。表示结果的 double 值的最短表示就是你所
看到的打印出来的那个可恶的数字。
更一般地说,问题在于并不是所有的小数都可以用二进制浮点数来精确表示的。
如果你正在用的是 JDK 5.0 或更新的版本,那么你可能会受其诱惑,通过使用
printf 工具来设置输出精度的方订正该程序:
//拙劣的解决方案——仍旧是使用二进制浮点数
1 | System.out.printf("%.2f%n",2.00 - 1.10); |
这条语句打印的是正确的结果,但是这并不表示它就是对底层问题的通用解决方
案:它使用的仍旧是二进制浮点数的 double 运算。浮点运算在一个范围很广的
值域上提供了很好的近似,但是它通常不能产生精确的结果。二进制浮点对于货
币计算是非常不适合的,因为它不可能将 0.1——或者 10 的其它任何次负幂——
精确表示为一个长度有限的二进制小数
解决该问题的一种方式是使用某种整数类型,例如 int 或 long,并且以分为单
位来执行计算。如果你采纳了此路线,请确保该整数类型大到足够表示在程序中
你将要用到的所有值。对这里举例的谜题来说,int 就足够了。下面是我们用 int
类型来以分为单位表示货币值后重写的 println 语句。这个版本将打印出正确答
案 90 分:
1 | System.out.println((200 - 110) + "cents"); |
解决该问题的另一种方式是使用执行精确小数运算的 BigDecimal。它还可以通
过 JDBC 与 SQL DECIMAL 类型进行互操作。这里要告诫你一点: 一定要用
BigDecimal(String)构造器,而千万不要用 BigDecimal(double)。后一个构造
器将用它的参数的“精确”值来创建一个实例:new BigDecimal(.1)将返回一个
表示 0.100000000000000055511151231257827021181583404541015625 的
BigDecimal。通过正确使用 BigDecimal,程序就可以打印出我们所期望的结果
0.90:
1 | import java.math.BigDecimal; |
这个版本并不是十分地完美,因为 Java 并没有为 BigDecimal 提供任何语言上的
支持。使用 BigDecimal 的计算很有可能比那些使用原始类型的计算要慢一些,
对某些大量使用小数计算的程序来说,这可能会成为问题,而对大多数程序来说,
这显得一点也不重要。
总之, 在需要精确答案的地方,要避免使用 float 和 double;对于货币计算,
要使用 int、long 或 BigDecimal。对于语言设计者来说,应该考虑对小数运算
提供语言支持。一种方式是提供对操作符重载的有限支持,以使得运算符可以被
塑造为能够对数值引用类型起作用,例如 BigDecimal。另一种方式是提供原始
的小数类型,就像 COBOL 与 PL/I 所作的一样。
谜题 3:长整除
这个谜题之所以被称为长整除是因为它所涉及的程序是有关两个 long 型数值整
除的。被除数表示的是一天里的微秒数;而除数表示的是一天里的毫秒数。这个
程序会打印出什么呢?
1 | public class LongDivision{ |
这个谜题看起来相当直观。每天的毫秒数和每天的微秒数都是常量。为清楚起见,
它们都被表示成积的形式。每天的微秒数是(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:
1 | public class LongDivision{ |
这个教训很简单:当你在操作很大的数字时,千万要提防溢出——它可是一个缄
默杀手。即使用来保存结果的变量已显得足够大,也并不意味着要产生结果的计
算具有正确的类型。当你拿不准时,就使用 long 运算来执行整个计算。
语言设计者从中可以吸取的教训是:也许降低缄默溢出产生的可能性确实是值得
做的一件事。这可以通过对不会产生缄默溢出的运算提供支持来实现。程序可以
抛出一个异常而不是直接溢出,就像 Ada 所作的那样,或者它们可以在需要的时
候自动地切换到一个更大的内部表示上以防止溢出,就像 Lisp 所作的那样。这
两种方式都可能会遭受与其相关的性能方面的损失。降低缄默溢出的另一种方式
是支持目标确定类型,但是这么做会显著地增加类型系统的复杂度
谜题 4:初级问题
得啦,前面那个谜题是有点棘手,但它是有关整除的,每个人都知道整除是很麻
烦的。那么下面的程序只涉及加法,它又会打印出什么呢?
1 | public class Elementary{ |
从表面上看,这像是一个很简单的谜题——简单到不需要纸和笔你就可以解决
它。加号的左操作数的各个位是从 1 到 5 升序排列的,而右操作数是降序排列的。
因此,相应各位的和仍然是常数,程序必定打印 66666。对于这样的分析,只有
一个问题:当你运行该程序时,它打印出的是 17777。难道是 Java 对打印这样
的非常数字抱有偏见吗?不知怎么的,这看起来并不像是一个合理的解释。
事物往往有别于它的表象。就以这个问题为例,它并没有打印出我们想要的输出。
请仔细观察 + 操作符的两个操作数,我们是将一个 int 类型的 12345 加到了
long 类型的 5432l 上。请注意左操作数开头的数字 1 和右操作数结尾的小写字
母 l 之间的细微差异。数字 1 的水平笔划(称为“臂(arm)”)和垂直笔划(称
为“茎(stem)”)之间是一个锐角,而与此相对照的是,小写字母 l 的臂和茎
之间是一个直角。
在你大喊“恶心!”之前,你应该注意到这个问题确实已经引起了混乱,这里确
实有一个教训:在 long 型字面常量中,一定要用大写的 L,千万不要用小写的 l。
这样就可以完全掐断这个谜题所产生的混乱的源头。
1 | System.out.println(12345+5432L); |
相类似的,要避免使用单独的一个 l 字母作为变量名。例如,我们很难通过观察
下面的代码段来判断它到底是打印出列表 l 还是数字 1。
1 | //不良代码-使用了 l 作为变量名 |
总之,小写字母 l 和数字 1 在大多数打字机字体中都是几乎一样的。为避免你的
程序的读者对二者产生混淆,千万不要使用小写的 l 来作为 long 型字面常量的
结尾或是作为变量名。Java 从 C 编程语言中继承良多,包括 long 型字面常量的
语法。也许当初允许用小写的 l 来编写 long 型字面常量本身就是一个错误。
谜题 5:十六进制的趣事 十六进制的趣事
下面的程序是对两个十六进制(hex)字面常量进行相加,然后打印出十六进制
的结果。这个程序会打印出什么呢?
1 | public class JoyOfHex{ |
看起来很明显,该程序应该打印出 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 丢失了。下面所示是用手写的加法实现。(在加法上面的数字是进
位。)
1 | 1111111 |
订正该程序非常简单,只需用一个 long 十六进制字面常量来表示右操作数即可。
这就可以避免了具有破坏力的符号扩展,并且程序也就可以打印出我们所期望的
结果 1cafebabe:
1 | public class JoyOfHex{ |
这个谜题给我们的教训是:混合类型的计算可能会产生混淆,尤其是十六进制和
八进制字面常量无需显式的减号符号就可以表示负的数值。为了避免这种窘境,
通常最好是避免混合类型的计算。对于语言的设计者们来说,应该考虑支持无符
号的整数类型,从而根除符号扩展的可能性。可能会有这样的争辩:负的十六进
制和八进制字面常量应该被禁用,但是这可能会挫伤程序员,他们经常使用十六
进制字面常量来表示那些符号没有任何重要含义的数值。
谜题 6:多重转型
转型被用来将一个数值从一种类型转换到另一种类型。下面的程序连续使用了三
个转型。那么它到底会打印出什么呢?
1 | public class Multicast{ |
无论你怎样分析这个程序,都会感到很迷惑。它以 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 转型为一个宽度更宽的类型,并且你不希望有符号
扩展,那么为清晰表达意图,可以考虑使用一个位掩码,即使它并不是必需的:
1 | int i = c & 0xffff; |
或者,书写一句注释来描述转换的行为:
int i = c; //不会执行符号扩展
如果你在将一个 char 数值 c 转型为一个宽度更宽的整型,并且你希望有符号扩
展,那么就先将 char 转型为一个 short,它与 char 具有同样的宽度,但是它是
有符号的。在给出了这种细微的代码之后,你应该也为它书写一句注释:
1 | int i = (short) c; //转型将引起符号扩展 |
如果你在将一个 byte 数值 b 转型为一个 char,并且你不希望有符号扩展,那么
你必须使用一个位掩码来限制它。这是一种通用做法,所以不需要任何注释:
1 | char c = (char) (b & 0xff); |
这个教训很简单:如果你通过观察不能确定程序将要做什么,那么它做的就很有
可能不是你想要的。要为明白清晰地表达你的意图而努力。尽管有这么一条简单
的规则,描述了涉及有符号和无符号整型拓宽转换的符号扩展行为,但是大多数
程序员都不知道它。如果你的程序依赖于它,那么你就应该把你的意图表达清楚。
谜题 7:互换内容
下面的程序使用了复合的异或赋值操作符,它所展示的技术是一种编程习俗。那
么它会打印出什么呢?
1 | public class CleverSwap{ |
就像其名称所暗示的,这个程序应该交换变量 x 和 y 的值。如果你运行它,就会
发现很悲惨,它失败了,打印的是
1 | x = 0; y = 1984。 |
交换两个变量的最显而易见的方式是使用一个临时变量:
1 | int tmp = x; |
很久以前,当中央处理器只有少数寄存器时,人们发现可以通过利用异或操作符
(^)的属性(x ^ y ^ x) == y 来避免使用临时变量:
1 | x = x ^ y; |
这个惯用法曾经在 C 编程语言中被使用过,并进一步被构建到了 C++中,但是它
并不保证在二者中都可以正确运行。但是有一点是肯定的,那就是它在 Java 中
肯定是不能正确运行的。
Java 语言规范描述到:操作符的操作数是从左向右求值的。为了求表达式 x ^=
expr 的值,x 的值是在计算 expr 之前被提取的,并且这两个值的异或结果被赋
给变量 x。在 CleverSwap 程序中,变量 x 的值被提取了两次——每次在表达式
中出现时都提取一次——但是两次提取都发生在所有的赋值操作之前。
下面的代码段详细地描述了将互换惯用法分解开之后的行为,并且解释了为什么
产生的是我们所看到的输出:
1 | // Java 中 x^= y^= x^= y 的实际行为 |
在 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 Dos Equis Dos Equis
这个谜题将测试你对条件操作符的掌握程度,这个操作符有一个更广为人知的名
字:问号冒号操作符。下面的程序将会打印出什么呢?
1 | public class DosEquis{ |
这个程序由两个变量声明和两个 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 的声
明即可,它肯定是一个合法的语句:
1 | x += i; |
但是,它并不是:
1 | x = x + i; |
许多程序员都会认为该迷题中的第一个表达式(x += i)只是第二个表达式(x =
x + i)的简写方式。但是这并不十分准确。这两个表达式都被称为赋值表达式。
第二条语句使用的是简单赋值操作符(=),而第一条语句使用的是复合赋值操
作符。(复合赋值操作符包括 +=、-=、*=、/=、%=、<<=、>>=、>>>=、&=、^=
和|=)Java 语言规范中讲到,复合赋值 E1 op= E2 等价于简单赋值 E1 =
(T)((E1)op(E2)),其中 T 是 E1 的类型,除非 E1 只被计算一次。
换句话说,复合赋值表达式自动地将它们所执行的计算的结果转型为其左侧变量
的类型。如果结果的类型与该变量的类型相同,那么这个转型不会造成任何影响。
然而,如果结果的类型比该变量的类型要宽,那么复合赋值操作符将悄悄地执行
一个窄化原始类型转换。因此,我们有很好的理由去解释为什么在尝试着执行等
价的简单赋值可能会产生一个编译错误。
为了说得具体一些,并提供一个解决方案给这个谜题,假设我们在该谜题的两个
赋值表达式之前有下面这些声明:
1 | short x = 0; |
复合赋值编译将不会产生任何错误:
1 | x += i; // 包含了一个隐藏的转型! |
你可能期望 x 的值在这条语句执行之后是 123,456,但是并非如此 l,它的值是
-7,616。int 类型的数值 123456 对于 short 来说太大了。自动产生的转型悄悄
地把 int 数值的高两位给截掉了。这也许就不是你想要的了。
相对应的简单赋值是非法的,因为它试图将 int 数值赋值给 short 变量,它需要
一个显式的转型:
1 | x = x + i; // 不要编译——“可能会丢掉精度” |
这应该是明显的,复合赋值表达式可能是很危险的。为了避免这种令人不快的突
袭,请不要将复合赋值操作符作用于 byte、short 或 char 类型的变量上。在将
复合赋值操作符作用于 int 类型的变量上时,要确保表达式右侧不是 long、float
或 double 类型。在将复合赋值操作符作用于 float 类型的变量上时,要确保表
达式右侧不是 double 类型。这些规则足以防止编译器产生危险的窄化转型。
总之,复合赋值操作符会悄悄地产生一个转型。如果计算结果的类型宽于变量的
类型,那么所产生的转型就是一个危险的窄化转型。这样的转型可能会悄悄地丢
弃掉精度或数量值。对语言设计者来说,也许让复合赋值操作符产生一个不可见
的转型本身就是一个错误;对于在复合赋值中的变量类型比计算结果窄的情况,
也许应该让其非法才对。
谜题 10:八两
与上面的例子相反,如果我们给出的关于变量 x 和 i 的声明是如下的合法语句:
1 | x = x + i; |
但是,它并不是:
1 | x += i; |
乍一看,这个谜题可能看起来与前面一个谜题相同。但是请放心,它们并不一样。
这两个谜题在哪一条语句必是合法的,以及哪一条语句必是不合法的方面,正好
相反。
就像前面的谜题一样,这个谜题也依赖于有关复合赋值操作符的规范中的细节。
二者的相似之处就此打住。基于前面的谜题,你可能会想:符合赋值操作符比简
单赋值操作符的限制要少一些。在一般情况下,这是对的,但是有这么一个领域,
在其中简单赋值操作符会显得更宽松一些。
复合赋值操作符要求两个操作数都是原始类型的,例如 int,或包装了的原始类
型,例如 Integer,但是有一个例外:如果在+=操作符左侧的操作数是 String
类型的,那么它允许右侧的操作数是任意类型,在这种情况下,该操作符执行的
是字符串连接操作。简单赋值操作符(=)允许其左侧的是对象引用类型,这就
显得要宽松许多了:你可以使用它们来表示任何你想要表示的内容,只要表达式
的右侧与左侧的变量是赋值兼容的即可。
你可以利用这一差异来解决该谜题。要想用 += 操作符来执行字符串连接操作,
你就必须将左侧的变量声明为 String 类型。通过使用直接赋值操作符,字符串
连接的结果可以存放到一个 Object 类型的变量中。
为了说得具体一些,并提供一个解决方案给这个谜题,假设我们在该谜题的两个
赋值表达式之前有下面这些声明:
1 | Object x = "Buy "; |
简单赋值是合法的,因为 x + i 是 String 类型的,而 String 类型又是与 Object
赋值兼容的:
1 | x = x + i; |
复合赋值是非法的,因为左侧是一个 Object 引用类型,而右侧是一个 String
类型:
1 | x += i; |
这个谜题对程序员来说几乎算不上什么教训。对语言设计者来说,加法的复合赋
值操作符应该在右侧是 String 类型的情况下,允许左侧是 Object 类型。这项修
改将根除这个谜题所展示的违背直觉的行为。