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

标题中这几个概念,是很多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++应该也有类似顺序点的规则。

原文发布于微信公众号 - zhisheng(zhisheng_blog)

原文发表时间:2016-05-02

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏企鹅号快讯

Python数据类型之字典

大家好 今天我们来共同探讨 Python的另外一种数据类型 字典 技术要点: 字典的定义 字典的基本使用 字典的特性 对于常规字典的定义 相信大家应该很熟悉 常...

38914
来自专栏贺贺的前端工程师之路

《JavaScript语言精粹》学习笔记

在JavaScript中,/ *可能出现在正则表达式字面量里,所以块注释对于被注释的代码块来说是<u>不安全的</u>。

742
来自专栏杨熹的专栏

2 天入门 Java-Day 2

第二天的课程明显就比第一天的要难了,? 表示很吃力,脑子不够用的节奏。 各种概念绕来绕去,脑袋都要绕成了壳。 不过还好没有放弃,想个办法画出各概念间的联系,...

3519
来自专栏MyBlog

Effective.Java 读书笔记(8)关于equals方法

重写equals看上去十分简单对吧,但是我觉得很多时候重写equals可能会招致一些问题,这些问题有时可能会特别严重,当然了不重写不就完事了吗?但是这只适用于那...

854
来自专栏非著名程序员

鸡蛋问题来了,是先有Class还是先有Object?

周末比较无聊,在浏览论坛的时候,偶然看到一个程序猿提问的问题,他时这样提问的:突然想到一个很菜的问题, 倒底先有Object还是先有Class?所有类都是Obj...

2036
来自专栏编程

浅谈如何定义和调用Python的函数

函数是python编程核心内容之一,笔者在本文中主要介绍下函数的概念和基础函数相关知识点。函数是什么?有什么作用、定义函数的方法及如何调用函数。 函数是可以实现...

1835
来自专栏移动端开发

swift 可选类型笔记

       晚上十一点半了,看书累了,原本想睡了的,想了想,还是把刚看的总结一下,把这篇笔记写了吧。广州下雨,真特么的冷。。好了,废话不说了,说正题说说Swi...

19310
来自专栏带你撸出一手好代码

正则表达式「^」符号的正确理解方式

「^」这个符号在正则表达式的中的应用相信是所有程序员都掌握的, 因为它是正则表达式中最基础最常用的知识点。 它在正则表达式中表示两种不同的意义 01 表示匹配一...

2813
来自专栏大数据和云计算技术

由快速排序到分治思想

算法是基础,小蓝同学准备些总结一系列算法分享给大家,这是第一篇《由快速排序到分治思想》,非常赞!希望对大家有帮助,大家会喜欢! 快速排序是一种基于分治思想...

3496
来自专栏Java帮帮-微信公众号-技术文章全总结

【Java提高四】接口与抽象类

【Java提高四】接口与抽象类 接口和内部类为我们提供了一种将接口与实现分离的更加结构化的方法。 抽象类与接口是java语言中对抽象概念进行定义的...

3696

扫码关注云+社区

领取腾讯云代金券