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