Skip to main content

运算符(下)

求值顺序

求值顺序是独立于运算符优先级和结合性的存在,与这两者都没有关系。例如

f1() * f2() + f3();

函数f3()可能在f1()f2()之前、之后甚至之间调用——因为这就这个表达式的计算过程而言是不影响的。例如我先计算f3()也不影响我之后再算f1()f2()之后再先乘除后加减。但另一方面,如果这三个函数确确实实是有副作用的,例如修改了某个全局变量,进行了输入输出操作等,产生的结果却是是有可能不同的。例如

getchar() - getchar();

输入ab,你很难保证上面的表达式结果会是1还是-1。更有意思的是,下面这个表达式:

-(getchar() - getchar())

很有可能与前者有一模一样的结果。既然先求值谁是未定义的,也就是无所谓的,那么这个负号实际上就被编译器直接去掉了。


我们来看下面这个例子。

int i = 0, x[10], y[10];
while (i < 10)
x[i] = y[i++];

实际上,你不能保证是赋值符号左侧的i先求值还是右侧的i先求值。所以你面临着两种可能性,即x[i]=y[i]x[i + 1] = y[i]。同样的如果你把它对调变成x[i++] = y[i]也是不行的。解决这个问题最直接的办法就是老老实实把自增单独提出来。

for (int i = 0; i < 10; ++i)
x[i] = y[i];

或者,如果你实在是一个忠实的自增爱好者,你可以试试

int* p = x, q = y;
while (p != x + 10)
*p++ = *q++;

虽然这里两侧都进行了自增,但是是互不影响的,因此没有什么问题。

同理,下面的代码都是应该避免的,它们都会引发 UB。

i++ + ++i;
f(i++, i++);
f(i = 1, i = 2);
a[i] = i++;

解决这些求值顺序问题的方法呼之欲出:

  1. 把有副作用的语句单独拆开来,明确执行的顺序
  2. 借助有求值顺序的运算符来实现

此外,标准还是明确规定了四个运算符的求值顺序,它们是&&||?:,

逗号运算符

逗号运算符规定先计算左表达式,再计算右表达式,最后返回左表达式的值。而因为逗号运算符是左结合的,所以在一连串的逗号运算符连接的表达式,总是从第一个开始依次往后执行,并返回最后一个表达式的值。

逗号运算符的本意是在不方便插入多个语句的地方(例如for的最后一个子句里)插入多个表达式。因此逗号拥有最低的优先级。所以下面的代码

a = 1, 2

并不会把a赋值为2,因为它实际的含义是

(a = 1), 2

所以a12被直接丢掉了。

所以a, b == b为真,虽然确实是因为逗号运算符返回最后一个操作数,但不是因为(a, b) == b,而是因为a, (b == b)

需要注意的是,逗号运算符的解析,只在没有逗号要求的情况下发生:

f(1, 2, 3); // 调用 f(int, int, int),不是逗号运算符
g((1, 2, 3)); // 调用 g(int),是逗号运算符

类似的情况还在复合初始化、宏等地方出现。

这就需要我们注意到求值顺序的问题,不能想当然的把逗号运算符的求值顺序往一切逗号上面套:

x = 1, x = 2, x = 3; // OK, x = 3
f(x = 1, x = 2, x = 3); // UB!
a[x = 1, x = 2, x = 3]; // OK, a[x = 3]

注意最后一行不是多维数组的访问(这对有多维数组的语言使用者来说可能比较熟悉,但请别弄混了,这不是你想的那样),逗号在这里只是一个普通的运算符而已。

短路求值

短路求值,说的是,只有&&第一个操作数为真才回去求值第二个操作数,||只有第一个操作数为假才回去求值第二个操作数。这很容易帮助我们写出下面这样的代码:

if (y != 0 && x / y > t)
...

这里我们不用担心会发生除以零的问题——因为只有y != 0为真,x / y > t才会被求值。同样的,

if (ch || (ch = getchar()))
...

只有在ch'\0'的时候,才会调用getchar()读入一个字符。

你可能注意到一个细节——这样的形式完全可以单独拿出来用。(这里我们使用, 0的方法让结果一定可以参与逻辑运算)

cond() && (clause(), 0); // if (cond()) clause();
cond() || (clause(), 0); // if (!cond()) clause();

如果我们再大胆一点,也许我们可以进行进一步的组合。(想一想,为什么then_clause, 后面跟的是1?)

cond() && (then_clause(), 1) || (else_clause(), 0); 
// if (cond()) then_clause(); else else_clause();

等等,我们是不是有一种更直接的方法:(想一想,下面哪个括号是可以省去的?)

cond() ? (then_clause(), 1) : (else_clause(), 0); 

没错,你可能才意识到,其实条件运算符也完全具备短路求值的特性——如果条件的真假值没有确定,后面两个子句也不会被执行。即时确定了,也只会选择其中一个执行,另外一个不会执行。由此来看,条件运算符和if-else并没有本质上的区别。

条件运算符

不知道你注意到没有,条件运算符的结合性是从右向左的。也就是说,下面的代码

a > b && a > c ? a : b > c ? b : c

的含义是

a > b && a > c ? a : (b > c ? b : c)

而不是左结合的

