持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第27天,点击查看活动详情
前言
本文就来分享一波作者对表达式求值的学习心得与见解。
笔者水平有限,难免存在纰漏,欢迎指正交流。
表达式求值
表达式
表达式由运算符和运算对象组成。最简单的表达式是一个单独的运算对象。
下面是一些表达式:
4
-6
a*(b + c / d) / 20
q > 3
p = 5 * 2
运算对象可以是常量、变量或二者的结合。
每一个表达式都有对应值。
赋值表达式的值与运算符左边的值相同,也就是赋给的是什么表达式的值就是什么,如p = 6*7的值就是42。
关系表达式(如q>3)的值不是0就是1,关系成立就是1,不成立就是0。
语句
语句是C程序的基本构建块,一条语句相当于一条完整的计算机指令。在C语言中,大部分语句都以分号结尾。
比如legs = 4没有分号就只是个表达式。
最简单的语句是空语句:
;(只有一个分号)
C把末尾加上一个分号的表达式都看作是一条语句(表达式语句)。
有些语句实际上不起作用(如3+4;),算不上真正的语句,语句应该是可以改变值或调用函数的。
不是所有的指令都是语句,如:
x = 6 + (y = 5);
y = 5是一条完整的指令,但它只是语句的一部分。
复合语句使用花括号括起来的一条或多条语句,也被称为块。
C的基本程序步骤由语句组成,而大多数语句都由表达式构成。
表达式匹配——贪心法
你有没有想过这样一个问题:编译器是如何准确识别出表达式中的各个操作符的呢?
C语言中有这样一个规则:每一个符号应该包含尽可能多的字符。
也就是说,编译器将程序分解成符号的方法是:从左到右一个一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符时会判断已读入的两个字符组成的字符串是否可能是一个符号的组成部分,如果可能则继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已经不再可能组成一个有意义的符号,这种处理策略被称为“贪心法”。
要注意除了字符串和字符常量以外,符号的中间不能嵌有空白字符(空格、制表符、换行符等),比如:==是一个符号,而= =则是两个符号。
比如a+++10,在读到a+时,由于要读尽可能多的字符,所以会继续往后读也就是a++,此时若再往后读就无法组成有意义的符号了,所以a++是一个整体,原式相当于a++ + 10。
再比如a+++b,按照贪心法从左往右读的话那应该就是a++ + b,那万一我其实是想要a + ++b呢,这不就背离我的本意了嘛。
总而言之,这个规则并不保证表达式正确,只是“贪多”。
求值因素
表达式的求值有三个影响的因素。
操作符的优先级
操作符的结合性
是否控制求值顺序。
两个相邻的操作符先执行哪个?取决于他们的优先级。当运算符共享一个运算对象时,优先级决定了求值顺序。
如果两者的优先级相同,则取决于他们的结合性。
但是大部分运算符都没有规定同优先级下的求值顺序。
实际上只有逻辑与,逻辑或,条件操作符和逗号操作符控制求值顺序,其他操作符没有控制求值顺序,这就有可能产生一些问题代码。
例题分析
例1
a*b + c*d + e*f
其实计算路径不唯一,有两种顺序:
代码1在计算的时候,由于*比+的优先级高,只能保证,*的计算是比+早,但是优先级并不能决定第三个*比第一个+早执行,因为算术操作符并没有控制求值顺序,所以就可能会有多条计算路径。
结合性只适用于共享同一运算对象的运算符,例如,在表达式
12/3*2中,/和*优先级相同,共享运算对象3,因此从左往右的结合性起作用,表达式简化为4*2即8,但是如果从右往左计算的话会得到12/6即2,这种情况下计算的先后顺序会影响最终结果。对于如
y = 6 * 12 + 5 * 20;这样的语句,两个*运算符并没有共享同一个运算对象,因此左结合性不起作用,*优先级比+高,肯定先算乘法运算对吧,那到底先进行哪一个乘法呢?C标准未定义,实际上得根据不同硬件或编译环境来确定,只不过该例中先算哪个对结果没有影响而已。那万一有影响呢?
例2
int c = 2;
int d = c + --c;
自增自减与算术操作符复合的时候很容易产生歧义,因为求值顺序不是唯一的,实际上这类是C标准未定义行为,结果有多种可能值,与编译器有关(相当于甩锅给编译器)。
编译器:我太难了……ಥ_ಥ
+左边c的值是什么时候确定的呢?是--c后吗?还是在--c之前呢?因为没有统一的标准,所以都有可能。
若是--c后确定左边c的值,则1+1=2,若是--c前确定左边c的值,则2+1=3。
操作符的优先级只能决定自减--的运算在+的运算的前面,但是我们并没有办法得知,+操作符的左操作数的获取在右操作数之前还是之后求值,所以结果是不可预测的,是有歧义的。
例3
int main()
{
int i = 10;
i = i-- - --i * ( i = -3 ) * i++ + ++i;
printf("i = %d\n", i);
return 0;
}
这个就纯属恶心人的,估计能够到世界乱码大赛的门槛了(笑🤣)。
有前辈测试过,发现在各个编译器上的值都大不相同。
这段代码堪称究极反面教材,提醒我们不要自作聪明玩一些花里胡哨的东西(恼),没有什么实际意义,本身就存在歧义,可读性就更别提了,无论是学习还是工作都要极力避免写出过于复杂的表达式。
例4
int fun()
{
static int count = 1;
return ++count;
}
int main()
{
int answer;
answer = fun() - fun() * fun();
printf( "%d\n", answer);//输出多少?
return 0;
}
在VS下是先一个一个地进入fun()后再进行算术运算的,所以变成了2 - 3 * 4 = -10。
还可以这样,先分别调用后面两个fun()进行乘法运算后再调用前面的fun()再进行减法运算,就变成了4 - 2 * 3 = -2。
我们只能通过操作符的优先级得知:先算乘法,再算减法。
然而函数的调用先后顺序无法通过操作符的优先级确定
例5
int main()
{
int i = 1;
int ret = (++i) + (++i) + (++i);
printf("%d\n", ret);
printf("%d\n", i);
return 0;
}
没错又是自增的锅😅。
可以先执行前两个++i再执行(++i)+(++i)得到2+3,再执行最后一个++i后执行2+3+(++i)得到9
还可以先把三个自增表达式执行完后再执行加法运算,即4+4+4=12。
这段代码中的第一个 + 在执行的时候,第三个++是否执行,这个是不确定的,因为依靠操作符的优先级和结合性是无法决定第一个 + 和第 三个前置 ++ 的先后顺序。
总结
我们写出的表达式如果不能通过操作符的属性确定唯一的计算路径,那这个表达式就是存在问题的。
为什么会有问题,因为计算顺序对表达式的值有影响,一般都和++或--脱不了干系,因为自增自减会改变变量的值,导致不清楚什么时候确认变量的值。
所以我们尽量不要写太复杂的表达式,尤其是带有自增或自减操作符的。
遵循以下规则,不要滥用自增或自减运算符
1.如果一个变量出现在一个函数的多个参数中,不要对其使用自增或自减运算符。
2.如果一个变量多次出现在一个表达式中,不要对其使用自增或自减运算符。
深入理解——副作用与序列点
副作用:是对数据对象或文件的修改。
比如:states = 50;,副作用是将变量states的值设置为50。
具有副作用的操作符有赋值操作符以及递增、递减操作符,使用它们的主要目的其实就是利用它们的副作用。
序列点:是程序执行的点,在该点上,所有的副作用都在进入下一步之前发生。
语句的分号标记了一个序列点,意味着在一个语句中,赋值操作符、递增操作符和递减操作符对运算对象做出的修改必须在程序执行下一条语句之前完成。此外,任何一个完整表达式的结束处也是一个序列点。
完整表达式:是指这个表达式不是另一个更大表达式的子表达式。
比如一条表达式语句中的表达式或者控制语句中作为测试条件的表达式,这些都是完整表达式。
通过上面讲的几个概念,我们就能比较好地分析递增或递减何时发生。
例如:
while(guests++ < 10)
printf("%d\n", guests);
对于该例,有人会认为是“先使用后递增”,从浅层来看是这么回事,我在以前的文章内容中也是这样来分析问题的,只不过往深入了说其实要用序列点来分析。
guests++ < 10是while循环的测试条件,该表达式的结束处就是一个序列点,因此能保证程序在执行printf()之前发生副作用(递增guests),同时,使用后缀++保证guests在完成与10的比较之后才进行递增。
那再来看看这个例子:
y = (4 + x++) + (6 + x++);
4 + x++并不是完整表达式,所以无法保证x在该表达式求值后立即递增,完整表达式是整个赋值表达式,分号标记了序列点,这里只能保证在执行下一条语句之前对x递增两次,但并未指明是在对子表达式求值以后立即递增x还是待到所有表达式求值后再递增x,标准未定义该行为,所以在不同编译器下具体的实现也可能不同。
自增运算符计算路径问题
为什么计算路径不唯一?编译器识别表达式时,递增或递减是同时加载至寄存器还是分批加载不确定而导致计算路径不唯一。
为什么不确定呢?因为对此没有明确的规定,这也涉及到序列点的问题。
比如对于int b = (++i)+(++i)+(++i);,(++i)并不是一个完整表达式,而是作为整个表达式的子表达式,整个赋值表达式语句才是一个完整表达式,分号标记了序列点,也就是在执行下一条语句之前肯定会完成表达式中的三次自增,只是以怎样的顺序进行并没有被明确规定,到底是先把三次自增都进行完(同时加载至寄存器)再进行加法运算,还是先把前两个自增完(分批加载)后相加,再进行后面的自增,然后几个数相加,或者其他顺序都有可能。
具体实现细节因为标准未定义,相当于一股脑甩给了编译器,让它自己决定(编译器:我**&%¥#@),不同编译器下的过程可谓五花八门了。
以上就是本文全部内容,感谢观看,你的支持就是对我最大的鼓励~