关于C语言中表达式运算顺序(优先级、结合性、副作用、序列点)的讨论以及c语言未定义行为

什么是优先级

到底什么是c语言的优先级?
我以前看了国内的很多教程,对于优先级的描述大多都是这样说的“优先级是用来决定当多个运算符出现在同一个表达式中时,先执行哪个运算符”。其实这是个错误的认知
比如以下的c代码

#include<stdio.h>
int main() 
{
  int a = 3, b = 2;
  b = a < 0 && a++ > 3;
  printf("a1 = %d , b1 = %d", a, b);
  a = 3, b = 2;
  b = 1 || (a = 1) && (a += 1);
  printf("a2 = %d , b2 = %d", a, b);
  return 0;
}

提示: 优先级 && > || > =/+=
按照国内教程所说的意思,最后的运行结果肯定是 a1 = 4,b1 = 0,a2 = 2, b2 = 1 但是编译器实际输出 a1 = 3,b1 = 0, a2 = 3, b2 = 1
我刚刚开始遇到这个问题的时候也很懵逼???到底为什么会这样???如果编译器是对的那么它的执行思路到底是什么样的?

想要解决这个问题,这就涉及c语言的标准了

C语言标准规定:表达式的结合次序取决于表达式中各种运算符的优先级,其中优先高的运算符优先结合,优先级低的运算符后结合

其次“优先级实际上体现的是一种在运算正式开始之前的预处理,即在预处理阶段会把优先级较高的运算符以及其左右操作符俩边加括号”
例如表达式a+b*c由于 * 优先级比 + 高,所以表达式在预处理阶段被处理成这样 a + (b * c):


显然c语言标准中,优先级只描述的是运算符和操作对象结合的方式(分组方式),并没有规定他先运算什么后运算什么,这取决于编译器的具体优化策略。一条表达式的运算先后顺序只是结合方式的附加产物而已。

看完了标准我们再对 1 | |(a = 1) && (a += 1) ;进行讨论

1.首先由于优先级  && > ||   所以&&左右俩边的操作数会优先结合成为一 个子表达式(在这里我只对=右边的表达式进行讨论,因为等号不会对表达式求值造成影响,具体原因请看关于C语言中a[i] = i++;和a[i++] = i;为什么是未定义行为的讨论)

int b = 1 ||((a = 1) && (a += 1));

2.其次由于   || > =/+=   所以 = 和 += 左右俩边的表达式要结合

int b = 1 ||((a = 1) && (a += 1));

3.当所有的优先级括号都加完了,表达式基本上都会被简化为 (xxx)   运算符   (xxx)

xxx就是表达式中由优先级判定先被结合的所有子表达式的集合也可以是一个单一的操作数(所谓的单一操作数通常指的是单一变量或者常量)
注意:操作符的任意操作数可为空,即 (xxx) 操作符 或者 操作符 (xxx) 类型

int b = 1 ||((a = 1) && (a += 1));
//本例中的 ((a = 1) && (a += 1))  &&和左右俩边子表达式组合在一起就是括号内的xxx(即所有子表达式的结合)

4.当表达式被简化为 (xxx)   运算符   (xxx) 时,需要依靠运算符来判断整个表达式的运算顺序,当操作符为&&、||、三元运算符、,(逗号运算符)时,c语言标准做出了规定保证这4个运算符的运算顺序是从左到右,如果最后简化的运算符是这4个运算符,那么该表达式的运算顺序也必须是从左到右,如果不是这4个运算符,那么运算方向是不确定的,可以从左到右也可以从右到左

int a = 3, b = 2;
int b = 1 ||((a = 1) && (a += 1));

这里的表达式被简化为 1 || (XXX) 由于c语言规定了 ||的运算方向,所以这个表达式运算方向肯定是从左往右
即 先算1 || (XXX) 但是由于||短路特性,|| 算完左边的操作数就直接短路了所以得出**a2 = 3, b2 = 1


我们再对b = a < 0 && a++ > 3;进行讨论

1.首先依靠优先级对表达式进行加括号处理结果为

b = ((a < 0) && ((a++) > 3));