(a > b && a > c ? a : b > c) ? b : c

这可能有些出会意料,可能没人会预料到出现左结合的形式,但是极个别语言(点名批评 PHP)还真就有这样的例外。

可以看到如果不加括号其实上面的代码是很难读的,不过条件运算符的结合性给了我们一种格式化的思路:

a > b && a > c 
? a
: b > c
? b
: c

或者把问号冒号都对齐,也是可以的。

你可能注意到赋值运算符和条件运算符微妙的关系了。在 C 语言里面条件运算符比赋值运算符优先级要高,因此下面的代码的含义显而易见

c = a > b ? a : b; // 选取 a 和 b 中的最大值

在 C++ 里,条件运算符和赋值运算符是同一个等级,但是上面句子的含义却没有变。因为它们都是右结合的运算符,所以即使优先级相同,结合性仍然保证了他们的先后顺序。例外出现在下面这个例子:

a > b ? c = a : c = b;

在 C++ 中它是合法的,被解读为

(a > b) ? (c = a) : (c = b);

但是在 C 语言里,因为条件运算符优先级较高,它的意思是

(a > b ? c = a : c) = b;

而 C 语言的三目运算符返回的是右值,因此编译失败。(而在 C++ 里,如果三目运算符两个分支都是左值,那么返回的也是左值,所以(a > b ? a : b) = c是可以的。)

你可能已经注意到了,问号和冒号之间天然的有一个括号的语义在里面,里面的优先级不会受到外部干扰。

位运算与逻辑运算符

位移运算

当你想要把整数的高位和低位拼在一起时,如果你这么写:

r = hi << 4 + lo; // 等价于 r = hi << (4 + lo)

lo == 0时,你还能得到正确的结果,但多数时候都不会那么侥幸。

除了加括号,这里还有一种比较合理的解决方法,就是全面使用位运算,这样也更符合直觉:

r = hi << 4 | lo; // 等价于 r = (hi << 4) | lo

在这里可能还算容易发现,怕就怕有人看到这样的代码:

apple = banana * 8 + 3;

突然想起,乘二的幂,就等于左移相同的位数啊!于是把它“优化“成了这样:

apple = banana << 3 + 3;

于是苹果就数量就从香蕉的八倍多三变成了六十四倍。

同样到道理适用于右移和除法,但是关于这两个运算符,还有更深层次的区别,参见这里

按位与、按位或

下面的代码检测flag的最低一位是否为1

if (flag & 1 == 1) 
...

这段代码可以工作的很好,但是当你想要改成检测最低两位时:

if (flag & 3 == 3) 
...

你会发现它还是检测的最低一位!问题出在哪?

没错,flag & 3 == 3的真实含义是flag & (3 == 3)!这也解释了为什么第一个例子意外地可以工作!

联系前面说的,建议对整数放在条件表达式里的情况加上!= 0,于是原本正确的

if (flag & MASK) // 等价于 (flag & MASK) != 0
...

就可能被“画蛇添足”地改进为

if (flag & MASK != 0) // 等价于 flag & (MASK != 0)
...

含义就悄悄地被改变了,只要MASK不为0(基本上没可能),这句话又成判断最低一位了。

除此之外,不要忘记三个位运算的优先级是不同的。

&&&|||

你可能会感到奇怪,为什么&|有如此低的优先级,相比于其他算术运算符,这两个算术运算符未免也太低了吧。答案是因为历史原因。C 的一个卓越贡献就是把逻辑(logical)运算符和按位(bitwise)运算符拆分成了两对不同的运算符,而不是混淆在一起——这是具有划时代意义的。成也萧何败萧何,正由于是从原运算符拆分而得,不便于与原运算符区别过大,所以保留了相近的优先级,成了数十年来的历史遗留。实际上,观察任何一个现代编程语言的运算符优先级和结合律,都或多或少可以看到 C 语言的影子。

既然当初决定把它们拆开,我们就更应该注意到,&&&不是等价的,|||不是等价的。这样的不等价体现在两个方面。

最直观的是运算结果的不等价。即使我们只关心结果的真假,也未必相同:例如(1 & 2) == 1为假(笔者写这个表达式的时候也忘记加括号了,就跟前面警告的一样,哈哈哈),但(1 && 2) == 1则为真(没错这个也要加括号,我又忘了,哈哈哈)。

更重要的是,&&||作为逻辑运算符,拥有短路求值的特性,这是&|所不具备的。

~!区别也很大,不容易混淆,这里就不再赘述了。

iso646

为了方便一些不支持某些特殊符号的键盘,C95 在<iso646.h>头文件中规定了下面的宏:(在 C++ 中,它们被直接定义为了关键字)

运算符
&&and
&=and_eq
&bitand
|bitor
~compl
!not
!=not_eq
||or
|=or_eq
^xor
^=xor_eq

其中and表示&&or表示||not表示!对于其他某些语言的用户来说是比较熟悉的(例如 Python)。但是我们仔细看就会发现:明明andor都是逻辑运算符,and_eqor_eq就摇身一变变成位运算符的复合赋值运算符了。明明位运算bitandbitor都有bit前缀,却偏偏xor没有。这是非常怪诞的,所以需要我们注意。