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

本文深入探讨了C语言中运算符优先级与结合性的概念,分析了常见的理解误区,并介绍了求值顺序、副作用及顺序点等重要知识点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

标题中这几个概念,是很多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++应该也有类似顺序点的规则
<think>好的,用户想了解C语言中编译器对表达式求值顺序的规则。首先,我需要回忆相关知识,并结合提供的引用内容来组织答案。 首先,C语言的表达式求值顺序确实不同于运算符的优先级结合。用户可能混淆了这些概念,需要明确区分。根据引用5,运算优先级结合决定了运算顺序,但子表达式的求值顺序是不确定的。比如,在表达式f1() + f2()中,虽然加法是左结合的,但f1f2的调用顺序编译器决定,这可能导致不同的结果如果它们有副作用。 接着,顺序的概念是关键。引用4提到顺序是C/C++中的一个重要概念,副作用(如变量的修改)必须在顺序之前完成。例如,分号、逗号运算符、逻辑运算符等位置都是顺序。在两个顺序之间,子表达式的求值副作用顺序是不确定的。比如x = x++这样的表达式,由于在两个副作用之间没有顺序,会导致未定义行为。 然后,用户可能需要知道为什么C语言这样设计。根据引用12,C/C++为了效率允许编译器自由调整求值顺序,这样编译器可以优化代码,提高效率。而像Java这样的语言严格规定顺序,牺牲了部分效率但保证了行为明确。 另外,需要提到未指定行为未定义行为的区别。引用34指出,依赖于求值顺序的表达式结果是不确定的(unspecified),而如果同一变量被多次修改而没有顺序,则是未定义行为(undefined),可能导致任何结果。 最后,应该给出实际例子,如a + b * c的优先级问题,以及像func(a++, a)这样的问题,说明参数求值顺序的不确定,并强调避免编写依赖求值顺序的代码。 需要确保回答结构清晰,分说明,并引用相关引用来支持观。同时生成相关问题,帮助用户深入理解。</think>### C语言表达式求值顺序规则 C语言中表达式求值顺序的规则涉及**运算优先级**、**结合****顺序**三个核心概念,但其子表达式的求值顺序具有**不确定**,这是语言设计有意为之的特[^1][^2][^3]。 --- #### 一、核心规则 1. **运算优先级结合** - 优先级决定运算符的运算顺序(如乘法 `*` 优先级高于加法 `+`),结合决定相同优先级运算符的执行方向(左结合或右结合)[^5]。 - 例如:`a + b * c` 等价于 `a + (b * c)`;`a = b = c` 等价于 `a = (b = c)`(赋值运算符右结合)。 2. **子表达式求值顺序未定义** - 同一表达式中的子表达式(如函数调用、变量自增等)的求值顺序**由编译器自由决定**。例如: ```c int a = 1; printf("%d %d", a++, a); // 输出结果可能是 "1 2" 或 "2 2",取决于编译器实现 ``` 此处参数 `a++` `a` 的求值顺序未定义[^2][^5]。 3. **顺序(Sequence Points)** - 顺序是程序执行中的特定位置,要求在此前的所有副作用(如变量修改)必须完成[^4]。 - 常见顺序包括:分号 `;`、逻辑运算符 `&&`/`||`、逗号运算符 `,`、函数调用前的参数求值完成等。 - 例如:`(a++ && b++)` 中,`a++` 的副作用在 `&&` 处(顺序)完成,之后再求值 `b++`。 --- #### 二、关键限制与风险 1. **未指定行为(Unspecified Behavior)** - 若表达式结果依赖于子表达式求值顺序(如 `func(a++, a)`),则结果未指定,不同编译器可能输出不同值[^3][^5]。 2. **未定义行为(Undefined Behavior)** - 若在同一表达式中对同一变量多次修改且无顺序分隔,则行为未定义。例如: ```c int i = 0; int j = (++i) + (++i); // 未定义行为,可能输出 3 或 4 ``` --- #### 三、设计哲学与效率权衡 C语言通过**不严格规定求值顺序**,允许编译器优化指令序列以提升效率。例如,编译器可调整内存访问顺序以减少冗余操作[^2]。相比之下,Java等语言严格规定顺序以保证可预测,但牺牲了部分能。 --- #### 四、编程建议 1. **避免依赖求值顺序** 如 `x = x++` 或 `func(a++, a)` 等代码应彻底禁止。 2. **拆分复杂表达式** 将多步骤计算分解为多个语句,明确顺序: ```c int tmp1 = a++; int tmp2 = a; printf("%d %d", tmp1, tmp2); // 明确输出 "1 2" ``` ---
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值