在这里表达式被化简为 b = (xxx)的形式,由于C标准对 = 操作数做出了规定:左操作数和右操作数的运算顺序是不确定的 ,但是在进行赋值时,运算的顺序必须是将右操作数给到左操作数 如果看不懂这句话的可以这篇文章关于C语言中a[i] = i++;和a[i++] = i;为什么是未定义行为的讨论

2.由于 = (赋值操作符)在进行最后的赋值运算的时候,运算顺序必须是 从右往左 ,所以 =(赋值操作符)不会影响右边表达式的运算顺序 ,所以直接来看右边的表达式就行了即  (a < 0) && ((a++) > 3),表达式最后被化简为 (xxx) && (xxx) ,而c语言规定了&&运算符的运算方向必须是 从左到右 ,所以表达式的运算方向也是从左到右 ,即先算(a < 0)结果为假,由于 短路特性 这里计数就终止了,表达式结果返回 0 即a1 = 3,b1 = 0;

什么是结合性**

所谓的结合性也不是国内劣质教材所说的“就是当一个表达式中出现多个优先级相同的运算符时,先执行哪个运算符:先执行表达式左边的叫左结合性,先执行表达式右边的叫右结合性。”

C语言标准规定,结合性仅在表达式中出现多个相同优先级运算符的情况下才有意义,当出现表达式中有多个优先级相同的运算符时候,表达式的结合次序由结合性决定

看完标准我们来举个例子更好说明

a + b + c + d;

由于表达式的优先级都一样所以这时候只能用结合性来判断结合次序("+"为左结合性)结果如下

(((a + b) + c )+ d);

最后总结一下优先级和结合性,通过上面的例子,我们可以知道所谓优先级和结合性,它们仅仅是用来判断表达式的结合次序的,并不是用来判断表达式的运算顺序,而表达式的运算顺序只有&&、||、三元运算符、,(逗号运算符)都是从左到右,以及序列点会影响到表达式的运算顺序

C语言中的副作用

c语言标准规定:访问易失性对象、修改对象、修改文件或调用执行任何这些操作的函数都是副作用,它们是执行环境状态的变化

1.1修改对象
        修改对象可以理解为更改(更新)该对象存储字节的内容

  其中包括
    1.简单赋值 ( =) 和复合赋值 ( *=, /= %=, +=, -=, <<=, >>=, &=,^=和|=)。
    2.递增和递减运算符(++和–),包括前缀和后缀。
    3.定义的同时进行初始化的对象。
    4.c标准库中专门用于修改对象的库行数,例如memcpy.

1.2访问易变对象
        在c语言中访问volatile类型的变量会产生副作用

1.3修改文件
        在c语言中使用<stdio.h>库中的修改文件的函数会产生副作用 例如:fopen()函数

1.4注意点
        调用或者执行拥有这些操作的函数也属于产生了副作用

C语言中的序列点

c语言标准规定:执行序列中某些特定的点被称为序列点。在序列点上,该点之前所有运算的副作用都应该结束,并且后继运算的副作用还没发生。

关于序列点有了标准文档的定义还不够,因为你单靠定义很难在程序中判断出哪些点是序列点,所以标准文档把c语言常见序列点都给写了出来,它们分别是

1.逻辑“与”运算符 (&& ) 的左操作数结尾处有一个序列点。由于&&的短路性质即(如果左操作数的计算结果为 true(非零),则不计算另一个操作数。)C语言标准保证&&运算符的运算方向必须为从左到右,所以在左操作符结束以后必须保证副作用全部结束

int a, b, c;
c = a && b;
//当表达式执行完a时立刻有一个序列点

2.逻辑“或”运算符 (||) 的左操作数结尾处有一个序列点。 由于||的短路性质即(如果左操作数的计算结果为 true(非零),则不计算另一个操作数。)C语言标准保证&&运算符的运算方向必须为从左到右,所以在左操作符结束以后必须保证副作用全部结束

int a, b, c;
c = a || b;
//当表达式执行完a的时候立刻有一个序列点

3.逗号运算符的每个左操作数。C语言标准规定逗号运算符的运算方向为从左到右,所以每个逗号在逗号表达式中都是一个序列点,因为编译器要保证每个左操作数在结束后所有的副作用都必须完成。请注意,函数调用中的逗号运算符作为分隔符使用不保证计算顺序。

