运算优先级、结合性、求值顺序、副作用和顺序点

标题中这几个概念,是很多C/C++程序员在表达式上容易出问题或不清楚的地方,虽然这些概念在很多语言都有体现,但C里面特别明显,所以就以C语言为例子总结下


运算符优先级比较简单,就是指在一个存在多个运算的表达式中,各运算的计算先后顺序,比如a+b*c是先算乘法等。而结合性就是指优先级同级的运算连续的时候,从左到右还是从右到左
然而就是这么两个最简单的概念,如果去网上搜,或一些C语言的书籍(有的还很有名),得到的结果也只是“大体相同”,对于常用的很多运算是没有什么争议,差别主要在优先级比较高的运算符上。最早引起我对这个问题注意的是《C陷阱与缺陷》一书,但这书的表格是不准确的,最准确的说法还是应该从C标准文档中找答案,不过不知道为什么,我找到的英文版的标准(参考了C89和C99)都没有找到这样一张表格,后来是在一个C89的按词条详解的中文电子书中找到一个比较准确的表格,不过这个表格没有包括C++的一些特性,而且有一个问题,综合来看,wikipedia上的内容最全,也最准确,但注意一定不能只看表格,还要看前后的一些注释,而且要看英文页面,中文的内容不全


先不考虑C++和后面标准新增的一些运算符,这方面的资料存在两个问题,第一个是++和--运算是否区分前缀/后缀的问题,在《C陷阱与缺陷》一书以及某些资料中,不区分前缀后缀,统一将这俩作为单目运算符,优先级排第二,且第二级运算符是右结合(第一级是[],(),.,->,左结合)
而标准的说法是,++和--区分前缀后缀,其中后缀运算优先级高于前缀,也就是说,后缀++和--是和上述四个一级运算符同级,且左结合,而前缀++和--是和其他单目运算符同级(其他单目运算符都是前缀形式),且右结合
一个合格的C编译器应该按照标准规定来解析表达式,不过对于很多表达式来说,第一种说法也能说得通,比如*p++先算++,按标准的话,这是因为后缀++优先级比*高,而按第一种说法,*和++同级,但是右结合,所以是先算++,看上去也有道理,不过考虑这三个式子(p是一个int指针):
++p[0]
p++[0]
p[0]++
第一个和第三个很常见,用第一种说法也能解释得通,但第二个呢?第二个是一个合法的C表达式,相当于:
tmp = p, p += 1, tmp[0]
这时候,如果认为->比++优先级高,就无法解释了,所以这个问题上,标准将其分为前后缀并规定不同优先级是对的


资料的第二个问题是,很多资料只给出一个简单的优先级表格,缺少对应的注释,这就容易给人造成一些误解,因为表达式如何编译不是单纯依靠优先级,还有一些细节上的特殊规定,例如,关于类型强制转换,这是一个单目运算符,而且和后缀++和--之外的其他单目运算符同级且右结合,乍一看好像没啥问题,但考虑这个式子:
sizeof(int)*p
若单纯按优先级规定,sizeof是一个运算符,和类型转换、解引用运算“*”都是单目运算符且同级,则这个式子就是先对p解引用,然后强制转换为int,然后进行sizeof运算,但如果到编译器试一下就知道了,实际的行为是对int类型进行sizeof然后乘以p,因为还有一条特殊规定:不能对一个不带括号的强制类型转换表达式做sizeof,否则强制类型转换的运算符会视为sizeof的参数,且sizeof如果是对类型做运算,必须加括号
上面说的C89的词条详解大概就注意到这个问题,将强制类型转换运算单独拉出来降了一级,可惜造成了更严重的问题,比如:
~(int)1.0
这个表达式计算显然是先强转为int,所以强转运算直接降一级是有问题的
《C Primer Plus(第五版)》就折中了一下,在表格中将这个运算单独降级,同时还保留在原先的级别,即在表格中出现两次,我猜作者是想用这个方式表示在不同情况下的不同行为,可惜没有详细的说明,不懂的人看了还是一头雾水
除了类型强转外,还有一些特殊规定,具体可以参考wikipedia上的注释,不过我也不清楚上面是否写全了,最准确的应该还是C标准了


