每一门程序语言都会提供各种运算符(Operator),每个运算符都会有特定的含义和规则,去完成指定操作最后给出相应的结果。有些运算符会普遍出现在各种语言中,比如加减乘除这些算数运算符;有些运算符则为某些语言特有,比如取地址运算符和反引用运算符只在C/C++中用到而Java,C#这类不能直接访问地址的程序语言就不具有;有些运算符因为特定的需要,还会改变其一般的含义,比如加法运算符在很多语言都被作为字符串拼接的操作符。学习一门程序语句,除了学习其语法以外还要了解其所拥有的运算符,但是因为各个语言运算符的相似性,常常让人简单略过对它们的了解。
运算符最主要有两个特性:优先级(Precedence)和结合律(Associativity)。在很早以前我的《两年半大学总结》讲到过自己对运算符, 特别是优先级的问题上的一些理解。在之后的学习和编写程序的过程中,我常常发现原来运算符的这两个特性会给代码带来隐形的意想不到的陷阱。所以我将实际中碰到的一些关于运算符的问题罗列于此,希望能对这些问题提高警惕以免跌入陷阱。
自增运算符和反引用运算符
对于下边的代码,会是什么样的输出结果呢?
[codesyntax lang=”cpp” lines=”normal”]
int a = 1; int *p = &a; cout << *p++ << endl; cout << a << endl; cout << *p << endl;
[/codesyntax]
其实主要是想问*p++
这个表达式是怎么进行运算操作的呢?如果注意到反引用(*)和后缀自增运算符的优先级关系,就会发现后者比前者的优先级要高,所以++
先操作,然后再计算反引用,也就相当于*(p++)
。而后缀自增是先返回自身的指(这里也就是a
的地址)然后自增(这自增其实是地址增加而非a
增加),所以第一和第二个cout
都会输出a
的原始值1
,最后一个cout
会输出随机数,因为p
指向a
的下一个地址(根据a
和p
的定义位置,可以知道其实p是指向了自身所在的地址)。
虽然看起来*p++
在上边的代码中的使用没有什么意义甚至不合理,实际上这样的操作一般用在在C/C++的字符串遍历上,比如下边的输出字符串:
[codesyntax lang=”cpp” lines=”normal”]
char a[] = "hello world"; char *p = a; while (*p != '\0') { cout << *p++; }
[/codesyntax]
好吧其实这个也没有什么意义,因为C可以直接用printf("%s",a);
而C++也可以直接cout << a;
。
说了后缀,也要看看其“弟弟”前缀自增运算符。为什么是“弟弟”不是“哥哥”呢?因为从优先级上说前缀要比后缀优先级低,与反引用的优先级相同。当优先级相同的前缀自增和反引用同时出现的时候,我们就要看运算符的结合律了。因为他们两个的结合律都是自右向左的,所以:
*++p
相当于*(++p)
,先增加p
所指地址位置,再取出内容;
++*p
相当于++(*p)
,先取出p
所指地址的内容,再增加内容的值。
位运算符和关系运算符
位运算可谓是随着二进制程序的出现而存在,但是它却给编程带来很多奇妙的特性,在《寻找比特位》中也提到有时比其它运算符都要高效。再比如判断一个数是否是偶数,一般我们会比较直接的使用模运算来判定该数是否能被2整数:
[codesyntax lang=”cpp” lines=”normal”]
if (num%2 == 0) { cout << num << " is even number"; }
[/codesyntax]
其实也可以直接用位运算操作来判定最后一位是否是0
就可以了:
[codesyntax lang=”cpp” lines=”normal”]
if (num&1 == 0) { cout << num << " is even number"; } else { cout << num << " is odd number"; }
[/codesyntax]
但是如果使用上述代码,会发现无论num是什么值,都会被判定位奇数。并不是因为这个逻辑思想错了,而是因为位运算的优先级要低于关系运算符,所以num&1 == 0
其实被计算机解读成num&(1==0)
,使得整个表达式恒为0
,也就是假值。
所以应该改成(num&1) == 0
,或者 !(num&1)
。不过这种问题也只会出现在C/C++和脚本语言这类对if
条件不局限在布尔值的程序语言上。而像Java这些语法更严格的语言,或者一些更严格编译器,对于这些语句都会提出警告甚至错误。
逻辑运算符
之前介绍的几个运算符,主要是针对优先级和结合律来说明一些注意点,而逻辑运算符,主要是要注意优先级上:“与或非”并不在同一个优先级,而是“非”(远)高于“与”和“或”,“与”(略)高于“或”。所以(a || !b && c)
等价于(a || ((!b) && c))
。
除了上述两个特性外,逻辑运算符还有几个比较特殊的性质,首先要提的就是“短路性(Short Circuit)”。简单说来就是当逻辑运算符前一项表达式的值可以决定整个逻辑运算的结果时,后一项表达式将不会被计算。因为这个特点,就可以在对空指针检查时可以把多个检查放在一起:
[codesyntax lang=”java” lines=”normal”]
if (staff != null && staff.titles != null && staff.title.contains("Manager")) { System.out.println(staff.name + " is a Manager"); }
[/codesyntax]
因为当staff
是null
,之后的就不会被执行,也就不用担心staff.titles
会引发空指针异常。这样子我们也就不需要拆分成多个if
语言徒添缩进:
[codesyntax lang=”java” lines=”normal”]
if (staff != null) { if (staff.titles != null) { if (staff.title.contains("Manager")) { System.out.println(staff.name + " is a Manager"); } } }
[/codesyntax]
这是其它表达式所不具有的特性,比如算数表达式:0*func(something)
,虽然通过0
就可知整个表达式结果一定是0
,但是func
函数还是会被调用计算并返回结果。
逻辑表达式另一个独特的性质则与 各个编程语言相关,姑且可称之为“结果类型模糊性”。对于其它表达式,我们都可以明确知道其结果是什么类型,例如算数运算符结果一定是数字类型(当然有些也可能是字符串拼接得到字符串类型),而对于逻辑运算符,其结果却不全是布尔型。这主要也是强类型语言和弱类型语言之间的区别所产生的不同。
比如在JavaScript中,对于逻辑运算符的描述是这样的:
Operator | Usage | Description |
---|---|---|
Logical AND (&&) | expr1 && expr2 | Returns expr1 if it can be converted to false; otherwise, returns expr2. |
Logical OR (||) | expr1 || expr2 | Returns expr1 if it can be converted to true; otherwise, returns expr2. |
也就是说运算符是返回这个表达式的值而非布尔值。由于逻辑表达式的这个特性,JavaScript里面才会有那么类似这样的利用逻辑表达式对变量进行初始化的语句:elem = elem || {};
。
位移运算符和算数运算符
先来一个Color
类的实现,其中有一个构造函数可以将整数表示的颜色值转成RGBA值进行表示,而另一个方法则希望将颜色的RGBA值转回整数表示。RGBA每个值都是0-255
,所以可以用一位的char
类型来表示,因为RGBA正好是四个又可以表示为4位的无符号整数。
[codesyntax lang=”cpp” lines=”normal”]
class Color { public: char r, g, b, a; public: Color(unsigned int c) { r = c >> 24; g = (c >> 16) & 0xff; b = (c >> 8) & 0xff; a = c & 0xff; } unsigned int toInt() { return (r<<24 + g<<16 + b<<8 + a); } };
[/codesyntax]
但是在使用过程中发现返回的整形与原来的值却不相同了
[codesyntax lang=”cpp” lines=”normal”]
int main(int argc, char const *argv[]) { unsigned int c = 0xFF00FFFF; Color color(0xFF00FFFF); cout << c << endl; cout << color.toInt() << endl; return 0; } //Output: //4278255615 //0
[/codesyntax]
怎么看这两个方法都是可逆的,也看不出来有什么问题呢,其实toInt()
方法里面 隐藏了很多的错误。
还是先说运算符的优先级问题,虽然代码让左移运算符跟变量和数字贴得更紧,而加法反而有空格相间,这不表明左移会先于加法被执行。看一下优先级表就知道其实算数运算符的优先级要高于位移运算符,所以实际上(r<<24 + g<<16 + b<<8 + a)
被计算机认作是(r << (24+g) << (16+b) << (8+a));
。这样的位移瞬间就让整数越界溢出,结果是0也变得显然。
所以括号很重要,必须显示且主动的表明优先级别:((r<<24) + (g<<16) + (b<<8) + a);
。
可是即使如此,输出结果依然不对:
4278255615
4278189823
如果把表达式拆成多行,慢慢调试查找,就会发现b<<8
出了错。这是因为里面有隐式的类型转换:b从char转成了位数更多的整形。而因为b
的值是0xFF
表示的是一个负数,因此转换时自动高位全部补1
变成了0xFFFFFFFF
而非0x000000FF
。所以对于RGBA的类型,应该定义成unsigned char
,或者short
,这样就不会出现负数的值了。
条件运算符
之前提到逻辑运算符在返回值上,各个编程语言有不同的结果。在结合律上,各个语言之间也会有些差异,条件运算符(?:)就是其中一个。
条件运算符可能是唯一的三元运算符,它的存在简化了if…else…
语言使得代码看起来更加简洁精炼。有时候我们还会嵌套使用条件运算符来,比如下边判定闰年的方法:
[codesyntax lang=”cpp” lines=”normal”]
bool isLeapYear(int year) { return (year%400 == 0? true :year%4==0? year%100 != 0 :false); }
[/codesyntax]
这个方法在C/C++,Java等中都可以很好的工作,但是在PHP中就执行就不正确了:
[codesyntax lang=”php” lines=”normal”]
function isLeapYear2($year) { return ($year%400 == 0? true :$year%4==0? $year%100 != 0 :false); }
[/codesyntax]
原因在于其它编程语言里,条件语言的结合律是自右向左,所以表达式被解读为:
(year%400 == 0? true : (year%4==0? year%100 != 0 :false))
偏偏PHP里面它是自左向右结合,因此在PHP里表达式变成了:
(($year%400 == 0? true :$year%4==0)? $year%100 != 0 :false);
才导致像2000
年也会被判定为非润年。
总的说来,在使用各种运算符的时候,要千万小心里面可能存在的陷阱,不能凭主观臆断,必要的时候就应该合理增加括号让表达式在运算时更加的明确,阅读时也更加清晰。