int a, b, c, d, e,f, g;
a = (a, b, c, d, f, g);
//a、b、c、d、e、f、g执行完且在执行下个逗号运算符之前有个序列点

4.在函数的实参求值列表之后且在执行该函数的第一条代码之前有一个序列点,c语言保证在调用函数时,执行函数的函数体之前会有一个序列点以保证调用函数体之前的实参列表的副作用均已完成

5.条件运算符的第一个操作数的结尾有一个序列点。 c语言标准保证条件运算符的第一个操作数结束后的所有副作用都必须完成。

int a, b, c, d;
d = a ? b:c;
//当表达式运行完a时侯还没运行到b或者c时会有个序列点 

6.完全初始化表达式的末尾

int x = 10;
//在表达式执行完,且在运行到;之前会有个序列点

7.在声明序列的每个声明之间

int a[10], b[20];
//a[10]后面是个序列点,b[20]后面也有个序列点

8.表达式语句中的表达式。 表达式语句由表达式和后面跟的分号 (; ) 组成。在表达式和其后面跟的(;)中间有一个序列点。

9.选择语句(if 或 switch)的控制表达式结尾处。 由于语句的执行逻辑的需要。定义顺序点是为了尽量消除编译器解释表达式时的歧义

int x = 5;
if (x > 0) //x > 0执行完立刻有一个序列点 
{
    printf("x is positive\n");
} else {
    printf("x is not positive\n");
}
x = 2;
switch (x) //x执行完立刻有一个序列点
{
    case 1:
        printf("x is 1\n");
        break;
    case 2:
        printf("x is 2\n");
        break;
    case 3:
        printf("x is 3\n");
        break;
    default:
        printf("x is not 1, 2 or 3\n");
}

10.while 或 do while语句的控制表达式结尾处。 由于语句的执行逻辑的需要。定义顺序点是为了尽量消除编译器解释表达式时的歧义

int i = 0;
while (i < 10)//当i < 10执行完后立刻有一个序列点 
{
    printf("%d\n", i);
    i++;
}
int i = 0;
do{
    printf("%d\n", i);
    i++;
} while (i < 10);//当i < 10执行完后立刻有一个序列点 

11.for 语句的所有三个表达式的每个表达式的结尾处。 由于语句的执行逻辑的需要。定义顺序点是为了尽量消除编译器解释表达式时的歧义

for (int i = 0; i < 10; i++)//每个表达式执行完且在执行到;之前立刻有一个序列点,除了i++这个表达式执行完立刻有一个序列点 
{
    printf("%d\n", i);
}

12.return 语句中的表达式。 c语言标准规定return语句和(;)之间有个序列点,是为了让函数体内的表达式在返回主函数之前的副作用全部结束。这样做能消除编程时的歧义。

#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;//return执行完后在执行;之前立刻有一个序列点
}

13.库函数结束之后返回主函数之前的位置有一个序列点,这条规则包含着第6条规则,因为库函数返回之前必然会结束掉一个完整表达式。

14.在与输入/输出格式说明符相关的每次转换之后有一个序列点

int a =100printf("a = %d, %lf", &a, 42.1);在函数执行过程中执行完第一个%d接下去执行%lf的中间有一个序列点

15.在调用比较函数中的回调函数(定义排序规则的回调函数)在调用之前和之后会有个序列点,且传递给函数的对象在进行排序时候,每次移动对象元素时都会一个序列点


除此之外C标准还对存在于俩个连续的序列点之间的表达式做出了几个规定

1.C语言标准规定在上一个和下一个序列点之间, 一个对象所保存的值至多只能被表达式的计算修改一次。而且前一个值只能用于决定将要保存的值。

2.C语言标准规定在上一个和下一个序列点之间,表达式中的所有副作用发生的顺序是不确定的

例子:i = i++ + i++ + i++;
解释:这个表达式是一个未定义行为有俩个错误
第一个由于对i多次自增违反了俩个序列点中一个对象只能对其的值修改一次
第二个由于多次自增所以该表达式出现了多个副作用,在俩个序列点中没办法确定哪个副作用先执行
综上所述这个表达式是一个未定义表达式,在不同编译器上会编译出不同结果

  • 11
    点赞
  • 51
    收藏
    觉得还不错? 一键收藏
  • 10
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值