运算符优先级和结合性规定了一个整体的计算顺序,但并不完全,对于参与计算的各运算分量的求值顺序,很多时候是没有定义的,比如:
f()+g()
由于()比+优先级高,所以按标准规定,先执行两个函数调用,然后将结果加起来,这个顺序是确定的,但是,f()和g()谁先谁后是没有定义,某些编译器下可能先执行f,另一些可能先执行g,都是符合标准的
C语言中,对运算分量的求值顺序做了规定的只有四个运算符:&&,||,?:和“,”,其中前两个都是先算左分量,根据结果短路,第三个是先算第一分量,再根据结果决定计算第二或第三分量,逗号运算符则是依次从前往后运算。其余运算都没有定义,最常见的误解是赋值运算,比如:
a[f()] = g()
这里f和g谁先谁后也是看编译器的
如此规定可以给编译器更大的优化空间,因为不同的求值顺序可能效率不同,例如:
f(a, b, a+1, b+1)
这个函数调用需要传入四个参数,则编译器可以优化流程:先将a读入eax,写入第一个参数位置,eax加一,写入第三个参数位置,然后对b做同样操作,这样就只需要一个寄存器了,当然,实际寄存器数量不会这么少,所以这种优化一般是在参数很多且有关联的时候。经试验,gcc会优化,vs似乎没有


某些书讲到了求值顺序的问题,于是举出下面的例子:
int a = 10;
cout << (a-- * a) << endl;
它说,由于乘法两个分量求值顺序不同,若先算a--则是10*9=90,而先算a则是10*10=100
然而这种说法是错的,上面这个代码的输出的确可能是90或100,但跟求值顺序没有关系,而是涉及副作用和顺序点两个概念,这两个概念混淆的人相对更多一些


所谓副作用,是指一个表达式求值过程中改变了执行环境,比如修改内存的值,文件内容,以及调用包含副作用的函数等,不过简单起见,一般只讨论给变量赋值这种副作用,简单说就是++,--和赋值运算,“副作用”这个词的意思是说,表达式的“首要工作”是求值,而改变变量的值只是“兼职”,所以称为副作用,所以C语言的赋值运算首先是一个需要求值表达式,其次才是一个赋值操作,赋值只是求值过程的副产品。当然这只是看法和称呼上的差别了


如果一个表达式可能有多个副作用,C语言规定,只有在顺序点的时候,才保证之前的副作用被执行,顺序点又叫序列点,可以看做是执行过程中的一个状态,在达到这个状态的时候,之前所有的副作用都被执行完毕,即该赋值的都赋值,而如果一段执行没有碰到顺序点,则副作用在什么时候执行,完全看编译器实现,标准不管的


C语言规定了如下顺序点:
1 函数调用的所有变元求值完毕时
2 &&,||,?:,“,”四种运算的左运算分量(对于?:是第一运算分量)求值完毕时
3 一个变量的初始化完成时
4 单独作为一条语句的表达式求值完毕时
5 switch、while、do while、if等语句的控制表达式求值完毕时
6 for语句的三个表达式的每一个求值完毕时
7 return语句的表达式求值完毕时


其余情况下,编译器可以自由安排副作用,有时候这种安排会让人非常意外,比如:
int a = 100;
a = a++ / 3;
cout << a << endl;
这段代码关键在于第二句,可能很多人觉得应该是这么算:
int tmp = a;
a += 1;
a = tmp / 3; //最后输出33
然而在vs和gcc下测试,最后的输出是34,因为编译器是这么干的:
a = a / 3;
a += 1; //最后输出34
这个例子显示,副作用和运算符优先级没有任何关系,虽然++“运算”一定要在除法之前执行,但++的副作用却可以延后执行,而由于后缀++的返回值就是a原本的值,所以编译器直接优化,根据上面的规定,这句语句就是一个表达式,所以顺序点在求值完毕后,两个副作用(a++和给a赋值)只要在顺序点之前,也就是下一条语句开始之前执行即可,其执行顺序是可随意安排的,所以,虽然++比除法和赋值的优先级都高,但赋值的副作用却先于++执行,尽管如此,这个表达式的值还是33,因为赋值语句的值是等号右边表达式的值,也就是说,如果第二句改成:
cout << (a = a++ / 3) << endl;
则代码在我的vs和gcc环境下会分别输出33和34,而在其他一些编译器下,则有可能输出两个33


不消说,工作中这种代码是不应该写的(上述代码gcc在-Wall下会warning),如果面试碰到了,则要么面试官自己有问题,要么他真的想考副作用和顺序点的知识了


由于C语言有这些容易出问题的语法,很多语言在这方面都不会提供很灵活的语法,或者增加一些严格的执行次序规定,但也要尽量避免类似代码,比如python的连续赋值:
f().a = g().b = 123
可以猜猜f和g谁先执行


C++中有一个和这有关的问题,比如写出如下代码:
cout << string("hello").c_str() << endl;
这是构造了一个临时string对象,通过c_str方法取到指向其内容的const char *指针,那么问题就在于,这个临时对象什么时候析构,如果在c_str()执行完后就析构,那返回的指针就悬空了,可能会崩溃,幸运的是,这个临时对象是在整个语句执行完后才析构的,对于临时对象的析构,C++应该也有类似顺序点的规则
  • 3
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值